From cb6161ce4cbad3b4698c4e79fa27959f23f9ee81 Mon Sep 17 00:00:00 2001 From: cewing Date: Sat, 8 Feb 2014 11:20:53 -0800 Subject: [PATCH 001/223] pep8 fixes --- resources/session06/microblog/microblog_tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/session06/microblog/microblog_tests.py b/resources/session06/microblog/microblog_tests.py index 2363655d..3e910226 100644 --- a/resources/session06/microblog/microblog_tests.py +++ b/resources/session06/microblog/microblog_tests.py @@ -6,6 +6,7 @@ import microblog + class MicroblogTestCase(unittest.TestCase): def setUp(self): @@ -82,7 +83,7 @@ def test_login_passes(self): def test_login_fails(self): with self.app.test_request_context('/'): - self.assertRaises(ValueError, + self.assertRaises(ValueError, microblog.do_login, microblog.app.config['USERNAME'], 'incorrectpassword') From 0a744473d19111f12cd1d59a563af3d6cb1e41ba Mon Sep 17 00:00:00 2001 From: cewing Date: Sat, 8 Feb 2014 11:21:29 -0800 Subject: [PATCH 002/223] complete the minimal slides for session06 --- source/presentations/session06.rst | 55 ++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/source/presentations/session06.rst b/source/presentations/session06.rst index 18e617e5..068fb965 100644 --- a/source/presentations/session06.rst +++ b/source/presentations/session06.rst @@ -74,7 +74,7 @@ One of you will start as the driver, the other as the observer. .. class:: incremental -About every 20 minutes, we will switch, so that each of you can take a turn +About every 20-30 minutes, we will switch, so that each of you can take a turn driving. @@ -85,15 +85,56 @@ In order for this to work properly, we'll need to have a few things in place. .. container:: incremental - First, you'll all need to make sure that you have the very latest code from the - class repository available on your local machine:: + First, we'll start from a canonical copy of the microblog. Make a fork of + the following repository to your github account:: - $ git add remote uwpce git@github.com:UWPCE-PythonCert/training.python_web.git + https://github.com/UWPCE-PythonCert/training.sample-flask-app .. container:: incremental - First, you both will need to make a branch of the class repository that you - can work on:: + Then, clone that repository to your local machine:: - $ git checkout -b session06-class + $ 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 + + First, add your partner's repository as ``upstream`` to yours:: + + $ 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 + + Then, fetch their copy so that you can easily merge their changes later:: + + $ git fetch upstream + +While You Work +-------------- + +Now, when you switch roles during your work, here's the workflow you can use: + +1. The current driver commits all changes and pushes to their repository:: + + $ git commit -a -m "Time to switch roles" + $ git push origin master + +2. The new driver fetches and merges changes made upstream. + + $ git fetch upstream master + $ git branch -a + * master + remotes/origin/master + remotes/upstream/master + $ git merge upstream/master + +3. The new driver continues working from where their partner left off. From 4c0d97950704a05f284ecff81fb84fa664efab85 Mon Sep 17 00:00:00 2001 From: cewing Date: Sat, 8 Feb 2014 12:42:03 -0800 Subject: [PATCH 003/223] more updates --- source/main/outline.rst | 36 ++++++++++++----------- source/presentations/session06.rst | 46 +++++++++++++++++++++++------- source/presentations/session07.rst | 16 ++++------- 3 files changed, 62 insertions(+), 36 deletions(-) diff --git a/source/main/outline.rst b/source/main/outline.rst index db8315b8..8fefabf8 100644 --- a/source/main/outline.rst +++ b/source/main/outline.rst @@ -110,7 +110,7 @@ using WSGI and see what benefits and drawbacks it confers. * `As Plain HTML `_ Homework Tutorials -++++++++++++++++++ +****************** Please walk through all three of these tutorials before session 5 begins. @@ -131,9 +131,15 @@ implementation of a *microframework*, `Flask `_. We'll install the framework and take a look at how it works. What does it have in common with work we've already done? -Along the way we'll learn about Jinja2, the templating language that Flask -uses, and a bit about the DBAPI2 and communicating with SQL databases from -within Python. +Finally, we'll spend our class implementing a simple application using Flask. +We'll decide the functionality we need, write tests to demonstrate that it +works, and then write the code to make the tests pass. When we finish, we'll +have a tested, simple app that we can view through the browser. + +Along the way, we'll learn a bit about how flask operates in a real +application. We'll learn some more about the Jinja2 templating language, and +we'll learn to tie the transactions of our database interaction to the cycles +of request and response. `Lecture Slides `_ @@ -141,20 +147,18 @@ within Python. Session 6 - A Flask Application ------------------------------- -In this class we will exercise our new-won knowledge by building a small -application using Flask. We'll write templates and forms, persist data, -implement login and logout. When we're done, we'll have a fully-functional -microblog. +During this class, we will explore the technique of `pair programming`_ in the +process of extending and improving our Flask application. Students will divide +into pairs and each pair will work together to implement one or more new +features for the Flask app we finished in the previous class. -We'll use a test-driven development style as we go. We'll decide the -functionality we need, write tests to prove it works, and then write the code -to make those tests pass. We'll be using the ``unittest`` module from the -Python Standard Library. +Along the way, we'll gain insight into how to build a more complex Flask +application, how to integrate with existing front-end design frameworks, and +even how to use alternate storage strategies. Most importantly, we'll gain a +bit of experience in the workflow of a small team as we explore how to share +our work quickly across different environments. -Along the way, we'll learn a bit more about how flask operates in a real -application. We'll learn some more about the Jinja2 templating language, and -we'll learn to tie the transactions of our database interaction to the cycles -of request and response. +.. _pair programming: http://en.wikipedia.org/wiki/Pair_programming `Lecture Slides `_ diff --git a/source/presentations/session06.rst b/source/presentations/session06.rst index 068fb965..636b608e 100644 --- a/source/presentations/session06.rst +++ b/source/presentations/session06.rst @@ -83,16 +83,22 @@ Preparation In order for this to work properly, we'll need to have a few things in place. -.. container:: incremental +.. 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:: + the following repository to your github account: + + .. code-block:: + :class: small https://github.com/UWPCE-PythonCert/training.sample-flask-app -.. container:: incremental +.. container:: incremental small + + Then, clone that repository to your local machine: - Then, clone that repository to your local machine:: + .. code-block:: bash + :class: small $ git clone https://github.com//training.sample-flask-app.git or @@ -104,31 +110,49 @@ 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 +.. container:: incremental small + + First, add your partner's repository as ``upstream`` to yours: - 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 +.. container:: incremental small - Then, fetch their copy so that you can easily merge their changes later:: + 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: -1. The current driver commits all changes and pushes to their repository:: +.. 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 -2. The new driver fetches and merges changes made upstream. +.. class:: small + +2. The new driver fetches and merges changes made upstream: + +.. code-block:: bash + :class: small $ git fetch upstream master $ git branch -a @@ -137,4 +161,6 @@ Now, when you switch roles during your work, here's the workflow you can use: remotes/upstream/master $ git merge upstream/master +.. class:: small + 3. The new driver continues working from where their partner left off. diff --git a/source/presentations/session07.rst b/source/presentations/session07.rst index 77d7975b..85692503 100644 --- a/source/presentations/session07.rst +++ b/source/presentations/session07.rst @@ -108,20 +108,16 @@ Popularity translates into: Active Development ------------------ -Django releases in the last 12+ months: +Django releases in the last 12+ months (a short list): .. class:: incremental -* 1.5.1 (March 2013) +* 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.5 (February 2013) -* 1.3.7 (February 2013) -* 1.4.3 (December 2012) -* 1.3.5 (December 2012) -* 1.4.2 (November 2012) -* 1.3.3 (August 2012) -* 1.4.1 (July 2012) -* 1.3.2 (July 2012) * 1.4 (March 2012) From 261b3535fb60d6240e51affe90721f6c5eab7850 Mon Sep 17 00:00:00 2001 From: cewing Date: Sat, 8 Feb 2014 15:48:07 -0800 Subject: [PATCH 004/223] updates --- source/img/admin_index.png | Bin 18633 -> 27949 bytes source/main/outline.rst | 50 +- source/presentations/session06.rst | 13 + source/presentations/session07.rst | 1608 +--------------------------- 4 files changed, 47 insertions(+), 1624 deletions(-) diff --git a/source/img/admin_index.png b/source/img/admin_index.png index 55a8919f5fb77f12d5af16b607a08229fd637def..ae7a19f986880cac0b47c2ea4cb48a722851c260 100644 GIT binary patch literal 27949 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 literal 18633 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 TaXvnw00000NkvXXu0mjfP)V4v diff --git a/source/main/outline.rst b/source/main/outline.rst index 8fefabf8..0585da80 100644 --- a/source/main/outline.rst +++ b/source/main/outline.rst @@ -162,36 +162,36 @@ our work quickly across different environments. `Lecture Slides `_ +Homework Tutorials +****************** + +Please walk through this tutorial before session 7 begins. + +* `An Introduction to Django `_ -Session 7 - Intro to Django ---------------------------- + +Session 7 - Basic Django +------------------------ In this class we'll get introduced to arguably the most popular full-stack Python web framework, Django. We'll install the framework, learn about how to get it running and how to get started creating your very own app. We'll be learning about the Django ORM and how Django Models can help shield -developers from much of the complexity of SQL. We'll learn how to use the -tools Django provides to explore and interact with your models while designing -them. We'll also get a brief introduction to the Django admin, Django's -*killer feature*. +developers from much of the complexity of SQL. -Along the way, we'll continue our test-driven development style: writing tests -to demonstrate the functionality we desire and then implementing code to make -them pass. We'll get a chance to see how to build tests within the framework -offered by Django's testrunner. +During the week leading up to this session, we'll `get started building`_ a +blog app in Django. We'll learn how to use the tools Django provides to explore +and interact with your models while designing them. We'll also get a brief +introduction to the Django admin, Django's *killer feature*. -`Lecture Slides `_ +.. _get started building: presentations/django_intro-plain.html -Session 8 - A Django Application --------------------------------- - -In this class we'll complete our exploration of Django. We'll customize the -Django admin to help us most efficiently administer our Blog application. -We'll create and test view functions that present our application to the world -and we'll provide front-end access to forms that allow us to create, edit and -publish blog entries without needing to use the admin. +Along the way, we'll build a nicely functional blog application. We'll learn +about model relationships, customizing the Django admin, and adding front-end +views so users can see our work. We'll even learn how we can update our +database code and keep it in sync with our progressing development work. Along the way we'll learn that the Django template language is quite similar to the Jinja2 language (in fact, Jinja2 was modelled on the Django version). @@ -199,7 +199,17 @@ We'll also get a chance to learn a bit more about the features that the Django test framework provides over and above the standard Python ``unittest`` library. -Finally, we'll discuss some of the strengths and weaknesses of Django. What +`Lecture Slides `_ + + +Session 8 - Extending Django +---------------------------- + +During this session, we will continue our exploration of Django, and of pair +programming. Students will once again pair up and work together to implement +one or more feature extending the basic Django app we created previously. + +Finally, we'll discuss some of the strengths and weaknesses of Django. What makes it a good choice for some projects but not for others. `Lecture Slides `_ diff --git a/source/presentations/session06.rst b/source/presentations/session06.rst index 636b608e..3bc546f1 100644 --- a/source/presentations/session06.rst +++ b/source/presentations/session06.rst @@ -164,3 +164,16 @@ Now, when you switch roles during your work, here's the workflow you can use: .. 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 index 85692503..7cfabd08 100644 --- a/source/presentations/session07.rst +++ b/source/presentations/session07.rst @@ -145,1648 +145,48 @@ that documentation for every commit **this is awesome** -Django Organization -------------------- - -A Django *project* represents a whole website: - -.. class:: incremental - -* global configuration settings -* inclusion points for additional functionality -* master list of URL endpoints - -.. class:: incremental - -A Django *app* encapsulates a unit of functionality: - -.. class:: incremental - -* A blog section -* A discussion forum -* A content tagging system - - -Apps Make Up a Project ----------------------- - -.. class:: big-centered - -One *project* can (and likely will) consist of many *apps* - - -Practice Safe Development -------------------------- - -We'll install Django and any other packages we use with it in a virtualenv. - -.. class:: incremental - -This will ensure that it is isolated from everything else we do in class (and -vice versa) - -.. container:: incremental - - Remember the basic format for creating a virtualenv: - - .. class:: small - - :: - - $ python virtualenv.py [options] - - $ virtualenv [options] - - -Set Up a VirtualEnv -------------------- - -Start by creating your virtualenv:: - - $ python virtualenv.py djangoenv - - $ virtualenv djangoenv - ... - -.. container:: incremental - - Then, activate it:: - - $ source djangoenv/bin/activate - - C:\> djangoenv\Scripts\activate - - -Install Django --------------- - -Finally, install Django 1.5.1 using `setuptools` or `pip`: - -.. class:: small - -:: - - (djangoenv)$ pip install Django==1.5.1 - Downloading/unpacking Django==1.5.1 - Downloading Django-1.5.1.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* - -.. class:: incremental - -To get started learning, we'll create one - -.. class:: incremental - -We'll use a script installed by Django, ``django-admin.py``: - -.. code-block:: - :class: incremental - - (djangoenv)$ django-admin.py startproject mysite - -.. class:: incremental - -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: - -.. code-block:: - - mysite/ - manage.py - mysite/ - __init__.py - settings.py - urls.py - wsgi.py - -.. class:: incremental - -If what you see doesn't match that, you're using an older version of Django. -Make sure you've installed 1.5.1. - - -What Got Created ----------------- - -.. class:: incremental - -* **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 and WSGI ---------------- - -If you open ``wsgi.py``, you'll see the following: - -.. code-block:: python - :class: small - - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") - - from django.core.wsgi import get_wsgi_application - application = get_wsgi_application() - -.. container:: incremental - - Django is pointing the python environment at your settings file and then - getting a wsgi application: - - .. code-block:: python - :class: small - - def get_wsgi_application(): - return WSGIHandler() - - -The Django WSGIHandler ----------------------- -.. code-block:: python - :class: small - - class WSGIHandler(base.BaseHandler): - #... - def __call__(self, environ, start_response): - #... set up django middleware - try: - #... build a request - request = self.request_class(environ) - except UnicodeDecodeError: - #... handle request errors - else: - # build a response - response = self.get_response(request) - #... determine response status - status = '%s %s' % (response.status_code, status_text) - #... build response headers - response_headers = [(str(k), str(v)) for k, v in response.items()] - #... start a response with status and headers - start_response(force_str(status), response_headers) - return response - - -*django-admin.py* and *manage.py* ---------------------------------- - -*django-admin.py* provides a hook for administrative tasks and abilities: - -.. class:: incremental - -* 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) - -.. class:: incremental - -*manage.py* wraps this functionality, adding the full environment of your -project. - - -Development Server ------------------- - -At this point, you should be ready to use the development server:: - - (djangoenv)$ cd mysite - (djangoenv)$ python manage.py runserver - ... - -.. class:: incremental - -Load ``http://localhost:8000`` in your browser. - - -A Blank Slate -------------- - -You should see this: - -.. image:: img/django-start.png - :align: center - :width: 98% - -.. class:: incremental center - -**Do you?** - - -Connecting A Database ---------------------- - -Django supplies its own ORM (Object-Relational Mapper) - -.. class:: incremental - -This ORM sits on top of the DB-API implementation you choose. - -.. class:: incremental - -You must provide connection information through Django configuration. - -.. class:: incremental - -All Django configuration takes place in ``settings.py`` in your project -folder. - - -Your Database Settings ----------------------- - -Edit your ``settings.py`` to match: - -.. code-block:: python - :class: small - - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'mysite.db', - # The following settings are not used with sqlite3: - 'USER': '', - 'PASSWORD': '', - 'HOST': '', - 'PORT': '', - } - } - - -Django and Your Database ------------------------- - -Django's ORM provides a layer of *abstraction* between you and SQL - -.. class:: incremental - -You write Python *models* describing the object that make up your system. - -.. class:: incremental - -The ORM handles converting data from these objects into SQL statements (and -back) - -.. class:: incremental - -We'll learn much more about this in a bit - - -Core Django *Apps* ------------------- - -Django already includes some *apps* for you. - -.. container:: incremental - - They're in ``settings.py`` in the ``INSTALLED_APPS`` setting: - - .. code-block:: python - :class: small - - 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. - -.. container:: incremental - - You make them by running the ``syncdb`` management command: - - .. class:: small - - :: - - (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): - -.. class:: incremental - -Add your first user at this prompt (remember the password) - - -Our Class App -------------- - -We are going to build an *app* to add to our *project*. To start with our app -will: - -.. class:: incremental - -* allow a user to create and edit blog posts -* allow a user to define categories -* allow a user to place a post in one or more categories - -.. class:: incremental - -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``. - -.. class:: incremental - -In your terminal, make sure you are in the *outer* mysite directory, where the -file ``manage.py`` is located. Then: - -.. class:: incremental - -:: - - (djangoenv)$ python manage.py startapp myblog - - -What is Created ---------------- - -This should leave you with the following structure: - -.. class:: small - -:: - - mysite/ - manage.py - mysite/ - ... - myblog/ - __init__.py - models.py - tests.py - views.py - -.. class:: incremental - -We'll start by defining the main Python class in 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. - -.. class:: incremental - -This base class hooks in to the ORM functionality converting Python code to -SQL. - -.. class:: incremental - -You can override methods from the base ``Model`` class to alter how this works -or write new methods to add functionality. - -.. class:: incremental - -Learn more about `models -`_ - - -Our Post Model --------------- - -Open the ``models.py`` file created in our ``myblog`` package. Add the -following: - -.. code-block:: python - :class: small - - 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) - - -Model Fields ------------- - -We've created a subclass of the Django ``Model`` class and added a bunch of -attributes. - -.. class:: incremental - -* 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 - -.. class:: incremental - -You can read much more about `Model Fields and options -`_ - - -Field Details -------------- - -There are some features of our fields worth mentioning in specific: - -.. class:: incremental - -Notice we have no field that is designated as the *primary key* - -.. class:: incremental - -* 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 ``pk`` - - -Field Details -------------- - -.. code-block:: python - :class: small - - title = models.CharField(max_length=128) - -.. class:: incremental - -The required ``max_length`` argument is specific to ``CharField`` fields. - -.. class:: incremental - -It affects *both* the Python and SQL behavior of a field. - -.. class:: incremental - -In python, it is used to *validate* supplied values during *model validation* - -.. class:: incremental - -In SQL it is used in the column definition: ``VARCHAR(128)`` - - -Field Details -------------- - -.. code-block:: python - :class: small - - text = models.TextField(blank=True) - # ... - published_date = models.DateTimeField(blank=True, null=True) - -.. class:: incremental - -The argument ``blank`` is shared across all field types. The default is -``False`` - -.. class:: incremental - -This argument affects only the Python behavior of a field, determining if the -field is *required* - -.. class:: incremental - -The related ``null`` argument affects the SQL definition of a field: is the -column NULL or NOT NULL - - -Field Details -------------- - -.. code-block:: python - :class: small - - created_date = models.DateTimeField(auto_now_add=True) - modified_date = models.DateTimeField(auto_now=True) - -.. class:: incremental - -``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. - -.. class:: incremental - -``auto_now`` is similar, but sets the value anew each time an instance is -saved. - -.. class:: incremental - -Setting either of these will cause the ``editable`` attribute of a field to be -set to ``False``. - - -Field Details -------------- - -.. code-block:: python - :class: small - - author = models.ForeignKey(User) - -.. class:: incremental - -Django also models SQL *relationships* as specific field types. - -.. class:: incremental - -The required positional argument is the class of the related Model. - -.. class:: incremental - -By default, the reverse relation is implemented as the attribute -``_set``. - -.. class:: incremental - -You can override this by providing the ``related_name`` argument. - - -Our Category Model ------------------- - -Our app specification says that a user should be able to place a post in one -or more categories. - -.. class:: incremental - -We'll create a second Model to represent this. It should: - -.. class:: incremental - -* Have a unique name -* Have a description -* Be in a many-to-many relationship with our ``Post`` model -* Instances of ``Category`` should have a ``posts`` attribute that provides - access to all posts in that category -* Instances of ``Post`` should have a ``categories`` attribute that provides - access to all the categories it has been placed in. - - -My Solution ------------ - -Add this new Model class to ``models.py``. - -.. class:: incremental small - -https://docs.djangoproject.com/en/1.5/ref/models/fields/ - -.. container:: incremental - - Here's my model code: - - .. code-block:: python - :class: small - - 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' - ) - - -A Word About Development ------------------------- - -These models we've created are not going to change often. This is unusual for -a development cycle. - -.. 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 - -We won't have time to `cover it `_ in -this class, but know it's there. - - -Hooking it Up -------------- - -In order to use our new models, we need Django to know about our *app* - -.. class:: incremental - -This is accomplished by configuration in the ``settings.py`` file. - -.. class:: incremental - -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: - -.. code-block:: python - :class: small - - 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', - 'myblog', # <- YOU ADD THIS PART - ) - - -Setting Up the Database ------------------------ - -You know what the next step will be: - -.. code-block:: - :class: incremental - - (djangoenv)$ python manage.py syncdb - Creating tables ... - Creating table myblog_post - Creating table myblog_category_posts - Creating table myblog_category - Installing custom SQL ... - Installing indexes ... - Installed 0 object(s) from 0 fixture(s) - -.. class:: incremental - -Django has now created tables for our app. How many did it create? - - -ORM and SQL ------------ - -That third table is the SQL mechanism by which a ``Post`` is related to a -``Category``. - -.. class:: incremental - -The ORM shields us, as Python developers, from SQL intricacies. - -.. class:: incremental - -We don't need to know that a join table is needed for a ManyToMany relation. - -.. class:: incremental - -This is but one of the ways that the ORM helps us. More soon. - - -Break Time ----------- - -.. class:: big-centered - -Let's take a break here and return in 10 minutes. - - -The Django Shell ----------------- - -Django provides a management command ``shell``: - -.. class:: incremental - -* 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. - -.. class:: incremental - -Let's explore the Model Instance API directly using this shell: - -.. class:: incremental - -:: - - (djangoenv)$ python manage.py shell - - -Creating Instances ------------------- - -Instances of our model can be created by simple instantiation: - -.. code-block:: python - :class: small - - >>> from myblog.models import Post - >>> p1 = Post(title="My first post", - ... text="This is the first post I've written") - >>> p1 - - -.. container:: incremental - - We can also validate that our new object is okay before we try to save it: - - .. code-block:: python - :class: small - - >>> 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``. - -.. class:: incremental - -To do this, we need to have an instance of the ``User`` class. - -.. class:: incremental - -We can use the ``User`` *model manager* to run table-level operations like -``SELECT``: - -.. class:: incremental - -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: - -.. code-block:: python - :class: small - - >>> from django.contrib.auth.models import User - >>> all_users = User.objects.all() - >>> all_users - [] - >>> u1 = all_users[0] - >>> p1.author = u1 - -.. container:: incremental - - And now our instance should validate properly: - - .. code-block:: python - :class: small - - >>> p1.full_clean() - >>> - - -Saving New Objects ------------------- - -Our model has three date fields, two of which are supposed to be -auto-populated: - -.. class:: python - :class: small - - >>> print(p1.created_date) - None - >>> print(p1.modified_date) - None - -.. container:: incremental - - When we save our post, these fields will get values assigned: - - .. code-block:: python - :class: small - - >>> p1.save() - >>> p1.created_date - datetime.datetime(2013, 7, 26, 20, 2, 38, 104217, tzinfo=) - >>> p1.modified_date - datetime.datetime(2013, 7, 26, 20, 2, 38, 104826, tzinfo=) - - -Updating An Instance --------------------- - -Models operate much like 'normal' python objects. - -.. container:: incremental - - To change the value of a field, simply set the instance attribute to a new - value. Call ``save()`` to persist the change: - - .. code-block:: python - :class: small - - >>> 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: - -.. code-block:: python - :class: small - - >>> 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. - -.. class:: incremental - -API methods take keyword arguments, where the keywords are special -constructions combining field names with field *lookups*: - -.. class:: incremental small - -* title__exact="The exact title" -* text__contains="decoration" -* id__in=range(1,4) -* published_date__lte=datetime.datetime.now() - -.. class:: incremental - -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. - -.. class:: incremental - -The former may be chained without hitting the database: - -.. code-block:: python - :class: small incremental - - >>> 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 - -.. container:: incremental - - Conversely, the latter will issue an SQL query when executed. - - .. code-block:: python - :class: small - - >>> 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: - -.. code-block:: python - :class: small incremental - - >>> 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 '\' ) - ) - -.. class:: incremental - -The SQL will vary depending on which DBAPI backend you use (yay ORM!!!) - - -Exploring the QuerySet API --------------------------- - -See https://docs.djangoproject.com/en/1.5/ref/models/querysets - - -.. code-block:: python - :class: small - - >>> [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. - -.. class:: incremental - -Changes are persisted without needing to call ``save``. - -.. code-block:: python - :class: small incremental - - >>> 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=), - ...] - - -Testing Our Models ------------------- - -As with any project, we want to test our work. Django provides a testing -framework to allow this. - -.. class:: incremental - -Django supports both *unit tests* and *doctests*. I strongly suggest using -*unit tests*. - -.. class:: incremental - -You add tests for your *app* to the file ``tests.py``, which should be at the -same package level as ``models.py``. - -.. class:: incremental - -Locate and open this file in your editor. - - -Django TestCase Classes ------------------------ - -**SimpleTestCase** is for basic unit testing with no ORM requirements - -.. class:: incremental - -**TransactionTestCase** is useful if you need to test transactional -actions (commit and rollback) in the ORM - -.. class:: incremental - -**TestCase** is used when you require ORM access and a test client - -.. class:: incremental - -**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. - -.. class:: incremental - -Django provides *fixtures* to handle this need. - -.. class:: incremental - -Create a directory called ``fixtures`` inside your ``myblog`` app directory. - -.. class:: incremental - -Copy the file ``myblog_test_fixture.json`` from the class resources into this -directory, it contains users for our tests. - - -Setting Up Our Tests --------------------- - -Now that we have a fixture, we need to instruct our tests to use it. - -.. container:: incremental - - Edit ``tests.py`` (which comes with one test already) to look like this: - - .. code-block:: python - :class: small - - 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 +DEPARTING FROM SCRIPT --------------------- -Look at the way our Post represents itself in the Django shell: - -.. code-block:: python - :class: small - - >>> [p for p in Post.objects.all()] - [, , - , ] - -.. class:: incremental - -Wouldn't it be nice if the posts showed their titles instead? - -.. class:: incremental - -In Django, the ``__unicode__`` method is used to determine how a Model -instance represents itself. - -.. class:: incremental - -Then, calling ``unicode(instance)`` gives the desired result. - - -Write The Test --------------- - -Let's write a test that demonstrates our desired outcome: - -.. code-block:: python - :class: small - - # add this import at the top - from myblog.models import Post - - # and this test method to the PostTestCase - 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 - -.. class:: incremental - -Without arguments, it will run all TestCases it finds in all installed *apps* - -.. class:: incremental - -You can pass the name of a single app to focus on those tests - -.. class:: incremental - -Quit your Django shell and in your terminal run the test we wrote: - -.. code-block:: bash - :class: small incremental - - (djangoenv)$ python manage.py test myblog - - -The Result ----------- - -We have yet to implement this enhancement, so our test should fail: - -.. class:: small - -:: - - 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 - -.. class:: incremental - -It will take ``self`` as its only argument - -.. class:: incremental - -And it should return its own title as the result - -.. class:: incremental - -Go ahead and take a stab at this in ``models.py`` - -.. code-block:: python - :class: small incremental - - class Post(models.Model): - #... - - def __unicode__(self): - return self.title - - -Did It Work? ------------- - -Re-run the tests to see: - -.. code-block:: bash - :class: small - - (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'... - -.. class:: incremental center - -**YIPEEEE!** - - -Repeat the Exercise -------------------- - -Although we haven't played with it yet, our Category class could use the same -treatment, using the ``name`` field. - -.. class:: incremental - -Add a CategoryTestCase to ``tests.py`` with one test that shows this. - -.. class:: incremental - -Run the tests, demonstrating that you have two tests and one failure - -.. class:: incremental - -Add the appropriate method to the appropriate class in ``models.py`` and -re-run the tests. - - -My Test -------- - -.. 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) - - -My Method ---------- - -.. code-block:: python - class Category(models.Model): - #... - - def __unicode__(self): - return self.name -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? -.. class:: incremental - -I *usually* don't write tests covering features provided directly by the -framework. -.. class:: incremental -I *do* write tests for functionality I add, and for places where I make -changes to how the default functionality works. -.. class:: incremental -This is largely a matter of style and taste (and of budget). -More Later +Break Time ---------- -We've only begun to test our blog app. - -.. class:: incremental - -We'll be adding many more tests later - -.. class:: incremental - -In between, you might want to take a look at the Django testing documentation: - -.. class:: incremental center - -https://docs.djangoproject.com/en/1.5/topics/testing/ - - -The Django Admin ----------------- - -As I stated earlier, the Django admin is really Django's *killer feature* - -.. class:: incremental - -To demonstrate this, we are going to set up the admin for our blog - - -Install the Admin ------------------ - -The Django Admin is, itself, an *app*. It is not installed by default. - -.. class:: incremental - -Open the ``settings.py`` file from our ``mysite`` project package and -uncomment the admin bit: - -.. code-block:: python - :class: incremental small - - INSTALLED_APPS = ( - # ... - 'django.contrib.staticfiles', - # Uncomment the next line to enable the admin: - 'django.contrib.admin', # <- THIS LINE HERE - # Uncomment the next line to enable admin documentation: - # 'django.contrib.admindocs', - 'myblog', - ) - - -Add the Admin Tables --------------------- - -As you might expect, enabling the admin alters our DB. We'll need to run -the ``syncdb`` management command:: - - (djangoenv)$ python manage.py syncdb - Creating tables ... - Creating table django_admin_log - Installing custom SQL ... - Installing indexes ... - Installed 0 object(s) from 0 fixture(s) - -.. class:: incremental - -All set. Now let's make it visitable - - -Django URL Resolution ---------------------- - -Django too has a system for routing URLs to code: the *urlconf*. - -.. class:: incremental - -* A urlconf is a list 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 -* 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 - -.. class:: incremental - -It is standard to include these urlconfs by rooting them at some path in your -site. - -.. container:: incremental - - You can do this by using the ``include`` function as the callable in a - ``url`` call: - - .. code-block:: python - :class: small - - 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. - -.. container:: incremental - - Uncomment three lines in ``urls.py``: - - .. code-block:: python - :class: small - - from django.contrib import admin #<- Uncomment these two - admin.autodiscover() #<- - - urlpatterns = patterns('', - - # Uncomment the next line to enable the admin: - 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. - -.. class:: incremental - -In your terminal, use the ``runserver`` management command to start the -development server: - -.. class:: incremental - -:: - - (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: - -.. image:: img/django-admin-login.png - :align: center - :width: 50% - -.. class:: incremental - -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: - -.. image:: img/admin_index.png - :align: center - :width: 90% - -.. class:: incremental - -Click on ``Users``. Find yourself? Edit yourself, but **don't** uncheck -``superuser``. - - -Add Posts to the Admin ----------------------- - -Okay, let's add our app models to the admin. - -.. class:: incremental - -Add a new file to the ``myblog`` app package: ``admin.py``. Open it and add -the following: - -.. code-block:: python - :class: incremental - - from django.contrib import admin - from myblog.models import Post, Category - - admin.site.register(Post) - admin.site.register(Category) - -.. class:: incremental - -Restart your Development server and reload the admin index - +.. class:: big-centered -Play A Bit ----------- +Let's take a break here and return in 10 minutes. -Visit the admin page for Posts. You should see the posts we created earlier in -the Django shell. -.. class:: incremental -Look at the listing of Posts. Because of our ``__unicode__`` method we see a -nice title. -.. class:: incremental -Are there other fields you'd like to see listed? -.. class:: incremental -Click on a Post, note what is and is not shown. -.. class:: incremental -Poke at the Category admin a bit too. -Next Steps ----------- -We've learned a great deal about Django's ORM and Models. -.. class:: incremental -We've also spent some time getting to know the Query API provided by model -managers and QuerySets. -.. class:: incremental -We've also hooked up the Django Admin and noted some shortcomings. -.. class:: incremental -In our next session we'll improve how the admin works for us. -.. class:: incremental -Then we'll put a front-end on this blog. -Break Time ----------- -.. class:: big-centered -See you back soon. From a0b0a969cb3bdb8fabeec7e9e582db1e6bf7811f Mon Sep 17 00:00:00 2001 From: cewing Date: Sat, 8 Feb 2014 15:48:37 -0800 Subject: [PATCH 005/223] set up homework for session 6, add django tutorial --- assignments/session06/django_intro-plain.html | 1246 ++++++++++++++ assignments/session06/img/admin_index.png | Bin 0 -> 27949 bytes .../session06/img/django-admin-login.png | Bin 0 -> 7749 bytes assignments/session06/img/django-start.png | Bin 0 -> 31371 bytes assignments/session06/tasks.txt | 7 + source/presentations/django_intro.rst | 1453 +++++++++++++++++ 6 files changed, 2706 insertions(+) create mode 100644 assignments/session06/django_intro-plain.html create mode 100644 assignments/session06/img/admin_index.png create mode 100644 assignments/session06/img/django-admin-login.png create mode 100644 assignments/session06/img/django-start.png create mode 100644 assignments/session06/tasks.txt create mode 100644 source/presentations/django_intro.rst diff --git a/assignments/session06/django_intro-plain.html b/assignments/session06/django_intro-plain.html new file mode 100644 index 00000000..ca60c56a --- /dev/null +++ b/assignments/session06/django_intro-plain.html @@ -0,0 +1,1246 @@ + + + + + + +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 new file mode 100644 index 0000000000000000000000000000000000000000..ae7a19f986880cac0b47c2ea4cb48a722851c260 GIT binary patch literal 27949 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 literal 0 HcmV?d00001 diff --git a/assignments/session06/img/django-admin-login.png b/assignments/session06/img/django-admin-login.png new file mode 100644 index 0000000000000000000000000000000000000000..4ceb5f54b92922f5f82b8896c8dccfbcd1ad6bdf GIT binary patch literal 7749 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 literal 0 HcmV?d00001 diff --git a/assignments/session06/tasks.txt b/assignments/session06/tasks.txt new file mode 100644 index 00000000..9cb9b211 --- /dev/null +++ b/assignments/session06/tasks.txt @@ -0,0 +1,7 @@ +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/source/presentations/django_intro.rst b/source/presentations/django_intro.rst new file mode 100644 index 00000000..0951003c --- /dev/null +++ b/source/presentations/django_intro.rst @@ -0,0 +1,1453 @@ +************************* +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. + +.. class:: incremental + +This will ensure that it is isolated from everything else we do in class (and +vice versa) + +.. container:: incremental + + Remember the basic format for creating a virtualenv: + + .. class:: small + + :: + + $ python virtualenv.py [options] + + $ virtualenv [options] + + +Set Up a VirtualEnv +------------------- + +Start by creating your virtualenv:: + + $ python virtualenv.py djangoenv + + $ virtualenv djangoenv + ... + +.. container:: incremental + + Then, activate it:: + + $ source djangoenv/bin/activate + + C:\> djangoenv\Scripts\activate + + +Install Django +-------------- + +Finally, install Django 1.6.2 using ``pip``: + +.. class:: small + +:: + + (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* + +.. class:: incremental + +To get started learning, we'll create one + +.. class:: incremental + +We'll use a script installed by Django, ``django-admin.py``: + +.. code-block:: + :class: incremental + + (djangoenv)$ django-admin.py startproject mysite + +.. class:: incremental + +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: + +.. code-block:: + + mysite + ├── manage.py + └── mysite + ├── __init__.py + ├── settings.py + ├── urls.py + └── wsgi.py + +.. class:: incremental + +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 +---------------- + +.. class:: incremental + +* **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: + +.. class:: incremental + +* 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) + +.. class:: incremental + +*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: + +.. code-block:: python + :class: small + + #!/usr/bin/env python + import os + import sys + + if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + ... + +.. class:: incremental + +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 + ... + +.. class:: incremental + +Load ``http://localhost:8000`` in your browser. + + +A Blank Slate +------------- + +You should see this: + +.. image:: img/django-start.png + :align: center + :width: 98% + +.. class:: incremental center + +**Do you?** + + +Connecting A Database +--------------------- + +Django supplies its own ORM (Object-Relational Mapper) + +.. class:: incremental + +This ORM sits on top of the DB-API implementation you choose. + +.. class:: incremental + +You must provide connection information through Django configuration. + +.. class:: incremental + +All Django configuration takes place in ``settings.py`` in your project +folder. + + +Your Database Settings +---------------------- + +Edit your ``settings.py`` to match: + +.. code-block:: python + :class: small + + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'mysite.db', + } + } + +.. class:: incremental + +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 + +.. class:: incremental + +You write Python classes called *models* describing the object that make up +your system. + +.. class:: incremental + +The ORM handles converting data from these objects into SQL statements (and +back) + +.. class:: incremental + +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: + +.. class:: incremental + +* global configuration settings +* inclusion points for additional functionality +* master list of URL endpoints + +.. class:: incremental + +A Django *app* encapsulates a unit of functionality: + +.. class:: incremental + +* A blog section +* A discussion forum +* A content tagging system + + +Apps Make Up a Project +---------------------- + +.. class:: big-centered + +One *project* can (and likely will) consist of many *apps* + + +Core Django *Apps* +------------------ + +Django already includes some *apps* for you. + +.. container:: incremental + + They're in ``settings.py`` in the ``INSTALLED_APPS`` setting: + + .. code-block:: python + :class: small + + 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. + +.. container:: incremental + + You make them by running the ``syncdb`` management command: + + .. class:: small + + :: + + (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): + +.. class:: incremental + +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. + +.. class:: incremental + +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``. + +.. class:: incremental + +In your terminal, make sure you are in the *outer* mysite directory, where the +file ``manage.py`` is located. Then: + +.. class:: incremental + +:: + + (djangoenv)$ python manage.py startapp myblog + + +What is Created +--------------- + +This should leave you with the following structure: + +.. class:: small + +:: + + mysite + ├── manage.py + ├── myblog + │   ├── __init__.py + │   ├── admin.py + │   ├── models.py + │   ├── tests.py + │   └── views.py + └── mysite + ├── __init__.py + ... + +.. class:: incremental + +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. + +.. class:: incremental + +This base class hooks in to the ORM functionality converting Python code to +SQL. + +.. class:: incremental + +You can override methods from the base ``Model`` class to alter how this works +or write new methods to add functionality. + +.. class:: incremental + +Learn more about `models +`_ + + +Our Post Model +-------------- + +Open the ``models.py`` file created in our ``myblog`` package. Add the +following: + +.. code-block:: python + :class: small + + 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. + +.. class:: incremental + +* 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 + +.. class:: incremental + +You can read much more about `Model Fields and options +`_ + + +Field Details +------------- + +There are some features of our fields worth mentioning in specific: + +.. class:: incremental + +Notice we have no field that is designated as the *primary key* + +.. class:: incremental + +* 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 +------------- + +.. code-block:: python + :class: small + + title = models.CharField(max_length=128) + +.. class:: incremental + +The required ``max_length`` argument is specific to ``CharField`` fields. + +.. class:: incremental + +It affects *both* the Python and SQL behavior of a field. + +.. class:: incremental + +In python, it is used to *validate* supplied values during *model validation* + +.. class:: incremental + +In SQL it is used in the column definition: ``VARCHAR(128)`` + + +Field Details +------------- + +.. code-block:: python + :class: small + + author = models.ForeignKey(User) + +.. class:: incremental + +Django also models SQL *relationships* as specific field types. + +.. class:: incremental + +The required positional argument is the class of the related Model. + +.. class:: incremental + +By default, the reverse relation is implemented as the attribute +``_set``. + +.. class:: incremental + +You can override this by providing the ``related_name`` argument. + + +Field Details +------------- + +.. code-block:: python + :class: small + + created_date = models.DateTimeField(auto_now_add=True) + modified_date = models.DateTimeField(auto_now=True) + +.. class:: incremental + +``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. + +.. class:: incremental + +``auto_now`` is similar, but sets the value anew each time an instance is +saved. + +.. class:: incremental + +Setting either of these will cause the ``editable`` attribute of a field to be +set to ``False``. + + +Field Details +------------- + +.. code-block:: python + :class: small + + text = models.TextField(blank=True) + # ... + published_date = models.DateTimeField(blank=True, null=True) + +.. class:: incremental + +The argument ``blank`` is shared across all field types. The default is +``False`` + +.. class:: incremental + +This argument affects only the Python behavior of a field, determining if the +field is *required* + +.. class:: incremental + +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* + +.. class:: incremental + +This is accomplished by configuration in the ``settings.py`` file. + +.. class:: incremental + +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: + +.. code-block:: python + :class: small + + 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: + +.. code-block:: + :class: incremental + + (djangoenv)$ python manage.py syncdb + Creating tables ... + Creating table myblog_post + Installing custom SQL ... + Installing indexes ... + Installed 0 object(s) from 0 fixture(s) + +.. class:: incremental + +Django has now created a table for our model. + +.. class:: incremental + +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``: + +.. class:: incremental + +* 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. + +.. class:: incremental + +Let's explore the Model Instance API directly using this shell: + +.. class:: incremental + +:: + + (djangoenv)$ python manage.py shell + + +Creating Instances +------------------ + +Instances of our model can be created by simple instantiation: + +.. code-block:: python + :class: small + + >>> from myblog.models import Post + >>> p1 = Post(title="My first post", + ... text="This is the first post I've written") + >>> p1 + + +.. container:: incremental + + We can also validate that our new object is okay before we try to save it: + + .. code-block:: python + :class: small + + >>> 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``. + +.. class:: incremental + +To do this, we need to have an instance of the ``User`` class. + +.. class:: incremental + +We can use the ``User`` *model manager* to run table-level operations like +``SELECT``: + +.. class:: incremental + +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: + +.. code-block:: python + :class: small + + >>> from django.contrib.auth.models import User + >>> all_users = User.objects.all() + >>> all_users + [] + >>> u1 = all_users[0] + >>> p1.author = u1 + +.. container:: incremental + + And now our instance should validate properly: + + .. code-block:: python + :class: small + + >>> p1.full_clean() + >>> + + +Saving New Objects +------------------ + +Our model has three date fields, two of which are supposed to be +auto-populated: + +.. class:: python + :class: small + + >>> print(p1.created_date) + None + >>> print(p1.modified_date) + None + +.. container:: incremental + + When we save our post, these fields will get values assigned: + + .. code-block:: python + :class: small + + >>> p1.save() + >>> p1.created_date + datetime.datetime(2013, 7, 26, 20, 2, 38, 104217, tzinfo=) + >>> p1.modified_date + datetime.datetime(2013, 7, 26, 20, 2, 38, 104826, tzinfo=) + + +Updating An Instance +-------------------- + +Models operate much like 'normal' python objects. + +.. container:: incremental + + To change the value of a field, simply set the instance attribute to a new + value. Call ``save()`` to persist the change: + + .. code-block:: python + :class: small + + >>> 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: + +.. code-block:: python + :class: small + + >>> 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. + +.. class:: incremental + +API methods take keyword arguments, where the keywords are special +constructions combining field names with field *lookups*: + +.. class:: incremental small + +* title__exact="The exact title" +* text__contains="decoration" +* id__in=range(1,4) +* published_date__lte=datetime.datetime.now() + +.. class:: incremental + +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. + +.. class:: incremental + +The former may be chained without hitting the database: + +.. code-block:: python + :class: small incremental + + >>> 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 + +.. container:: incremental + + Conversely, the latter will issue an SQL query when executed. + + .. code-block:: python + :class: small + + >>> 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: + +.. code-block:: python + :class: small incremental + + >>> 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 '\' ) + ) + +.. class:: incremental + +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 + + +.. code-block:: python + :class: small + + >>> [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. + +.. class:: incremental + +Changes are persisted without needing to call ``save``. + +.. code-block:: python + :class: small incremental + + >>> 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=), + ...] + + +Testing Our Model +----------------- + +As with any project, we want to test our work. Django provides a testing +framework to allow this. + +.. class:: incremental + +Django supports both *unit tests* and *doctests*. I strongly suggest using +*unit tests*. + +.. class:: incremental + +You add tests for your *app* to the file ``tests.py``, which should be at the +same package level as ``models.py``. + +.. class:: incremental + +Locate and open this file in your editor. + + +Django TestCase Classes +----------------------- + +**SimpleTestCase** is for basic unit testing with no ORM requirements + +.. class:: incremental + +**TransactionTestCase** is useful if you need to test transactional +actions (commit and rollback) in the ORM + +.. class:: incremental + +**TestCase** is used when you require ORM access and a test client + +.. class:: incremental + +**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. + +.. class:: incremental + +Django provides *fixtures* to handle this need. + +.. class:: incremental + +Create a directory called ``fixtures`` inside your ``myblog`` app directory. + +.. class:: incremental + +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. + +.. container:: incremental + + Edit ``tests.py`` (which comes with one test already) to look like this: + + .. code-block:: python + :class: small + + 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: + +.. code-block:: python + :class: small + + >>> [p for p in Post.objects.all()] + [, , + , ] + +.. class:: incremental + +Wouldn't it be nice if the posts showed their titles instead? + +.. class:: incremental + +In Django, the ``__unicode__`` method is used to determine how a Model +instance represents itself. + +.. class:: incremental + +Then, calling ``unicode(instance)`` gives the desired result. + + +Write The Test +-------------- + +Let's write a test that demonstrates our desired outcome: + +.. code-block:: python + :class: small + + # 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 + +.. class:: incremental + +Without arguments, it will run all TestCases it finds in all installed *apps* + +.. class:: incremental + +You can pass the name of a single app to focus on those tests + +.. class:: incremental + +Quit your Django shell and in your terminal run the test we wrote: + +.. code-block:: bash + :class: small incremental + + (djangoenv)$ python manage.py test myblog + + +The Result +---------- + +We have yet to implement this enhancement, so our test should fail: + +.. class:: small + +:: + + 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 + +.. class:: incremental + +It will take ``self`` as its only argument + +.. class:: incremental + +And it should return its own title as the result + +.. class:: incremental + +Go ahead and take a stab at this in ``models.py`` + +.. code-block:: python + :class: small incremental + + class Post(models.Model): + #... + + def __unicode__(self): + return self.title + + +Did It Work? +------------ + +Re-run the tests to see: + +.. code-block:: bash + :class: small + + (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'... + +.. class:: incremental center + +**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? + +.. class:: incremental + +I *usually* don't write tests covering features provided directly by the +framework. + +.. class:: incremental + +I *do* write tests for functionality I add, and for places where I make +changes to how the default functionality works. + +.. class:: incremental + +This is largely a matter of style and taste (and of budget). + + +More Later +---------- + +We've only begun to test our blog app. + +.. class:: incremental + +We'll be adding many more tests later + +.. class:: incremental + +In between, you might want to take a look at the Django testing documentation: + +.. class:: incremental center + +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* + +.. class:: incremental + +And without doubt the Django Admin is a *killer feature* for Django. + +.. class:: incremental + +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). + +.. class:: incremental + +Open the ``settings.py`` file from our ``mysite`` project package and +verify that you see it in the list: + +.. code-block:: python + :class: incremental small + + 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. + +.. class:: incremental + +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*. + +.. class:: incremental + +* 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. + +.. class:: incremental + +That iterable is generally built by calling the ``django.conf.urls.patterns`` +function. + +.. class:: incremental + +It's best to build it that way, but in reality, any iterable will do. + +.. class:: incremental + +However, the name you give this iterable is **not flexible**. + +.. class:: incremental + +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 + +.. class:: incremental + +It is standard to include these urlconfs by rooting them at some path in your +site. + +.. container:: incremental + + You can do this by using the ``django.conf.urls.include`` function as the + callable in a ``url`` call: + + .. code-block:: python + :class: small + + 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. + +.. container:: incremental + + verify the following lines in ``urls.py``: + + .. code-block:: python + :class: small + + 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. + +.. class:: incremental + +In your terminal, use the ``runserver`` management command to start the +development server: + +.. class:: incremental + +:: + + (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: + +.. image:: img/django-admin-login.png + :align: center + :width: 50% + +.. class:: incremental + +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: + +.. image:: img/admin_index.png + :align: center + :width: 90% + +.. class:: incremental + +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. + +.. class:: incremental + +Find the ``admin.py`` file in the ``myblog`` package. Open it, add the +following and save the file: + +.. code-block:: python + :class: incremental + + from django.contrib import admin # <- this is already there. + from myblog.models import Post + + admin.site.register(Post) + +.. class:: incremental + +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. + +.. class:: incremental + +Look at the listing of Posts. Because of our ``__unicode__`` method we see a +nice title. + +.. class:: incremental + +Are there other fields you'd like to see listed? + +.. class:: incremental + +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. + +.. class:: incremental + +We've also spent some time getting to know the Query API provided by model +managers and QuerySets. + +.. class:: incremental + +We've also hooked up the Django Admin and noted some shortcomings. + +.. class:: incremental + +In class we'll learn how to put a front end on this, add new models, and +customize the admin experience. + + From 0c1ccfc905cbc4758c347c0cc831ba9f948f603b Mon Sep 17 00:00:00 2001 From: cewing Date: Sat, 8 Feb 2014 18:51:25 -0800 Subject: [PATCH 006/223] fix up session 7, gut session 8, move css resource to session 7 resources --- .../{session08 => session07}/django_blog.css | 0 source/presentations/session07.rst | 1559 ++++++++++++++- source/presentations/session08.rst | 1746 +---------------- 3 files changed, 1554 insertions(+), 1751 deletions(-) rename resources/{session08 => session07}/django_blog.css (100%) diff --git a/resources/session08/django_blog.css b/resources/session07/django_blog.css similarity index 100% rename from resources/session08/django_blog.css rename to resources/session07/django_blog.css diff --git a/source/presentations/session07.rst b/source/presentations/session07.rst index 7cfabd08..66bdf5b0 100644 --- a/source/presentations/session07.rst +++ b/source/presentations/session07.rst @@ -5,11 +5,11 @@ Python Web Programming :align: left :width: 50% -Session 7: Introducing Django +Session 7: A Django Application .. class:: intro-blurb right -Wherein we become 'perfectionists with deadlines' +Wherein we build a simple blogging app. .. class:: image-credit @@ -62,7 +62,7 @@ You write the models, it provides the UI .. class:: incremental center -**Really** +You've seen this in action. Pretty neat, eh? The Pareto Principle @@ -145,48 +145,1593 @@ that documentation for every commit **this is awesome** +Where We Stand +-------------- -DEPARTING FROM SCRIPT +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 writin 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 %} -Break Time + +Hook it Up ---------- -.. class:: big-centered +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_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``: -Let's take a break here and return in 10 minutes. +.. 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 editing relationship so that you can add categories to +posts, and cannot choose posts from categories. +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 index ffa6f7ef..da9e0ad3 100644 --- a/source/presentations/session08.rst +++ b/source/presentations/session08.rst @@ -5,1756 +5,14 @@ Internet Programming with Python :align: left :width: 50% -Session 8: A Django Application +Session 8: Extending Django .. class:: intro-blurb right -Wherein we complete our Django blog app. +Wherein we extend our Django blog app. .. class:: image-credit image: http://djangopony.com/ -Where We Stand --------------- - -We've created a couple of models, Post and Category, that make up our blog -app. - -.. class:: incremental - -We've taken some time to get familiar with the basic workings of the Django -ORM. - -.. class:: incremental - -We've made a minor modification to our model classes and written tests for it. - -.. class:: incremental - -And we've installed the Django Admin site and added our app to it. - - -Customizing the Admin ---------------------- - -We have noted, however, that the admin isn't exactly right for our needs. - -.. class:: incremental - -* Listing of posts should show created, modified and published dates -* Listing of posts should show the author of a post, with a link to the author -* It should be possible to add a post to a category while creating or editing - it - -.. class:: incremental small center - -https://docs.djangoproject.com/en/1.5/ref/contrib/admin/ - - -The ModelAdmin Class --------------------- - -Open ``admin.py`` from your ``myblog`` package. - -.. class:: incremental - -* The ``admin.site`` is a globally available instance of the ``Admin`` class. -* It is initialized at runtime automatically. -* It stores a registry of the models that are registered with it. -* Each call to ``admin.site.register`` adds a new model to the global *site*. -* ``register`` takes two args: a *Model* subclass and an optional *ModelAdmin* subclass -* If you call it without the optional subclass, you get the default. - -.. class:: incremental - -Most usable admin functions are provided by the ModelAdmin. - - -Custom ModelAdmin ------------------ - -Our first task is to list date and author information. - -.. container:: incremental - - In ``admin.py`` add the following code (): - - .. code-block:: python - :class: small - - # this is new - class PostAdmin(admin.ModelAdmin): - list_display = ('__unicode__', 'created_date', 'modified_date', - 'published_date', 'author') - - admin.site.register(Post, PostAdmin) #<- update this registration - -.. class:: incremental - -Let's see what that did. - - -View The Results ----------------- - -If you haven't already, activate your virtualenv then fire up the development -server: - -:: - - (djangoenv)$ python manage.py runserver - -.. class:: incremental - -Load http://localhost:8000/admin and click through to the Post admin. - -.. class:: incremental - -Pretty simple, eh? - - -List Display ------------- - -A Couple of things about the ``list_display`` option are important to know: - -.. class:: incremental - -* The value you provide must be an iterable even if it has only one item -* Each item in the iterable becomes a column in the list -* The first item is the one that links to the change page for that object - - * That can be customized by the ``list_display_links`` option - -* Listed items can be field names or callables. - -* Callables can be module-level functions, or methods on the ModelAdmin or - Model - - -A Better Author Listing ------------------------ - -Let's use this last bit to fix the author listing. - -.. class:: incremental - -We'll need functionality that provides: - -.. class:: incremental - -* The full name of the author, if present, otherwise the username. -* A link to the admin change form for that author. - -.. class:: incremental - -Where should this go? Module? ModelAdmin? Model? - -.. class:: incremental - -* The first could be useful in public listings -* The second is really only useful on the backend - - -Add Tests ---------- - -In ``tests.py`` add the following test: - -.. code-block:: python - :class: small - - class PostTestCase(TestCase): - #... - def test_author_name(self): - for author in User.objects.all(): - fn, ln, un = (author.first_name, - author.last_name, - author.username) - author_name = Post(author=author).author_name() - if not (fn and ln): - self.assertEqual(author_name, un) - else: - if fn: - self.assertTrue(fn in author_name) - if ln: - self.assertTrue(ln in author_name) - - -Add Tests ---------- - -To test the admin, we'll first need a new TestClass: - -.. code-block:: python - :class: small - - # new imports - from django.contrib.admin.sites import AdminSite - from myblog.admin import PostAdmin - - # new TestCase - class PostAdminTestCase(TestCase): - fixtures = ['myblog_test_fixture.json', ] - - def setUp(self): - admin = AdminSite() - self.ma = PostAdmin(Post, admin) - for author in User.objects.all(): - title = "%s's title" % author.username - post = Post(title=title, author=author) - post.save() - - -Add Tests ---------- - -And then we need a test added to it: - -.. code-block:: python - :class: small - - def test_author_link(self): - expected_link_path = '/admin/auth/user/%s' - for post in Post.objects.all(): - expected = expected_link_path % post.author.pk - actual = self.ma.author_link(post) - self.assertTrue(expected in actual) - -.. container:: incremental - - Quit the django server and run your tests: - - .. class:: small - - :: - - (djangoenv)$ python manage.py test myblog - ... - Ran 4 tests in 0.026s - FAILED (errors=2) - - -Make Them Pass --------------- - -First, add the ``author_name`` method to our Post model in ``models.py``: - -.. code-block:: python - :class: small - - def author_name(self): - raw_name = "%s %s" % (self.author.first_name, - self.author.last_name) - name = raw_name.strip() - if not name: - name = self.author.username - return name - -.. class:: small incremental - -:: - - (djangoenv)$ python manage.py test myblog - ... - Ran 4 tests in 0.027s - FAILED (errors=1) - - -Make Them Pass --------------- - -Finally, add the ``author_link`` method to the PostAdmin in ``admin.py``: - -.. code-block:: python - :class: small - - # add an import - from django.core.urlresolvers import reverse - - # and a method - class PostAdmin(admin.ModelAdmin): - #... - def author_link(self, post): - url = reverse('admin:auth_user_change', args=(post.id,)) - name = post.author_name() - return '%s' % (url, name) - -.. class:: small incremental - -:: - - (djangoenv)$ python manage.py test myblog - ...Ran 4 tests in 0.035s - OK - - -Hook It Up ----------- - -First, replace the ``'author'`` name in ``list_display`` with -``'author_link'``: - -.. code-block:: python - :class: small - - list_display = (..., 'author_link') - -.. container:: incremental - - We also need to let the admin know our HTML is safe: - - .. code-block:: python - :class: small - - def author_link(self, post): - #... method body - author_link.allow_tags = True - - -Wait, What?? ------------- - -In Python, *everything* is an object. Even methods of classes. - -.. class:: incremental - -The Django admin uses special *method attributes* to control the methods you -create for ``list_display``. - -.. container:: incremental - - Another special attribute controls the column title used in the list page: - - .. code-block:: python - :class: small - - def author_link(self, post): - #... method body - author_link.allow_tags = True - author_link.short_description = "Author" #<- add this - - -See The Results ---------------- - -Start up the Django server again and see what you've done: - -.. class:: small - -:: - - (djangoenv)$ python manage.py runserver - -.. class:: incremental - -Reload your admin site, click on the Post admin and see the new 'Author' -column. - -.. class:: incremental - -* Click on an author name. -* Set the first and last names (if you haven't already). -* Go back to Posts and see the outcome of this change. - -.. class:: incremental - -Not bad, eh? - - -Categorize Posts ----------------- - -We'd like to be able to add categories to posts while adding or editing them. - -.. class:: incremental - -But there is no field on the ``Post`` model that would show them. - -.. class:: incremental - -Django provides the concept of an ``inline`` form to allow adding objects that -are related when there is no field available. - -.. class:: incremental - -In the Django Admin, these are created using subclasses of the -``InlineAdmin``. - - -Create an Inline Admin ----------------------- - -In ``admin.py`` add the following code *above* the definition of PostAdmin: - -.. code-block:: python - :class: small - - class CategoryInlineAdmin(admin.TabularInline): - model = Category.posts.through - extra = 1 - -.. container:: incremental - - And then add one line to the PostAdmin class definition: - - .. code-block:: python - :class: small - - class PostAdmin(admin.ModelAdmin): - #... other options - inlines = [CategoryInlineAdmin, ] - - #... methods - - -Try It Out ----------- - -Restart the Django server and see what you've done: - -.. class:: small - -:: - - (djangoenv)$ python manage.py runserver - -.. class:: incremental - -Note that you can even add *new* categories via the inline form. - -.. class:: incremental - -But, in the form for a category, you see the field for Post. That shouldn't be -there. - - -A Final Tweak -------------- - -See if you can figure out how to remove the ``posts`` field from the -CategoryAdmin. - -.. code-block:: python - :class: small incremental - - # create a custom model admin class - class CategoryAdmin(admin.ModelAdmin): - exclude = ('posts', ) - - # and register Category to use it in the Admin - admin.site.register(Category, CategoryAdmin) - - -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 -------------- - -We talked in the previous session about the Django urlconf - -.. 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"), - ) - - -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. - - -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. - - -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``: - - .. 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 regexp like so: - - .. code-block:: python - :class: small - - r'^posts/(?P\d+)/$' - -.. class:: incremental - -Reload the same url. Notice the change. - -.. class:: incremental - -How you declare a capture group in your url pattern regexp influenced 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, 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 - -.. class:: incremental - -To save us time, I've written these tests already - -.. class:: incremental - -You can find them in the class resources directory: ``blog_view_tests.py`` - -.. class:: incremental - -Copy the contents of that file into our blog ``tests.py`` file. - - -Run The Tests -------------- - -:: - - (djangoenv)$ python manage.py test myblog - ... - ---------------------------------------------------------------------- - Ran 7 tests in 0.478s - - FAILED (failures=2) - Destroying test database for alias 'default'... - - -Our First View --------------- - -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`` find ``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 7 tests in 0.494s - FAILED (failures=1) - - -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 - - # replace RequestContext and loader import - from django.shortcuts import render - - # 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 - - -Detail View ------------ - -Next, let's write a view function for the detail view of a post - -.. container:: incremental - - It should have the following signature: - - .. code-block:: python - :class: small - - detail_view(request, post_id) - -.. class:: incremental - -We will call the template ``detail.html`` - -.. class:: incremental - -Let's start with the code in ``views.py`` - - -detail_view ------------ - -.. 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) - -.. class:: incremental - -All models raise a DoesNotExist exception if ``get`` returns nothing. - -.. class:: incremental - -We can use that fact to raise a Not Found exception. - -.. class:: incremental - -Django will handle the rest for us. - - -detail.html ------------ - -.. code-block:: jinja - :class: small - - {% 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 ``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 7 tests in 0.513s - 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_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. - - -Login/Logout ------------- - -Django 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 = '/' - - -Handling Forms --------------- - -Adding login and logout has given us a sneak peek at forms. - -.. class:: incremental - -But there is a *lot* of magic happening that we should see directly. - -.. class:: incremental - -As a last task, let's add a non-admin way to create new posts. - -.. class:: incremental - -We'll use a form, submit it to a view, and have it create a new Post object - - -Django Forms ------------- - -Forms are, like Models, a Django *class* - -.. class:: incremental - -Like Models, you add fields to a form as class *attributes* - -.. class:: incremental - -Like Model fields, the fields on a form are also Python class instances. - -.. class:: incremental - -Unlike Model fields, Form fields are built to interact with data in a -*request* - -.. class:: incremental - -By tradition, they are created in a module called ``forms.py`` - - -Post Form ---------- - -Create ``forms.py`` in ``myblog`` and open it in your editor. - -.. code-block:: python - :class: small - - from django import forms - from myblog.models import Post - - class PostForm(forms.ModelForm): - - class Meta: - model = Post - fields = ('title', 'text', 'author') - -.. class:: incremental - -The ``ModelForm`` class generates fields based on the model. - -.. class:: incremental - -Use ``fields`` to force only a subset of those. - - -A View for Our Form -------------------- - -The basic approach to handling forms in Django always follows this pattern: - -.. code-block:: python - :class: small - - if request.method == 'POST': - # bind a form instance to POST data - if form.is_valid(): - # process the form data here - # tell the user about the success - else: - # tell the user about the problem - else: - # create an unbound form - # render the form template - -.. class:: incremental - -Let's create a ``add_post`` view that does this with our ``PostForm`` - -add_post view -------------- - -.. code-block:: python - :class: small - - # add imports to views.py - from django.core.exceptions import PermissionDenied - from django.contrib import messages - from django.core.urlresolvers import reverse - from myblog.forms import PostForm - - # and a new view function: - def add_view(request): - user = request.user - if not user.is_authenticated: - raise PermissionDenied - if request.method == 'POST': - form = PostForm(request.POST) - # handle form submission - else: - form = PostForm() - context = {'form': form} - return render(request, 'add.html', context) - - -Add A URL ---------- - -In ``myblog/urls.py`` add a new entry to our urlconf: - -.. code-block:: python - :class: small - - url(r'^add/$', - 'add_view', - name="add_post"), - -.. container:: incremental - - And hook it up to the control bar link in ``base.html`` - - .. code-block:: jinja - - - new post - - -Create add.html ---------------- - -Finally, we need to create a template, ``add.html`` in ``myblog/templates``: - -.. code-block:: jinja - :class: small - - {% extends "base.html" %} - - {% block content %} -

New Blog Post

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

-
- {% endblock %} - - -Try it Out ----------- - -You should be able to click on the 'new post' button in the control bar. - -.. class:: incremental - -How does the form look? - -.. class:: incremental - -It would be nice if the 'author' field were auto-populated, and even hidden. - -.. class:: incremental - -Let's do that next. - - -Form 'initial' --------------- - -When instantiating a form, you can pass it *initial* values. - -.. container:: incremental - - In ``views.py`` make the following changes to the ``add_view``: - - .. code-block:: python - :class: small - - def add_view(request): - user = request.user - if not user.is_authenticated: - raise PermissionDenied - if request.method == 'POST': - #... not quite ready for this yet. - else: - initial = {'author': user} #<- add this - form = PostForm(initial=initial) #<- updated - - -Hidden Fields -------------- - -If you reload, you should now see ``author`` pre-popluated. - -.. container:: incremental - - To hide it, we must update the 'widget' it will use in ``forms.py``: - - .. code-block:: python - :class: small - - class PostForm(forms.ModelForm): - - class Meta: - #... - widgets = { - 'author': forms.HiddenInput(), - } - -.. class:: incremental - -Reload again to see the input disappear. Check page source to see the 'hidden' -input. - - -Form Submission ---------------- - -That's all we need to have for processing. We want to: - -.. class:: incremental - -* Validate the form input -* Report validation errors to the user and return the bound form -* If no errors occur, save the form, creating an instance -* Report success to the user and redirect to the list homepage. - -.. class:: incremental - -Django's ``messages`` framework will allow notifications. - - -Handle a Submitted Form ------------------------ - -In ``views.py``, update the ``add_view``: - -.. code-block:: python - :class: small - - def add_view(request): - user = request.user - if not user.is_authenticated(): - raise PermissionDenied - if request.method == 'POST': - form = PostForm(request.POST) - if form.is_valid(): - post = form.save() - msg = "post '%s' saved" % post - messages.add_message(request, messages.INFO, msg) - return HttpResponseRedirect(reverse('blog_index')) - else: - messages.add_message(request, messages.INFO, - "please fix the errors below") - else: - #... - - -Showing Messages ----------------- - -The ``messages`` framework pushes messages onto a stack. - -.. class:: incremental - -You can then pop them back off by printing them in a template. - -.. container:: incremental - - In ``base.html`` let's give them a place to go: - - .. code-block:: jinja - :class: small - -
- {% if messages %} -
- {% for message in messages %} -

{{ message }}

- {% endfor %} -
- {% endif %} - - - -Final Run ---------- - -That should be enough to get us going. - -.. class:: incremental - -Fill out your form, supplying title and text. - -.. class:: incremental - -Submit the form, and notice the messaging from the system. - -.. class:: incremental - -Why is your new post not appearing in the blog list? - - -Next Steps ----------- - -There are a number of improvements one could make to this blog system: - -.. class:: incremental - -* Send email notifications to "blog administrators" that would notify them of - new posts awaiting publication. -* Provide a second list view giving users access to edit their unpublished - posts. -* Provide restricted access to certain users to view all unpublished posts and - choose to publish them. -* Add a form field for the post category and put the post in a category when - processing the form -* Provide a list view of a category, showing all posts in it. -* Provide HTML editing for post text. - - -That's All For Now ------------------- - -But this is all we have time for in this session. - -.. class:: big-centered incremental - -We'll see you next session! From cf41f3e66697d9308f0811dc63ede6ef2a703650 Mon Sep 17 00:00:00 2001 From: cewing Date: Sat, 8 Feb 2014 19:22:44 -0800 Subject: [PATCH 007/223] add assignment for session 7 --- assignments/session07/tasks.txt | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 assignments/session07/tasks.txt diff --git a/assignments/session07/tasks.txt b/assignments/session07/tasks.txt new file mode 100644 index 00000000..b3df2917 --- /dev/null +++ b/assignments/session07/tasks.txt @@ -0,0 +1,49 @@ +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/ From cc84152c7495450005a1ef80ea5e24f4ed13e4b5 Mon Sep 17 00:00:00 2001 From: cewing Date: Sat, 8 Feb 2014 19:23:50 -0800 Subject: [PATCH 008/223] update the session 8 resources to contain what is finished after session 7 --- resources/session08/mysite/myblog/admin.py | 38 +--- .../mysite/myblog/migrations/0001_initial.py | 78 ++++++++ .../migrations/0002_auto__add_category.py | 93 +++++++++ .../mysite/myblog/migrations/__init__.py | 0 resources/session08/mysite/myblog/models.py | 14 +- .../mysite/myblog/static/django_blog.css | 2 +- .../mysite/myblog/templates/detail.html | 2 +- .../mysite/myblog/templates/list.html | 9 +- resources/session08/mysite/myblog/tests.py | 43 +--- resources/session08/mysite/myblog/urls.py | 5 +- resources/session08/mysite/myblog/views.py | 38 +--- resources/session08/mysite/mysite/settings.py | 189 ++++++------------ .../mysite/mysite/templates/base.html | 13 +- .../mysite/mysite/templates/login.html | 2 +- resources/session08/mysite/mysite/urls.py | 14 +- resources/session08/mysite/mysite/wsgi.py | 26 +-- source/presentations/session07.rst | 7 +- 17 files changed, 269 insertions(+), 304 deletions(-) create mode 100644 resources/session08/mysite/myblog/migrations/0001_initial.py create mode 100644 resources/session08/mysite/myblog/migrations/0002_auto__add_category.py create mode 100644 resources/session08/mysite/myblog/migrations/__init__.py diff --git a/resources/session08/mysite/myblog/admin.py b/resources/session08/mysite/myblog/admin.py index a2c21638..f4539ccc 100644 --- a/resources/session08/mysite/myblog/admin.py +++ b/resources/session08/mysite/myblog/admin.py @@ -1,37 +1,7 @@ from django.contrib import admin -from django.core.urlresolvers import reverse -from myblog.models import Post, Category +from myblog.models import Post +from myblog.models import Category -class CategoryInlineAdmin(admin.TabularInline): - model = Category.posts.through - extra = 1 - verbose_name = "Category" - verbose_name_plural = "Categories" - - -class PostAdmin(admin.ModelAdmin): - list_display = ('__unicode__', 'created_date', 'modified_date', - 'published_date', 'author_link') - readonly_fields = ('created_date', 'modified_date') - inlines = [CategoryInlineAdmin, ] - - def author_link(self, post): - url = reverse('admin:auth_user_change', args=(post.id,)) - name = post.author_name() - return '%s' % (url, name) - author_link.allow_tags = True - - def get_readonly_fields(self, request, obj=None): - fields = () - if obj is not None: - fields = self.readonly_fields - return fields - - -class CategoryAdmin(admin.ModelAdmin): - exclude = ('posts', ) - - -admin.site.register(Post, PostAdmin) -admin.site.register(Category, CategoryAdmin) +admin.site.register(Post) +admin.site.register(Category) diff --git a/resources/session08/mysite/myblog/migrations/0001_initial.py b/resources/session08/mysite/myblog/migrations/0001_initial.py new file mode 100644 index 00000000..75c584ca --- /dev/null +++ b/resources/session08/mysite/myblog/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# -*- 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')(null=True, 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', [], {'null': 'True', 'blank': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '128'}) + } + } + + complete_apps = ['myblog'] \ No newline at end of file diff --git a/resources/session08/mysite/myblog/migrations/0002_auto__add_category.py b/resources/session08/mysite/myblog/migrations/0002_auto__add_category.py new file mode 100644 index 00000000..5afa4370 --- /dev/null +++ b/resources/session08/mysite/myblog/migrations/0002_auto__add_category.py @@ -0,0 +1,93 @@ +# -*- 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', [], {'null': 'True', 'blank': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '128'}) + } + } + + complete_apps = ['myblog'] \ No newline at end of file diff --git a/resources/session08/mysite/myblog/migrations/__init__.py b/resources/session08/mysite/myblog/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/session08/mysite/myblog/models.py b/resources/session08/mysite/myblog/models.py index 44ed1e0f..67674268 100644 --- a/resources/session08/mysite/myblog/models.py +++ b/resources/session08/mysite/myblog/models.py @@ -4,7 +4,7 @@ class Post(models.Model): title = models.CharField(max_length=128) - text = models.TextField(blank=True) + text = models.TextField(blank=True, null=True) author = models.ForeignKey(User) created_date = models.DateTimeField(auto_now_add=True) modified_date = models.DateTimeField(auto_now=True) @@ -13,19 +13,12 @@ class Post(models.Model): def __unicode__(self): return self.title - def author_name(self): - raw_name = "%s %s" % (self.author.first_name, - self.author.last_name) - name = raw_name.strip() - if not name: - name = self.author.username - return name - class Category(models.Model): name = models.CharField(max_length=128) description = models.TextField(blank=True) - posts = models.ManyToManyField(Post, + posts = models.ManyToManyField( + Post, blank=True, null=True, related_name='categories' @@ -33,4 +26,3 @@ class Category(models.Model): def __unicode__(self): return self.name - diff --git a/resources/session08/mysite/myblog/static/django_blog.css b/resources/session08/mysite/myblog/static/django_blog.css index 64560dc0..45a882de 100644 --- a/resources/session08/mysite/myblog/static/django_blog.css +++ b/resources/session08/mysite/myblog/static/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/mysite/myblog/templates/detail.html b/resources/session08/mysite/myblog/templates/detail.html index 34e1b356..cd0322ff 100644 --- a/resources/session08/mysite/myblog/templates/detail.html +++ b/resources/session08/mysite/myblog/templates/detail.html @@ -14,4 +14,4 @@

{{ post }}

  • {{ category }}
  • {% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/resources/session08/mysite/myblog/templates/list.html b/resources/session08/mysite/myblog/templates/list.html index b2299169..c51702ff 100644 --- a/resources/session08/mysite/myblog/templates/list.html +++ b/resources/session08/mysite/myblog/templates/list.html @@ -2,12 +2,11 @@ {% block content %}

    Recent Posts

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

    - {{ post }} -

    +

    {{ post }}

    @@ -21,4 +20,4 @@

    {% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/resources/session08/mysite/myblog/tests.py b/resources/session08/mysite/myblog/tests.py index a51abbe7..5671bfda 100644 --- a/resources/session08/mysite/myblog/tests.py +++ b/resources/session08/mysite/myblog/tests.py @@ -1,12 +1,9 @@ import datetime from django.test import TestCase from django.contrib.auth.models import User -from django.contrib.admin.sites import AdminSite from django.utils.timezone import utc - from myblog.models import Post from myblog.models import Category -from myblog.admin import PostAdmin class PostTestCase(TestCase): @@ -21,19 +18,6 @@ def test_unicode(self): actual = unicode(p1) self.assertEqual(expected, actual) - def test_author_name(self): - for author in User.objects.all(): - fn, ln, un = (author.first_name, author.last_name, - author.username) - author_name = Post(author=author).author_name() - if not (fn and ln): - self.assertEqual(author_name, un) - else: - if fn: - self.assertTrue(fn in author_name) - if ln: - self.assertTrue(ln in author_name) - class CategoryTestCase(TestCase): @@ -44,36 +28,14 @@ def test_unicode(self): self.assertEqual(expected, actual) -class PostAdminTestCase(TestCase): - fixtures = ['myblog_test_fixture.json', ] - - def setUp(self): - admin = AdminSite() - self.ma = PostAdmin(Post, admin) - for author in User.objects.all(): - title = "%s's title" % author.username - post = Post(title=title, author=author) - post.save() - self.client.login(username='admin', password='secret') - - def test_author_link(self): - expected_link_path = '/admin/auth/user/%s' - for post in Post.objects.all(): - expected = expected_link_path % post.author.pk - actual = self.ma.author_link(post) - self.assertTrue(expected in 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) - self.category = Category(name='A Category') - self.category.save() for count in range(1,11): post = Post(title="Post %d Title" % count, text="foo", @@ -83,9 +45,6 @@ def setUp(self): pubdate = self.now - self.timedelta * count post.published_date = pubdate post.save() - if bool(count & 1): - # put odd items in category: - self.category.posts.add(post) def test_list_only_published(self): resp = self.client.get('/') diff --git a/resources/session08/mysite/myblog/urls.py b/resources/session08/mysite/myblog/urls.py index 3c72e6bc..8b97b5dc 100644 --- a/resources/session08/mysite/myblog/urls.py +++ b/resources/session08/mysite/myblog/urls.py @@ -5,10 +5,7 @@ url(r'^$', 'list_view', name="blog_index"), - url(r'^posts/(?P\d+)/$', + url(r'^posts/(\d+)/$', 'detail_view', name="blog_detail"), - url(r'^add/$', - 'add_view', - name="add_post"), ) diff --git a/resources/session08/mysite/myblog/views.py b/resources/session08/mysite/myblog/views.py index 3768595e..c3f48eb0 100644 --- a/resources/session08/mysite/myblog/views.py +++ b/resources/session08/mysite/myblog/views.py @@ -1,12 +1,7 @@ -from django.http import HttpResponse, Http404 -from django.http import HttpResponseRedirect from django.shortcuts import render -from django.core.exceptions import PermissionDenied -from django.core.urlresolvers import reverse -from django.contrib import messages - +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.template import RequestContext, loader from myblog.models import Post -from myblog.forms import PostForm def stub_view(request, *args, **kwargs): @@ -23,8 +18,12 @@ def stub_view(request, *args, **kwargs): 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) + template = loader.get_template('list.html') + context = RequestContext(request, { + 'posts': posts, + }) + body = template.render(context) + return HttpResponse(body, content_type="text/html") def detail_view(request, post_id): @@ -35,24 +34,3 @@ def detail_view(request, post_id): raise Http404 context = {'post': post} return render(request, 'detail.html', context) - - -def add_view(request): - user = request.user - if not user.is_authenticated: - raise PermissionDenied - if request.method == 'POST': - form = PostForm(request.POST) - if form.is_valid: - post = form.save() - msg = "post '%s' saved" % post - messages.add_message(request, messages.INFO, msg) - return HttpResponseRedirect(reverse('blog_index')) - else: - messages.add_message("please fix the errors below") - else: - initial = {'author': user} - form = PostForm(initial=initial) - - context = {'form': form} - return render(request, 'add.html', context) diff --git a/resources/session08/mysite/mysite/settings.py b/resources/session08/mysite/mysite/settings.py index 8c2a28a9..e9a98f3c 100644 --- a/resources/session08/mysite/mysite/settings.py +++ b/resources/session08/mysite/mysite/settings.py @@ -1,162 +1,91 @@ -# Django settings for mysite project. +""" +Django settings for mysite project. -DEBUG = True -TEMPLATE_DEBUG = DEBUG +For more information on this file, see +https://docs.djangoproject.com/en/1.6/topics/settings/ -ADMINS = ( - # ('Your Name', 'your_email@example.com'), -) +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.6/ref/settings/ +""" -MANAGERS = ADMINS +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'mysite.db', - # The following settings are not used with sqlite3: - 'USER': '', - 'PASSWORD': '', - 'HOST': '', - 'PORT': '', - } -} -# Hosts/domain names that are valid for this site; required if DEBUG is False -# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts -ALLOWED_HOSTS = [] +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ -# Local time zone for this installation. Choices can be found here: -# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name -# although not all choices may be available on all operating systems. -# In a Windows environment this must be set to your system time zone. -TIME_ZONE = 'America/Chicago' +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'izjnm)3w#gc)za-gnc0vwayf45ucd1p59w=_ia74h^q7z2j@p8' -# Language code for this installation. All choices can be found here: -# http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-us' - -SITE_ID = 1 - -# If you set this to False, Django will make some optimizations so as not -# to load the internationalization machinery. -USE_I18N = True - -# If you set this to False, Django will not format dates, numbers and -# calendars according to the current locale. -USE_L10N = True - -# If you set this to False, Django will not use timezone-aware datetimes. -USE_TZ = True - -# Absolute filesystem path to the directory that will hold user-uploaded files. -# Example: "/var/www/example.com/media/" -MEDIA_ROOT = '' - -# URL that handles the media served from MEDIA_ROOT. Make sure to use a -# trailing slash. -# Examples: "http://example.com/media/", "http://media.example.com/" -MEDIA_URL = '' - -# Absolute path to the directory static files should be collected to. -# Don't put anything in this directory yourself; store your static files -# in apps' "static/" subdirectories and in STATICFILES_DIRS. -# Example: "/var/www/example.com/static/" -STATIC_ROOT = '' +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True -# URL prefix for static files. -# Example: "http://example.com/static/", "http://static.example.com/" -STATIC_URL = '/static/' +TEMPLATE_DEBUG = True -# Additional locations of static files -STATICFILES_DIRS = ( - # Put strings here, like "/home/html/static" or "C:/www/django/static". - # Always use forward slashes, even on Windows. - # Don't forget to use absolute paths, not relative paths. -) +ALLOWED_HOSTS = [] -# List of finder classes that know how to find static files in -# various locations. -STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', -# 'django.contrib.staticfiles.finders.DefaultStorageFinder', -) -# Make this unique, and don't share it with anybody. -SECRET_KEY = '(g!gi6orza2nez)tf-xj_g%5g3!+pmdhs15xjgat)0g!10qku*' +# Application definition -# List of callables that know how to import templates from various sources. -TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', -# 'django.template.loaders.eggs.Loader', +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'south', + 'myblog', ) MIDDLEWARE_CLASSES = ( - 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - # Uncomment the next line for simple clickjacking protection: - # 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) ROOT_URLCONF = 'mysite.urls' -# Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = 'mysite.wsgi.application' -TEMPLATE_DIRS = ( - # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". - # Always use forward slashes, even on Windows. - # Don't forget to use absolute paths, not relative paths. - '/Users/cewing/projects/training/uw_pce/testme/training.python_web/resources/session08/mysite/mysite/templates' -) -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', - 'myblog', -) +# Database +# https://docs.djangoproject.com/en/1.6/ref/settings/#databases -# A sample logging configuration. The only tangible logging -# performed by this configuration is to send an email to -# the site admins on every HTTP 500 error when DEBUG=False. -# See http://docs.djangoproject.com/en/dev/topics/logging for -# more details on how to customize your logging configuration. -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse' - } - }, - 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler' - } - }, - 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, - }, +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'mysite.db', } } +# Internationalization +# https://docs.djangoproject.com/en/1.6/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.6/howto/static-files/ + +STATIC_URL = '/static/' + +TEMPLATE_DIRS = ( + '/Users/cewing/projects/training/uw_pce/testme/mysite/mysite/templates', +) LOGIN_URL = '/login/' -LOGIN_REDIRECT_URL = '/' \ No newline at end of file +LOGIN_REDIRECT_URL = '/' + diff --git a/resources/session08/mysite/mysite/templates/base.html b/resources/session08/mysite/mysite/templates/base.html index 5168b320..61300646 100644 --- a/resources/session08/mysite/mysite/templates/base.html +++ b/resources/session08/mysite/mysite/templates/base.html @@ -8,22 +8,15 @@ +

    this is my user: {{user}}

    - {% if messages %} -
    - {% for message in messages %} -

    {{ message }}

    - {% endfor %} -
    - {% endif %}
    {% block content %} [content will go here] @@ -31,4 +24,4 @@
    - \ No newline at end of file + diff --git a/resources/session08/mysite/mysite/templates/login.html b/resources/session08/mysite/mysite/templates/login.html index e1a56ee8..1566d0f7 100644 --- a/resources/session08/mysite/mysite/templates/login.html +++ b/resources/session08/mysite/mysite/templates/login.html @@ -6,4 +6,4 @@

    My Blog Login

    {{ form.as_p }}

    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/resources/session08/mysite/mysite/urls.py b/resources/session08/mysite/mysite/urls.py index f3f985db..9c67dfdb 100644 --- a/resources/session08/mysite/mysite/urls.py +++ b/resources/session08/mysite/mysite/urls.py @@ -1,11 +1,14 @@ from django.conf.urls import patterns, include, url -# Uncomment the next two lines to enable the admin: from django.contrib import admin admin.autodiscover() urlpatterns = patterns('', + # Examples: + # url(r'^$', 'mysite.views.home', name='home'), + # url(r'^blog/', include('blog.urls')), url(r'^', include('myblog.urls')), + url(r'^admin/', include(admin.site.urls)), url(r'^login/$', 'django.contrib.auth.views.login', {'template_name': 'login.html'}, @@ -14,13 +17,4 @@ 'django.contrib.auth.views.logout', {'next_page': '/'}, name="logout"), - # Examples: - # url(r'^$', 'mysite.views.home', name='home'), - # url(r'^mysite/', include('mysite.foo.urls')), - - # Uncomment the admin/doc line below to enable admin documentation: - # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), - - # Uncomment the next line to enable the admin: - url(r'^admin/', include(admin.site.urls)), ) diff --git a/resources/session08/mysite/mysite/wsgi.py b/resources/session08/mysite/mysite/wsgi.py index 34e900eb..10ef32d9 100644 --- a/resources/session08/mysite/mysite/wsgi.py +++ b/resources/session08/mysite/mysite/wsgi.py @@ -1,32 +1,14 @@ """ WSGI config for mysite project. -This module contains the WSGI application used by Django's development server -and any production WSGI deployments. It should expose a module-level variable -named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover -this application via the ``WSGI_APPLICATION`` setting. - -Usually you will have the standard Django WSGI application here, but it also -might make sense to replace the whole Django WSGI application with a custom one -that later delegates to the Django one. For example, you could introduce WSGI -middleware here, or combine a Django application with an application of another -framework. +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.6/howto/deployment/wsgi/ """ -import os -# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks -# if running multiple sites in the same mod_wsgi process. To fix this, use -# mod_wsgi daemon mode with each site in its own daemon process, or use -# os.environ["DJANGO_SETTINGS_MODULE"] = "mysite.settings" +import os os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") -# This application object is used by any WSGI server configured to use this -# file. This includes Django's development server, if the WSGI_APPLICATION -# setting points here. from django.core.wsgi import get_wsgi_application application = get_wsgi_application() - -# Apply WSGI middleware here. -# from helloworld.wsgi import HelloWorldApplication -# application = HelloWorldApplication(application) diff --git a/source/presentations/session07.rst b/source/presentations/session07.rst index 66bdf5b0..3f270de1 100644 --- a/source/presentations/session07.rst +++ b/source/presentations/session07.rst @@ -1690,8 +1690,7 @@ Post admin*. Your Assignment --------------- -You'll be reversing that editing relationship so that you can add categories to -posts, and cannot choose posts from categories. +You'll be reverse that relationship so that you can only add categories to posts Take the following steps: @@ -1721,7 +1720,9 @@ 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: + +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. From f52e7da71e80de5242f1db5329322bc32bf848ea Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Tue, 11 Feb 2014 12:00:17 -0800 Subject: [PATCH 009/223] fixed plural --- source/presentations/django_intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/django_intro.rst b/source/presentations/django_intro.rst index 0951003c..8824066a 100644 --- a/source/presentations/django_intro.rst +++ b/source/presentations/django_intro.rst @@ -246,7 +246,7 @@ Django's ORM provides a layer of *abstraction* between you and SQL .. class:: incremental -You write Python classes called *models* describing the object that make up +You write Python classes called *models* describing the objects that make up your system. .. class:: incremental From 3e1768efa4fe6ad07917cbaf069a80a0a1c96686 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Tue, 11 Feb 2014 12:28:20 -0800 Subject: [PATCH 010/223] Fixed INSTALLED_APPS --- source/presentations/django_intro.rst | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/source/presentations/django_intro.rst b/source/presentations/django_intro.rst index 8824066a..fdcc01f5 100644 --- a/source/presentations/django_intro.rst +++ b/source/presentations/django_intro.rst @@ -303,16 +303,12 @@ Django already includes some *apps* for you. :class: small INSTALLED_APPS = ( + 'django.contrib.admin', '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', ) @@ -610,10 +606,10 @@ You extend Django functionality by *installing apps*. This is pretty simple: :class: small INSTALLED_APPS = ( + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', - 'django.contrib.sites', 'django.contrib.messages', 'django.contrib.staticfiles', 'myblog', # <- YOU ADD THIS PART From 92b5f718c13bfdb77461ea9572a5754f1ad550f8 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Tue, 11 Feb 2014 12:31:28 -0800 Subject: [PATCH 011/223] The first user is "admin", as per prior recommendation. --- source/presentations/django_intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/django_intro.rst b/source/presentations/django_intro.rst index fdcc01f5..d2379a57 100644 --- a/source/presentations/django_intro.rst +++ b/source/presentations/django_intro.rst @@ -724,7 +724,7 @@ 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 - [] + [] >>> u1 = all_users[0] >>> p1.author = u1 From 51232b8dd3c911e9356289aa75c4b57da1b9a5a6 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Tue, 11 Feb 2014 12:57:02 -0800 Subject: [PATCH 012/223] There are no tests in tests.py yet. --- source/presentations/django_intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/django_intro.rst b/source/presentations/django_intro.rst index d2379a57..f98b7921 100644 --- a/source/presentations/django_intro.rst +++ b/source/presentations/django_intro.rst @@ -1000,7 +1000,7 @@ Now that we have a fixture, we need to instruct our tests to use it. .. container:: incremental - Edit ``tests.py`` (which comes with one test already) to look like this: + Edit ``tests.py`` to look like this: .. code-block:: python :class: small From 12d57c2960de2d9268ed1a8176fdf91d7a2c7c65 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Tue, 11 Feb 2014 13:02:42 -0800 Subject: [PATCH 013/223] Fix Typo --- source/presentations/django_intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/django_intro.rst b/source/presentations/django_intro.rst index f98b7921..ea57c5c8 100644 --- a/source/presentations/django_intro.rst +++ b/source/presentations/django_intro.rst @@ -1249,7 +1249,7 @@ Django too has a system for dispatching requests to code: the *urlconf*. .. class:: incremental -* A urlconf is a an iterable of calls to the ``django.conf.urls.url`` function +* A urlconf is an iterable of calls to the ``django.conf.urls.url`` function * This function takes: * a regexp *rule*, representing the URL From 0121bc4a6e943b21c7a58607d5f296228b9dd3f8 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Sun, 16 Feb 2014 16:38:29 -0800 Subject: [PATCH 014/223] Wrong file name for css --- source/presentations/session07.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/session07.rst b/source/presentations/session07.rst index 3f270de1..4d708d63 100644 --- a/source/presentations/session07.rst +++ b/source/presentations/session07.rst @@ -1435,7 +1435,7 @@ Create a new directory ``static`` in the ``myblog`` app. .. class:: incremental -Copy the ``django_css`` file into that new directory. +Copy the ``django_blog.css`` file into that new directory. .. container:: incremental From 5e235dad6efe5b2b03b5fb00f953fd1aa769bc15 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Sun, 16 Feb 2014 16:53:22 -0800 Subject: [PATCH 015/223] Update session07.rst --- source/presentations/session07.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/session07.rst b/source/presentations/session07.rst index 4d708d63..02d50ee8 100644 --- a/source/presentations/session07.rst +++ b/source/presentations/session07.rst @@ -1690,7 +1690,7 @@ Post admin*. Your Assignment --------------- -You'll be reverse that relationship so that you can only add categories to posts +You'll be reversing that relationship so that you can only add categories to posts Take the following steps: From 8e048665cb5c2681c1893c75b743f55fd060a069 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Thu, 20 Feb 2014 11:31:17 -0800 Subject: [PATCH 016/223] baseline of myblog app as it was at the end of class on Feb 18 --- assignments/session07/mysite/manage.py | 10 ++ .../session07/mysite/myblog/__init__.py | 0 assignments/session07/mysite/myblog/admin.py | 6 ++ .../myblog/fixtures/myblog_test_fixture.json | 38 +++++++ .../mysite/myblog/migrations/0001_initial.py | 78 +++++++++++++++ .../migrations/0002_auto__add_category.py | 93 ++++++++++++++++++ .../mysite/myblog/migrations/__init__.py | 0 assignments/session07/mysite/myblog/models.py | 22 +++++ .../mysite/myblog/static/django_blog.css | 74 ++++++++++++++ .../mysite/myblog/templates/detail.html | 17 ++++ .../mysite/myblog/templates/list.html | 25 +++++ assignments/session07/mysite/myblog/tests.py | 65 ++++++++++++ assignments/session07/mysite/myblog/urls.py | 10 ++ assignments/session07/mysite/myblog/views.py | 29 ++++++ assignments/session07/mysite/mysite.db | Bin 0 -> 155648 bytes .../session07/mysite/mysite/__init__.py | 0 .../session07/mysite/mysite/settings.py | 89 +++++++++++++++++ .../mysite/mysite/templates/base.html | 26 +++++ .../mysite/mysite/templates/login.html | 9 ++ assignments/session07/mysite/mysite/urls.py | 20 ++++ assignments/session07/mysite/mysite/wsgi.py | 14 +++ 21 files changed, 625 insertions(+) create mode 100755 assignments/session07/mysite/manage.py create mode 100644 assignments/session07/mysite/myblog/__init__.py create mode 100644 assignments/session07/mysite/myblog/admin.py create mode 100644 assignments/session07/mysite/myblog/fixtures/myblog_test_fixture.json create mode 100644 assignments/session07/mysite/myblog/migrations/0001_initial.py create mode 100644 assignments/session07/mysite/myblog/migrations/0002_auto__add_category.py create mode 100644 assignments/session07/mysite/myblog/migrations/__init__.py create mode 100644 assignments/session07/mysite/myblog/models.py create mode 100644 assignments/session07/mysite/myblog/static/django_blog.css create mode 100644 assignments/session07/mysite/myblog/templates/detail.html create mode 100644 assignments/session07/mysite/myblog/templates/list.html create mode 100644 assignments/session07/mysite/myblog/tests.py create mode 100644 assignments/session07/mysite/myblog/urls.py create mode 100644 assignments/session07/mysite/myblog/views.py create mode 100644 assignments/session07/mysite/mysite.db create mode 100644 assignments/session07/mysite/mysite/__init__.py create mode 100644 assignments/session07/mysite/mysite/settings.py create mode 100644 assignments/session07/mysite/mysite/templates/base.html create mode 100644 assignments/session07/mysite/mysite/templates/login.html create mode 100644 assignments/session07/mysite/mysite/urls.py create mode 100644 assignments/session07/mysite/mysite/wsgi.py diff --git a/assignments/session07/mysite/manage.py b/assignments/session07/mysite/manage.py new file mode 100755 index 00000000..8a50ec04 --- /dev/null +++ b/assignments/session07/mysite/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/assignments/session07/mysite/myblog/__init__.py b/assignments/session07/mysite/myblog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assignments/session07/mysite/myblog/admin.py b/assignments/session07/mysite/myblog/admin.py new file mode 100644 index 00000000..67aec2d6 --- /dev/null +++ b/assignments/session07/mysite/myblog/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from myblog.models import Post +from myblog.models import Category + +admin.site.register(Post) +admin.site.register(Category) diff --git a/assignments/session07/mysite/myblog/fixtures/myblog_test_fixture.json b/assignments/session07/mysite/myblog/fixtures/myblog_test_fixture.json new file mode 100644 index 00000000..592dea17 --- /dev/null +++ b/assignments/session07/mysite/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/assignments/session07/mysite/myblog/migrations/0001_initial.py b/assignments/session07/mysite/myblog/migrations/0001_initial.py new file mode 100644 index 00000000..4e7a9de9 --- /dev/null +++ b/assignments/session07/mysite/myblog/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# -*- 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 new file mode 100644 index 00000000..1ecf7fcc --- /dev/null +++ b/assignments/session07/mysite/myblog/migrations/0002_auto__add_category.py @@ -0,0 +1,93 @@ +# -*- 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/migrations/__init__.py b/assignments/session07/mysite/myblog/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assignments/session07/mysite/myblog/models.py b/assignments/session07/mysite/myblog/models.py new file mode 100644 index 00000000..29b851c7 --- /dev/null +++ b/assignments/session07/mysite/myblog/models.py @@ -0,0 +1,22 @@ +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/static/django_blog.css b/assignments/session07/mysite/myblog/static/django_blog.css new file mode 100644 index 00000000..64560dc0 --- /dev/null +++ b/assignments/session07/mysite/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; +} \ No newline at end of file diff --git a/assignments/session07/mysite/myblog/templates/detail.html b/assignments/session07/mysite/myblog/templates/detail.html new file mode 100644 index 00000000..cd0322ff --- /dev/null +++ b/assignments/session07/mysite/myblog/templates/detail.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} +Home +

    {{ post }}

    + +
    + {{ post.text }} +
    +
      + {% for category in post.categories.all %} +
    • {{ category }}
    • + {% endfor %} +
    +{% endblock %} diff --git a/assignments/session07/mysite/myblog/templates/list.html b/assignments/session07/mysite/myblog/templates/list.html new file mode 100644 index 00000000..88920817 --- /dev/null +++ b/assignments/session07/mysite/myblog/templates/list.html @@ -0,0 +1,25 @@ +{% 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/assignments/session07/mysite/myblog/tests.py b/assignments/session07/mysite/myblog/tests.py new file mode 100644 index 00000000..a2a27010 --- /dev/null +++ b/assignments/session07/mysite/myblog/tests.py @@ -0,0 +1,65 @@ +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/myblog/urls.py b/assignments/session07/mysite/myblog/urls.py new file mode 100644 index 00000000..ddce82fb --- /dev/null +++ b/assignments/session07/mysite/myblog/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import patterns, url + +urlpatterns = patterns('myblog.views', + url(r'^$', + 'list_view', + name="blog_index"), + url(r'^posts/(?P\d+)/$', + 'detail_view', + name="blog_detail"), +) \ No newline at end of file diff --git a/assignments/session07/mysite/myblog/views.py b/assignments/session07/mysite/myblog/views.py new file mode 100644 index 00000000..54c4bb75 --- /dev/null +++ b/assignments/session07/mysite/myblog/views.py @@ -0,0 +1,29 @@ +from django.shortcuts import render +from django.http import HttpResponse, HttpResponseRedirect, Http404 +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) \ No newline at end of file diff --git a/assignments/session07/mysite/mysite.db b/assignments/session07/mysite/mysite.db new file mode 100644 index 0000000000000000000000000000000000000000..63c9e9a57dbf40bdfacafb2a0d046cc8f04f22df GIT binary patch literal 155648 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 bAOHf7Isv}q=gWVIFaJH(@}GzE_y7MN3OLNV literal 0 HcmV?d00001 diff --git a/assignments/session07/mysite/mysite/__init__.py b/assignments/session07/mysite/mysite/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assignments/session07/mysite/mysite/settings.py b/assignments/session07/mysite/mysite/settings.py new file mode 100644 index 00000000..78a3248e --- /dev/null +++ b/assignments/session07/mysite/mysite/settings.py @@ -0,0 +1,89 @@ +""" +Django settings for mysite project. + +For more information on this file, see +https://docs.djangoproject.com/en/1.6/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.6/ref/settings/ +""" + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'o$)p$v##&^xobe)62v2mvh(+m297vqd-sqnma@c0@g#g&o_!bw' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +TEMPLATE_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', + 'south', + 'myblog', +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +) + +ROOT_URLCONF = 'mysite.urls' + +WSGI_APPLICATION = 'mysite.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.6/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'mysite.db', + } +} + +# Internationalization +# https://docs.djangoproject.com/en/1.6/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.6/howto/static-files/ + +STATIC_URL = '/static/' + +TEMPLATE_DIRS = (os.path.join(BASE_DIR, 'mysite/templates'),) + +LOGIN_URL = '/login/' +LOGIN_REDIRECT_URL = '/' diff --git a/assignments/session07/mysite/mysite/templates/base.html b/assignments/session07/mysite/mysite/templates/base.html new file mode 100644 index 00000000..2a01d991 --- /dev/null +++ b/assignments/session07/mysite/mysite/templates/base.html @@ -0,0 +1,26 @@ + + + + My Django Blog + + + + +
    +
    + {% block content %} + [content will go here] + {% endblock %} +
    +
    + + diff --git a/assignments/session07/mysite/mysite/templates/login.html b/assignments/session07/mysite/mysite/templates/login.html new file mode 100644 index 00000000..1566d0f7 --- /dev/null +++ b/assignments/session07/mysite/mysite/templates/login.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block content %} +

    My Blog Login

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

    +
    +{% endblock %} diff --git a/assignments/session07/mysite/mysite/urls.py b/assignments/session07/mysite/mysite/urls.py new file mode 100644 index 00000000..9c67dfdb --- /dev/null +++ b/assignments/session07/mysite/mysite/urls.py @@ -0,0 +1,20 @@ +from django.conf.urls import patterns, include, url + +from django.contrib import admin +admin.autodiscover() + +urlpatterns = patterns('', + # Examples: + # url(r'^$', 'mysite.views.home', name='home'), + # url(r'^blog/', include('blog.urls')), + url(r'^', include('myblog.urls')), + url(r'^admin/', include(admin.site.urls)), + 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"), +) diff --git a/assignments/session07/mysite/mysite/wsgi.py b/assignments/session07/mysite/mysite/wsgi.py new file mode 100644 index 00000000..10ef32d9 --- /dev/null +++ b/assignments/session07/mysite/mysite/wsgi.py @@ -0,0 +1,14 @@ +""" +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.6/howto/deployment/wsgi/ +""" + +import os +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() From 1c59bb903eada3ac4a4cb87ca759e41525b5ca17 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Thu, 20 Feb 2014 11:34:34 -0800 Subject: [PATCH 017/223] fix test with bad indentation --- assignments/session07/mysite/myblog/tests.py | 76 ++++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/assignments/session07/mysite/myblog/tests.py b/assignments/session07/mysite/myblog/tests.py index a2a27010..413b2131 100644 --- a/assignments/session07/mysite/myblog/tests.py +++ b/assignments/session07/mysite/myblog/tests.py @@ -19,47 +19,47 @@ def test_unicode(self): class CategoryTestCase(TestCase): - def test_unicode(self): - expected = "A Category" - c1 = Category(name=expected) - actual = unicode(c1) - self.assertEqual(expected, actual) + 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', ] + """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 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) + 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) + 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) From 172303e0a05dc65b52f33b33645c04d93d78e0ef Mon Sep 17 00:00:00 2001 From: cewing Date: Mon, 24 Feb 2014 21:18:32 -0800 Subject: [PATCH 018/223] update session 08 --- source/presentations/session08.rst | 120 +++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/source/presentations/session08.rst b/source/presentations/session08.rst index da9e0ad3..ca6cea03 100644 --- a/source/presentations/session08.rst +++ b/source/presentations/session08.rst @@ -16,3 +16,123 @@ Wherein we extend our Django blog app. 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. From 08863ac057e90c858ef1a4378a9e8497c312a6f6 Mon Sep 17 00:00:00 2001 From: cewing Date: Mon, 24 Feb 2014 21:20:18 -0800 Subject: [PATCH 019/223] updated and merged --- source/presentations/session08.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/presentations/session08.rst b/source/presentations/session08.rst index ca6cea03..2fdfc768 100644 --- a/source/presentations/session08.rst +++ b/source/presentations/session08.rst @@ -61,6 +61,7 @@ In order for this to work properly, we'll need to have a few things in place. or $ git clone git@github.com:/django-microblog.git + Connect to Your Partner ----------------------- @@ -136,3 +137,4 @@ Instructions for this `may be found here`_. 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. + From 3950f3f8ec3d3529a50ca936e8372006676af3f5 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Sun, 2 Mar 2014 14:39:46 -0800 Subject: [PATCH 020/223] Small typo --- source/presentations/session09.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/session09.rst b/source/presentations/session09.rst index 0a1f7ae0..6bc081ac 100644 --- a/source/presentations/session09.rst +++ b/source/presentations/session09.rst @@ -924,7 +924,7 @@ Instances of these classes are able to know when they've been changed. .. class:: incremental -When a ZODB transaction is committed, all changes objects are saved. +When a ZODB transaction is committed, all changed objects are saved. Persistent Base Classes From ee760cab433de0e69f459b9264c99f6eec832376 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Sun, 2 Mar 2014 14:40:41 -0800 Subject: [PATCH 021/223] Minor typo --- source/presentations/session09.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/session09.rst b/source/presentations/session09.rst index 6bc081ac..fe1bbf9e 100644 --- a/source/presentations/session09.rst +++ b/source/presentations/session09.rst @@ -1235,7 +1235,7 @@ We are ready to add views now. We'll need: * 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* and existing Page +* A view that allows us to *edit* an existing Page .. class:: incremental From c1e509e0069f27c8e79d9b2c011f7ee1fdf01e5f Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Sun, 2 Mar 2014 14:52:55 -0800 Subject: [PATCH 022/223] Update session09.rst --- source/presentations/session09.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/session09.rst b/source/presentations/session09.rst index fe1bbf9e..754b8dda 100644 --- a/source/presentations/session09.rst +++ b/source/presentations/session09.rst @@ -1545,7 +1545,7 @@ Update ``view_page``: def view_page(context, request): #... - content = wikiwords.sub(check, content) #<- already there + content = WIKIWORDS.sub(check, content) #<- already there edit_url = request.resource_url(context, 'edit_page') #<- add return dict(page=context, content=content, From d501637266380b7b5c4585266b8c5d58f0ebba85 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Sun, 2 Mar 2014 16:39:25 -0800 Subject: [PATCH 023/223] logged_in is not defined by slide 12 Using this base.pt without commenting out the (or otherwise removing the "logged_in") will break the template. --- resources/session10/base.pt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/session10/base.pt b/resources/session10/base.pt index 6d7e665c..db9e81d6 100644 --- a/resources/session10/base.pt +++ b/resources/session10/base.pt @@ -42,9 +42,11 @@
    @@ -59,4 +61,4 @@
    - \ No newline at end of file + From ddb211dabbabcd86d3e01848f7058b98ecdad01e Mon Sep 17 00:00:00 2001 From: cewing Date: Sun, 2 Mar 2014 17:02:02 -0800 Subject: [PATCH 024/223] update cgi_1.py to have a more "correct" shebang --- resources/session04/cgi/cgi-bin/cgi_1.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/session04/cgi/cgi-bin/cgi_1.py b/resources/session04/cgi/cgi-bin/cgi_1.py index b969de64..7dfafcd2 100755 --- a/resources/session04/cgi/cgi-bin/cgi_1.py +++ b/resources/session04/cgi/cgi-bin/cgi_1.py @@ -1,5 +1,5 @@ -#!/usr/bin/python -import cgi +#!/usr/bin/env python +import cgi cgi.test() From ba7cb047b224fd8b4ec3d727c7f03261be8ea5d6 Mon Sep 17 00:00:00 2001 From: Fulvio Casali Date: Mon, 3 Mar 2014 23:35:14 -0800 Subject: [PATCH 025/223] move adding template to session09. revert change to base.pt --- resources/session09/base.pt | 62 +++++++++++++++ resources/session10/base.pt | 2 - source/presentations/session09.rst | 124 +++++++++++++++++++++++++++++ source/presentations/session10.rst | 84 +------------------ 4 files changed, 190 insertions(+), 82 deletions(-) create mode 100644 resources/session09/base.pt diff --git a/resources/session09/base.pt b/resources/session09/base.pt new file mode 100644 index 00000000..c89d5283 --- /dev/null +++ b/resources/session09/base.pt @@ -0,0 +1,62 @@ + + + + Pyramid Wiki + + + + + + + + +
    +
    +
    +
    + pyramid +
    +
    +
    +
    +
    +
    + + Viewing Page Name Goes + Here + +
    + You can return to the + FrontPage. +
    + +
    +
    +
    +
    + +
    +
    +
    + + + diff --git a/resources/session10/base.pt b/resources/session10/base.pt index db9e81d6..c89d5283 100644 --- a/resources/session10/base.pt +++ b/resources/session10/base.pt @@ -42,11 +42,9 @@
    diff --git a/source/presentations/session09.rst b/source/presentations/session09.rst index 754b8dda..b79b0e88 100644 --- a/source/presentations/session09.rst +++ b/source/presentations/session09.rst @@ -1560,6 +1560,130 @@ Update ``view_page``: 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 ---------- diff --git a/source/presentations/session10.rst b/source/presentations/session10.rst index f7af5495..8c3731fc 100644 --- a/source/presentations/session10.rst +++ b/source/presentations/session10.rst @@ -13,28 +13,6 @@ Session 10: A Pyramid Application | Totally not built by aliens. -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. - - Chameleon Templates ------------------- @@ -152,30 +130,13 @@ METAL provides operators related to creating and using template macros: Much of this will become clearer as we actually create our templates. -The view.pt Template --------------------- - -Type this code into your ``view.pt`` file: - -.. code-block:: xml - - - -
    - Page text goes here. -
    -

    - - Edit this page - -

    -
    -
    - - 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 @@ -212,43 +173,6 @@ The ``structure`` expression ensures that the HTML is not escaped. our anchor to the value passed into our template as ``edit_url``. -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 ------------ From 3ba0e0220defec77a4d7223155c8259456ea7989 Mon Sep 17 00:00:00 2001 From: cewing Date: Tue, 27 May 2014 09:34:35 -0700 Subject: [PATCH 026/223] typo --- source/presentations/session07.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/presentations/session07.rst b/source/presentations/session07.rst index 02d50ee8..c5a6c966 100644 --- a/source/presentations/session07.rst +++ b/source/presentations/session07.rst @@ -783,7 +783,7 @@ Full Urlconf Testing Views ------------- -Before we begin writin real views, we need to add some tests for the views we +Before we begin writing real views, we need to add some tests for the views we are about to create. .. class:: incremental From 6128dfaa9bc59cd0521f8bbe3b912327d85a5b9c Mon Sep 17 00:00:00 2001 From: cewing Date: Tue, 27 May 2014 09:35:47 -0700 Subject: [PATCH 027/223] add newline --- resources/session10/wikitutorial/wikitutorial/templates/edit.pt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/session10/wikitutorial/wikitutorial/templates/edit.pt b/resources/session10/wikitutorial/wikitutorial/templates/edit.pt index 9d6ef02a..12af8152 100644 --- a/resources/session10/wikitutorial/wikitutorial/templates/edit.pt +++ b/resources/session10/wikitutorial/wikitutorial/templates/edit.pt @@ -12,4 +12,4 @@ - \ No newline at end of file + From 84dc67f7c5a966572f60889b698911dd21e764e5 Mon Sep 17 00:00:00 2001 From: cewing Date: Sun, 28 Dec 2014 18:22:47 -0800 Subject: [PATCH 028/223] update .gitignore to omit junk --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d04dac0b..86ecdd9d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,10 @@ svn-commit.tmp bin build include -lib \ No newline at end of file +lib +cast-offs +develop-eggs +development +*.db +*.sublime-project +*.sublime-workspace From 5459388e9583a5a59c8c5ef8fa59183cb720ecdd Mon Sep 17 00:00:00 2001 From: cewing Date: Tue, 30 Dec 2014 19:33:21 -0800 Subject: [PATCH 029/223] pep8 fix --- assignments/session02/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assignments/session02/tests.py b/assignments/session02/tests.py index a74fe150..1438857c 100644 --- a/assignments/session02/tests.py +++ b/assignments/session02/tests.py @@ -77,7 +77,7 @@ def test_passed_mimetype_in_response(self): def test_passed_body_in_response(self): bodies = [ - "a body", + "a body", "a longer body\nwith two lines", open("webroot/sample.txt", 'r').read(), ] From 73b49de5f81fd249643d32587105cf52652025d7 Mon Sep 17 00:00:00 2001 From: cewing Date: Tue, 30 Dec 2014 19:37:16 -0800 Subject: [PATCH 030/223] begin shuffling things around --- source/presentations/session01.rst | 1201 ------------------ source/presentations/session04-old.rst | 1435 +++++++++++++++++++++ source/presentations/session04.rst | 1616 ++++++++++-------------- 3 files changed, 2126 insertions(+), 2126 deletions(-) delete mode 100644 source/presentations/session01.rst create mode 100644 source/presentations/session04-old.rst diff --git a/source/presentations/session01.rst b/source/presentations/session01.rst deleted file mode 100644 index 69f9106b..00000000 --- a/source/presentations/session01.rst +++ /dev/null @@ -1,1201 +0,0 @@ -Python Web Programming -====================== - -.. image:: img/python.png - :align: left - :width: 33% - -Session 1: Networking and Sockets - -.. class:: intro-blurb - -Wherein we learn about the basic structure of the internet and explore the -building blocks that make it possible. - - -But First ---------- - -Class presentations are available online for your use - -.. class:: small - -https://github.com/UWPCE-PythonCert/training.python_web - -.. class:: incremental - -Licensed with Creative Commons BY-NC-SA - -.. class:: small incremental - -* You must attribute the work -* You may not use the work for commercial purposes -* You have to share your versions just like this one - -.. class:: incremental - -Find mistakes? See improvements? Make a pull request. - - -But First ---------- - -Classroom Protocol - -.. class:: incremental - -Questions to ask: - -.. class:: incremental - -* What did you just say? -* Please explain what we just did again? -* Why didn't that work for me? -* Is that a typo? - - -But First ---------- - -Classroom Protocol - -.. class:: incremental - -Questions **not** to ask: - -.. class:: incremental - -* **Hypotheticals**: What happens if I do X? -* **Research**: Can Python do Y? -* **Syllabus**: Are we going to cover Z in class? -* **Marketing questions**: please just don't. -* **Performance questions**: Is Python fast enough? -* **Unpythonic**: Why doesn't Python do it some other way? -* **Show off**: Look what I just did! - - -But First ---------- - -.. class:: big-centered - -Introductions - - -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/session04-old.rst b/source/presentations/session04-old.rst new file mode 100644 index 00000000..f8214583 --- /dev/null +++ b/source/presentations/session04-old.rst @@ -0,0 +1,1435 @@ +Python Web Programming +====================== + +.. image:: img/gateway.jpg + :align: left + :width: 50% + +Session 4: CGI, WSGI and Living Online + +.. class:: intro-blurb + +Wherein we discover the gateways to dynamic processes on a server. + +.. class:: image-credit + +image: The Wandering Angel http://www.flickr.com/photos/wandering_angel/1467802750/ - CC-BY + +But First +--------- + +.. class:: big-centered + +A look at some of the cool mashups you built over the week. + + +But First +--------- + +Clean up the git situation. + + +But First +--------- + +Before you leave the classroom today, please complete the following tasks: + +1. Create a virtualenv called ``flaskenv`` +2. Activate that virtualenv +3. ``pip install flask`` to your virtualenv + +You will need this for some of your homework this week. + +But First +--------- + +A special note to pay attention to the readings. You will be expected to have +read the basics on Jinja2, SQLite3 and Flask **before** class starts. + +Previously +---------- + +.. class:: incremental + +* You've learned about passing messages back and forth with sockets +* You've created a simple HTTP server using sockets +* You may even have made your server *dynamic* by returning the output of a + python script. + +.. class:: incremental + +What if you want to pass information to that script? + +.. class:: incremental + +How can you give the script access to information about the HTTP request +itself? + + +Stepping Away +------------- + +A computer has an *environment*: + +.. container:: incremental + + in \*nix, you can see this in a shell: + + .. class:: small + + :: + + $ printenv + TERM_PROGRAM=iTerm.app + ... + +.. container:: incremental + + or in Windows at the command prompt: + + .. class:: small + + :: + + C:\> set + ALLUSERSPROFILE=C:\ProgramData + ... + + +Setting The Environment +----------------------- + +This can be manipulated: + +.. container:: incremental + + In a ``bash`` shell we can do this: + + .. class:: small + + :: + + $ export VARIABLE='some value' + $ echo $VARIABLE + some value + +.. container:: incremental + + or at a Windows command prompt: + + .. class:: small + + :: + + C:\Users\Administrator\> set VARIABLE='some value' + C:\Users\Administrator\> echo %VARIABLE% + 'some value' + + +Viewing the Results +------------------- + +These new values are now part of the *environment* + +.. container:: incremental + + \*nix: + + .. class:: small + + :: + + $ printenv + TERM_PROGRAM=iTerm.app + ... + VARIABLE=some value + +.. container:: incremental + + Windows: + + .. class:: small + + :: + + C:\> set + ALLUSERSPROFILE=C:\ProgramData + ... + VARIABLE='some value' + +Environment in Python +--------------------- + +We can see this *environment* in Python, too:: + + $ python + +.. code-block:: python + + >>> import os + >>> print os.environ['VARIABLE'] + some_value + >>> print os.environ.keys() + ['VERSIONER_PYTHON_PREFER_32_BIT', 'VARIABLE', + 'LOGNAME', 'USER', 'PATH', ...] + +Altering the Environment +------------------------ + +You can alter os environment values while in Python: + +.. code-block:: python + :class: small + + >>> os.environ['VARIABLE'] = 'new_value' + >>> print os.environ['VARIABLE'] + new_value + +.. container:: incremental + + But that doesn't change the original value, *outside* Python: + + .. class:: small + + :: + + >>> ^D + + $ echo this is the value: $VARIABLE + this is the value: some_value + + C:\> \Users\Administrator\> echo %VARIABLE% + 'some value' + +Lessons Learned +--------------- + +.. class:: incremental + +* Subprocesses inherit their environment from their Parent +* Parents do not see changes to environment in subprocesses +* In Python, you can actually set the environment for a subprocess explicitly + +.. class:: incremental small + +:: + + subprocess.Popen(args, bufsize=0, executable=None, + stdin=None, stdout=None, stderr=None, + preexec_fn=None, close_fds=False, + shell=False, cwd=None, env=None, # <------- + universal_newlines=False, startupinfo=None, + creationflags=0) + + +Web Environment +--------------- + +.. class:: big-centered + +CGI is little more than a set of standard environmental variables + + +RFC 3875 +-------- + +First discussed in 1993, formalized in 1997, the current version (1.1) has +been in place since 2004. + +From the preamble: + +.. class:: center + +*This memo provides information for the Internet community. It does not specify +an Internet standard of any kind.* + +.. class:: image-credit + +RFC 3875 - CGI Version 1.1: http://tools.ietf.org/html/rfc3875 + + +Meta-Variables +-------------- + +.. class:: small + +:: + + 4. The CGI Request . . . . . . . . . . . . . . . . . . . . . . . 10 + 4.1. Request Meta-Variables . . . . . . . . . . . . . . . . . 10 + 4.1.1. AUTH_TYPE. . . . . . . . . . . . . . . . . . . . 11 + 4.1.2. CONTENT_LENGTH . . . . . . . . . . . . . . . . . 12 + 4.1.3. CONTENT_TYPE . . . . . . . . . . . . . . . . . . 12 + 4.1.4. GATEWAY_INTERFACE. . . . . . . . . . . . . . . . 13 + 4.1.5. PATH_INFO. . . . . . . . . . . . . . . . . . . . 13 + 4.1.6. PATH_TRANSLATED. . . . . . . . . . . . . . . . . 14 + 4.1.7. QUERY_STRING . . . . . . . . . . . . . . . . . . 15 + 4.1.8. REMOTE_ADDR. . . . . . . . . . . . . . . . . . . 15 + 4.1.9. REMOTE_HOST. . . . . . . . . . . . . . . . . . . 16 + 4.1.10. REMOTE_IDENT . . . . . . . . . . . . . . . . . . 16 + 4.1.11. REMOTE_USER. . . . . . . . . . . . . . . . . . . 16 + 4.1.12. REQUEST_METHOD . . . . . . . . . . . . . . . . . 17 + 4.1.13. SCRIPT_NAME. . . . . . . . . . . . . . . . . . . 17 + 4.1.14. SERVER_NAME. . . . . . . . . . . . . . . . . . . 17 + 4.1.15. SERVER_PORT. . . . . . . . . . . . . . . . . . . 18 + 4.1.16. SERVER_PROTOCOL. . . . . . . . . . . . . . . . . 18 + 4.1.17. SERVER_SOFTWARE. . . . . . . . . . . . . . . . . 19 + + +Running CGI +----------- + +You have a couple of options: + +.. class:: incremental + +* Python Standard Library CGIHTTPServer +* Apache +* IIS (on Windows) +* Some other HTTP server that implements CGI (lighttpd, ...?) + +.. class:: incremental + +Let's keep it simple by using the Python module + + +Preparations +------------ + +In the class resources, you'll find a directory named ``cgi``. Make a copy of +that folder in your class working directory. + +.. class:: incremental small red + +Windows Users, you will have to edit the first line of +``cgi/cgi-bin/cgi_1.py`` to point to your python executable. + +.. class:: incremental + +* Open *two* terminal windows in this ``cgi`` directory +* In the first terminal, run ``python -m CGIHTTPServer`` +* Open a web browser and load ``http://localhost:8000/`` +* Click on *CGI Test 1* + + +Did that work? +-------------- + +* If nothing at all happens, check your terminal window +* Look for this: ``OSError: [Errno 13] Permission denied`` +* If you see something like that, check permissions for ``cgi-bin`` *and* + ``cgi_1.py`` +* The file must be executable, the directory needs to be readable *and* + executable. + + +.. class:: incremental + +Remember that you can use the bash ``chmod`` command to change permissions in +\*nix + +.. class:: incremental + +Windows users, use the 'properties' context menu to get to permissions, just +grant 'full' + +Break It +-------- + +Problems with permissions can lead to failure. So can scripting errors + +.. class:: incremental + +* Open ``cgi/cgi-bin/cgi_1.py`` in an editor +* Before where it says ``cgi.test()``, add a single line: + +.. code-block:: python + :class: incremental + + 1 / 0 + +.. class:: incremental + +Reload your browser, what happens now? + + +Errors in CGI +------------- + +CGI is famously difficult to debug. There are reasons for this: + +.. class:: incremental + +* CGI is designed to provide access to runnable processes to *the internet* +* The internet is a wretched hive of scum and villainy +* Revealing error conditions can expose data that could be exploited + +Viewing Errors in Python CGI +---------------------------- + +Back in your editor, add the following lines, just below ``import cgi``: + +.. code-block:: python + :class: incremental + + import cgitb + cgitb.enable() + +.. class:: incremental + +Now, reload again. + +cgitb Output +------------ + +.. image:: img/cgitb_output.png + :align: center + :width: 100% + + +Repair the Error +---------------- + +Let's fix the error from our traceback. Edit your ``cgi_1.py`` file to match: + +.. code-block:: python + :class: small + + #!/usr/bin/python + import cgi + import cgitb + + cgitb.enable() + + cgi.test() + +.. class:: incremental + +Notice the first line of that script: ``#!/usr/bin/python``. This is called a +*shebang* (short for hash-bang) and it tells the system what executable +program to use when running the script. + + +CGI Process Execution +--------------------- + +When a web server like ``CGIHTTPServer`` or ``Apache`` runs a CGI script, it +simply attempts to run the script as if it were a normal system user. This is +just like you calling:: + + $ ./cgi_bin/cgi_1.py + +.. class:: incremental + +In fact try that now in your second terminal (use the real path), what do you +get? + +.. class:: incremental small center + +Windows folks, you may need ``C:\>python cgi_1.py`` + +.. class:: incremental + +What is missing? + + +CGI Process Execution +--------------------- + +There are a couple of important facts that are related to the way CGI +processes are run: + +.. class:: incremental + +* The script **must** include a *shebang* so that the system knows how to run + it. +* The script **must** be executable. +* The *executable* named in the *shebang* will be called as the *nobody* user. +* This is a security feature to prevent CGI scripts from running as a user + with any privileges. +* This means that the *executable* from the script *shebang* must be one that + *anyone* can run. + + +The CGI Environment +------------------- + +CGI is largely a set of agreed-upon environmental variables. + +.. class:: incremental + +We've seen how environmental variables are found in python in ``os.environ`` + +.. class:: incremental + +We've also seen that at least some of the variables in CGI are **not** in the +standard set of environment variables. + +.. class:: incremental + +Where do they come from? + + +CGI Servers +----------- + +Let's find 'em. In a terminal (on your local machine, please) fire up python: + +.. code-block:: + + >>> import CGIHTTPServer + >>> CGIHTTPServer.__file__ + '/big/giant/path/to/lib/python2.6/CGIHTTPServer.py' + +.. class:: incremental + +Copy this path and open the file it points to in your text editor + + +Environmental Set Up +-------------------- + +From CGIHTTPServer.py, in the CGIHTTPServer.run_cgi method: + +.. code-block:: python + :class: tiny + + # Reference: http://hoohoo.ncsa.uiuc.edu/cgi/env.html + # XXX Much of the following could be prepared ahead of time! + env = {} + env['SERVER_SOFTWARE'] = self.version_string() + env['SERVER_NAME'] = self.server.server_name + env['GATEWAY_INTERFACE'] = 'CGI/1.1' + env['SERVER_PROTOCOL'] = self.protocol_version + env['SERVER_PORT'] = str(self.server.server_port) + env['REQUEST_METHOD'] = self.command + ... + ua = self.headers.getheader('user-agent') + if ua: + env['HTTP_USER_AGENT'] = ua + ... + os.environ.update(env) + ... + + +CGI Scripts +----------- + +And that's it, the big secret. The server takes care of setting up the +environment so it has what is needed. + +.. class:: incremental + +Now, in reverse. How does the information that a script creates end up in your +browser? + +.. class:: incremental + +A CGI Script must print its results to stdout. + +.. class:: incremental + +Use the same method as above to import and open the source file for the +``cgi`` module. Note what ``test`` does for an example of this. + + +Recap: +------ + +What the Server Does: + +.. class:: incremental small + +* parses the request +* sets up the environment, including HTTP and SERVER variables +* figures out if the URI points to a CGI script and runs it +* builds an appropriate HTTP Response first line ('HTTP/1.1 200 OK\\r\\n') +* appends what comes from the script on stdout and sends that back + +What the Script Does: + +.. class:: incremental small + +* names appropriate *executable* in it's *shebang* line +* uses os.environ to read information from the HTTP request +* builds *any and all* appropriate **HTTP Headers** (Content-type:, + Content-length:, ...) +* prints headers, empty line and script output (body) to stdout + + +In-Class Exercise +----------------- + +You've seen the output from the ``cgi.test()`` method from the ``cgi`` module. +Let's make our own version of this. + +.. class:: incremental small + +* In the directory ``cgi-bin`` you will find the file ``cgi_2.py``. +* Open that file in your editor. +* The script contains some html with text naming elements of the CGI + environment. +* You should use the values in os.environ to fill in the blanks. +* You should be able to view the results of your work by loading + ``http://localhost:8000/`` and clicking on *Exercise One* + +.. class:: incremental center + +**GO** + + +User Provided Data +------------------ + +All this is well and good, but where's the *dynamic* stuff? + +.. class:: incremental + +It'd be nice if a user could pass form data to our script for it to use. + +.. container:: incremental + + In HTTP, these types of inputs show up in the URL *query* (the part after + the ``?``):: + + http://myhost.com/script.py?a=23&b=37 + + +Form Data in CGI +---------------- + +In the ``cgi`` module, we get access to this with the ``FieldStorage`` class: + +.. code-block:: python + :class: incremental small + + import cgi + + form = cgi.FieldStorage() + stringval = form.getvalue('a', None) + listval = form.getlist('b') + +.. class:: incremental + +* The values in the ``FieldStorage`` are *always* strings +* ``getvalue`` allows you to return a default, in case the field isn't present +* ``getlist`` always returns a list: empty, one-valued, or as many values as + are present + + +In-Class Exercise +----------------- + +Let's create a dynamic adding machine. + +.. class:: incremental + +* In the ``cgi-bin`` directory you'll find ``cgi_sums.py``. +* In the ``index.html`` file in the ``cgi`` directory, the third link leads to + this file. +* You will use the structure of that link, and what you learned just now about + ``cgi.FieldStorage``. +* Complete the cgi script in ``cgi_sums.py`` so that the result of adding all + operands sent via the url query is returned. + +.. class:: incremental + +For extra fun, return the results in ``json`` format (mimetype: +'application/json'). + + +My Solution +----------- + +.. code-block:: python + :class: small incremental + + form = cgi.FieldStorage() + operands = form.getlist('operand') + total = 0 + for operand in operands: + try: + value = int(operand) + except ValueError: + value = 0 + total += value + + output = {'result': total} + json_output = json.dumps(output) + + print "Content-Type: application/json" + print "Content-Length: %s" % len(json_output) + print + print json_output + + +Stopping Point +-------------- + +.. class:: big-centered + +Let's take a break here, before continuing + + +CGI Problems +------------ + +CGI is great, but there are problems: + +.. class:: incremental + +* Code is executed *in a new process* +* **Every** call to a CGI script starts a new process on the server +* Starting a new process is expensive in terms of server resources +* *Especially for interpreted languages like Python* + +.. class:: incremental + +How do we overcome this problem? + + +Alternatives to CGI +------------------- + +The most popular approach is to have a long-running process *inside* the +server that handles CGI scripts. + +.. class:: incremental + +FastCGI and SCGI are existing implementations of CGI in this fashion. The +Apache module **mod_python** offers a similar capability for Python code. + +.. class:: incremental + +* Each of these options has a specific API +* None are compatible with each-other +* Code written for one is **not portable** to another + +.. class:: incremental + +This makes it much more difficult to *share resources* + + +WSGI +---- + +Enter WSGI, the Web Server Gateway Interface. + +.. class:: incremental + +Where other alternatives are specific implementations of the CGI standard, +WSGI is itself a new standard, not an implementation. + +.. class:: incremental + +WSGI is generalized to describe a set of interactions, so that developers can +write WSGI-capable apps and deploy them on any WSGI server. + +.. class:: incremental + +Read the WSGI spec: http://www.python.org/dev/peps/pep-0333 + + +WSGI: Apps and Servers +---------------------- + +.. class:: small + +WSGI consists of two parts, a *server* and an *application*. + +.. class:: small + +A WSGI Server must: + +.. class:: incremental small + +* set up an environment, much like the one in CGI +* provide a method ``start_response(status, headers, exc_info=None)`` +* build a response body by calling an *application*, passing + ``environment`` and ``start_response`` as args +* return a response with the status, headers and body + +.. class:: small + +A WSGI Appliction must: + +.. class:: incremental small + +* Be a callable (function, method, class) +* Take an environment and a ``start_response`` callable as arguments +* Call the ``start_response`` method. +* Return an iterable of 0 or more strings, which are treated as the body of + the response. + + +Simplified WSGI Server +---------------------- + +.. code-block:: python + :class: small + + from some_application import simple_app + + def build_env(request): + # put together some environment info from the reqeuest + return env + + def handle_request(request, app): + environ = build_env(request) + iterable = app(environ, start_response) + for data in iterable: + # send data to client here + + def start_response(status, headers): + # start an HTTP response, sending status and headers + + # listen for HTTP requests and pass on to handle_request() + serve(simple_app) + + +Simple WSGI Application +----------------------- + +Where the simplified server above is **not** functional, this *is* a complete +app: + +.. code-block:: python + + def application(environ, start_response) + status = "200 OK" + body = "Hello World\n" + response_headers = [('Content-type', 'text/plain'), + ('Content-length', len(body))] + start_response(status, response_headers) + return [body] + + +WSGI Middleware +--------------- + +A third part of the puzzle is something called WSGI *middleware* + +.. class:: incremental + +* Middleware implements both the *server* and *application* interfaces +* Middleware acts as a server when viewed from an application +* Middleware acts as an application when viewed from a server + +.. image:: img/wsgi_middleware_onion.png + :align: center + :width: 38% + :class: incremental + + +Flowcharts +---------- + +WSGI Servers: + +.. class:: center incremental + +**HTTP <---> WSGI** + +.. class:: incremental + +WSGI Applications: + +.. class:: center incremental + +**WSGI <---> app code** + + +The Whole Enchilada +------------------- + +The WSGI *Stack* can thus be expressed like so: + +.. class:: incremental big-centered + +**HTTP <---> WSGI <---> app code** + + +Using wsgiref +------------- + +The Python standard lib provides a reference implementation of WSGI: + +.. image:: img/wsgiref_flow.png + :align: center + :width: 80% + :class: incremental + + +Apache mod_wsgi +--------------- + +You can also deploy with Apache as your HTTP server, using **mod_wsgi**: + +.. image:: img/mod_wsgi_flow.png + :align: center + :width: 80% + :class: incremental + + +Proxied WSGI Servers +-------------------- + +Finally, it is also common to see WSGI apps deployed via a proxied WSGI +server: + +.. image:: img/proxy_wsgi.png + :align: center + :width: 80% + :class: incremental + + +The WSGI Environment +-------------------- + +.. class:: small + +REQUEST_METHOD + The HTTP request method, such as "GET" or "POST". This cannot ever be an + empty string, and so is always required. +SCRIPT_NAME + The initial portion of the request URL's "path" that corresponds to the + application object, so that the application knows its virtual "location". + This may be an empty string, if the application corresponds to the "root" of + the server. +PATH_INFO + The remainder of the request URL's "path", designating the virtual + "location" of the request's target within the application. This may be an + empty string, if the request URL targets the application root and does not + have a trailing slash. +QUERY_STRING + The portion of the request URL that follows the "?", if any. May be empty or + absent. +CONTENT_TYPE + The contents of any Content-Type fields in the HTTP request. May be empty or + absent. + + +The WSGI Environment +-------------------- + +.. class:: small + +CONTENT_LENGTH + The contents of any Content-Length fields in the HTTP request. May be empty + or absent. +SERVER_NAME, SERVER_PORT + When combined with SCRIPT_NAME and PATH_INFO, these variables can be used to + complete the URL. Note, however, that HTTP_HOST, if present, should be used + in preference to SERVER_NAME for reconstructing the request URL. See the URL + Reconstruction section below for more detail. SERVER_NAME and SERVER_PORT + can never be empty strings, and so are always required. +SERVER_PROTOCOL + The version of the protocol the client used to send the request. Typically + this will be something like "HTTP/1.0" or "HTTP/1.1" and may be used by the + application to determine how to treat any HTTP request headers. (This + variable should probably be called REQUEST_PROTOCOL, since it denotes the + protocol used in the request, and is not necessarily the protocol that will + be used in the server's response. However, for compatibility with CGI we + have to keep the existing name.) + + +The WSGI Environment +-------------------- + +.. class:: small + +HTTP\_ Variables + Variables corresponding to the client-supplied HTTP request headers (i.e., + variables whose names begin with "HTTP\_"). The presence or absence of these + variables should correspond with the presence or absence of the appropriate + HTTP header in the request. + +.. class:: center incremental + +**Seem Familiar?** + + +A Bit of Repetition +------------------- + +Let's start simply. We'll begin by repeating our first CGI exercise in WSGI + +.. class:: incremental + +* Find the ``wsgi`` directory in the class resources. Copy it to your working + directory. +* Open the file ``wsgi_1.py`` in your text editor. +* We will fill in the missing values using the wsgi ``environ``, just as we + use ``os.environ`` in cgi + +.. class:: incremental center + +**But First** + + +Orientation +----------- + +.. code-block:: python + :class: small + + if __name__ == '__main__': + from wsgiref.simple_server import make_server + srv = make_server('localhost', 8080, application) + srv.serve_forever() + +.. class:: incremental + +Note that we pass our ``application`` function to the server factory + +.. class:: incremental + +We don't have to write a server, ``wsgiref`` does that for us. + +.. class:: incremental + +In fact, you should *never* have to write a WSGI server. + + +Orientation +----------- + +.. code-block:: python + :class: small + + def application(environ, start_response): + response_body = body % ( + environ.get('SERVER_NAME', 'Unset'), # server name + ... + ) + status = '200 OK' + response_headers = [('Content-Type', 'text/html'), + ('Content-Length', str(len(response_body)))] + start_response(status, response_headers) + return [response_body] + +.. class:: incremental + +We do not define ``start_response``, the application does that. + +.. class:: incremental + +We *are* responsible for determining the HTTP status. + + +Running a WSGI Script +--------------------- + +You can run this script with python:: + + $ python wsgi_1.py + +.. class:: incremental + +This will start a wsgi server. What host and port will it use? + +.. class:: incremental + +Point your browser at ``http://localhost:8080/``. Did it work? + +.. class:: incremental + +Go ahead and fill in the missing bits. Use the ``environ`` passed into +``application`` + + +Some Tips +--------- + +Because WSGI is a long-running process, the file you are editing is *not* +reloaded after you edit it. + +.. class:: incremental + +You'll need to quit and re-run the script between edits. + +.. class:: incremental + +You may also want to consider using ``print environ`` in your application so +you can see the dictionary. + +.. class:: incremental + +If you do that, where will the printed environment appear? + + +A More Complex Example +---------------------- + +Let's create a multi-page wsgi application. It will serve a small database of +python books. + +.. class:: incremental + +The database (with a very simple api) can be found in ``wsgi/bookdb.py`` + +.. class:: incremental + +* We'll need a listing page that shows the titles of all the books +* Each title will link to a details page for that book +* The details page for each book will display all the information and have a + link back to the list + + +Some Questions to Ponder +------------------------ + +.. class:: incremental + +When viewing our first wsgi app, do we see the name of the wsgi application +script anywhere in the URL? + +.. class:: incremental + +In our wsgi application script, how many applications did we actually have? + +.. class:: incremental + +How are we going to serve different types of information out of a single +application? + + +Dispatch +-------- + +We have to write an app that will map our incoming request path to some code +that can handle that request. + +.. class:: incremental + +This process is called ``dispatch``. There are many possible approaches + +.. class:: incremental + +Let's begin by designing this piece of it. + +.. class:: incremental + +Open ``bookapp.py`` from the ``wsgi`` folder. We'll do our work here. + + +PATH +---- + +The wsgi environment gives us access to *PATH_INFO*, which maps to the URI the +user requested when they loaded the page. + +.. class:: incremental + +We can design the URLs that our app will use to assist us in routing. + +.. class:: incremental + +Let's declare that any request for ``/`` will map to the list page + +.. container:: incremental + + We can also say that the URL for a book will look like this:: + + http://localhost:8080/book/ + +Writing resolve_path +-------------------- + +Let's write a function, called ``resolve_path`` in our application file. + +.. class:: incremental + +* It should take the *PATH_INFO* value from environ as an argument. +* It should return the function that will be called. +* It should also return any arguments needed to call that function. +* This implies of course that the arguments should be part of the PATH + + +My Solution +----------- + +.. code-block:: python + :class: small incremental + + def resolve_path(path): + urls = [(r'^$', books), + (r'^book/(id[\d]+)$', book)] + matchpath = path.lstrip('/') + for regexp, func in urls: + match = re.match(regexp, matchpath) + if match is None: + continue + args = match.groups([]) + return func, args + # we get here if no url matches + raise NameError + + +Application Updates +------------------- + +We need to hook our new router into the application. + +.. class:: incremental + +* The path should be extracted from ``environ``. +* The router should be used to get a function and arguments +* The body to return should come from calling that function with those + arguments +* If an error is raised by calling the function, an appropriate response + should be returned +* If the router raises a NameError, the application should return a 404 + response + + +My Solution +----------- + +.. code-block:: python + :class: small incremental + + def application(environ, start_response): + headers = [("Content-type", "text/html")] + try: + path = environ.get('PATH_INFO', None) + if path is None: + raise NameError + func, args = resolve_path(path) + body = func(*args) + status = "200 OK" + except NameError: + status = "404 Not Found" + body = "

    Not Found

    " + except Exception: + status = "500 Internal Server Error" + body = "

    Internal Server Error

    " + finally: + headers.append(('Content-length', str(len(body)))) + start_response(status, headers) + return [body] + + +Test Your Work +-------------- + +Once you've got your script settled, run it:: + + $ python bookapp.py + +.. class:: incremental + +Then point your browser at ``http://localhost:8080/`` + +.. class:: incremental + +* ``http://localhost/book/id3`` +* ``http://localhost/book/id73/`` +* ``http://localhost/sponge/damp`` + +.. class:: incremental + +Did that all work as you would have expected? + + +Building the List +----------------- + +The function ``books`` should return an html list of book titles where each +title is a link to the detail page for that book + +.. class:: incremental + +* You'll need all the ids and titles from the book database. +* You'll need to build a list in HTML using this information +* Each list item should have the book title as a link +* The href for the link should be of the form ``/book/`` + + +My Solution +----------- + +.. code-block:: python + :class: incremental small + + def books(): + all_books = DB.titles() + body = ['

    My Bookshelf

    ', '
      '] + item_template = '
    • {title}
    • ' + for book in all_books: + body.append(item_template.format(**book)) + body.append('
    ') + return '\n'.join(body) + + +Test Your Work +-------------- + +Quit and then restart your application script:: + + $ python bookapp.py + +.. container:: incremental + + Then reload the root of your application:: + + http://localhost:8080/ + +.. class:: incremental + +You should see a nice list of the books in the database. Do you? + +.. class:: incremental + +Click on a link to view the detail page. Does it load without error? + + +Showing Details +--------------- + +The next step of course is to polish up those detail pages. + +.. class:: incremental + +* You'll need to retrieve a single book from the database +* You'll need to format the details about that book and return them as HTML +* You'll need to guard against ids that do not map to books + +.. class:: incremental + +In this last case, what's the right HTTP response code to send? + + +My Solution +----------- + +.. code-block:: python + :class: incremental small + + def book(book_id): + page = """ +

    {title}

    + + + + +
    Author{author}
    Publisher{publisher}
    ISBN{isbn}
    + Back to the list + """ + book = DB.title_info(book_id) + if book is None: + raise NameError + return page.format(**book) + + +Revel in Your Success +--------------------- + +Quit and restart your script one more time + +.. class:: incremental + +Then poke around at your application and see the good you've made + +.. class:: incremental + +And your application is portable and sharable + +.. class:: incremental + +It should run equally well under any `wsgi server +`_ + + +A Few Steps Further +------------------- + +Next steps for an app like this might be: + +* Create a shared full page template and incorporate it into your app +* Improve the error handling by emitting error codes other than 404 and 500 +* Swap out the basic backend here with a different one, maybe a Web Service? +* Think about ways to make the application less tightly coupled to the pages + it serves + + +Homework +-------- + +For your homework this week, you'll be creating a wsgi application of your +own. + +.. class:: incremental + +As the source of your data, use the mashup you created last week. + +.. class:: incremental + +Your application should have at least two separate "pages" in it. + +.. class:: incremental + +The HTML you produce does not need to be pretty, but it should be something +that shows up in a browser. + + +Submitting Your Homework +------------------------ + +To submit your homework: + +.. class:: small + +* Create a new python script in ``assignments/session04``. It should be + something I can run with: + +.. class:: small + +:: + + $ python your_script.py + +.. class:: small + +* Once your script is running, I should be able to view your application in my + browser. + +* Include all instructions I need to successfully run and view your script. + +* Add tests for your code. I should be able to run the tests like so: + +.. class:: small + +:: + + $ python tests.py + +.. class:: small + +* Commit your changes to your fork of the repo in github, then open a pull + request. + + +But Wait, There's More +---------------------- + +In addition, read and step through the quick tutorials on templates and +database persistence in the assignments directory. + +Use your flaskenv Python, it has everything you need installed. + + +Wrap-Up +------- + +For educational purposes, you might wish to take a look at the source code for +the ``wsgiref`` module. It's the canonical example of a simple wsgi server + + >>> import wsgiref + >>> wsgiref.__file__ + '/full/path/to/your/copy/of/wsgiref.py' + ... + +.. class:: incremental center + +**See you Next Time** diff --git a/source/presentations/session04.rst b/source/presentations/session04.rst index f8214583..69f9106b 100644 --- a/source/presentations/session04.rst +++ b/source/presentations/session04.rst @@ -1,1435 +1,1201 @@ Python Web Programming ====================== -.. image:: img/gateway.jpg +.. image:: img/python.png :align: left - :width: 50% + :width: 33% -Session 4: CGI, WSGI and Living Online +Session 1: Networking and Sockets .. class:: intro-blurb -Wherein we discover the gateways to dynamic processes on a server. +Wherein we learn about the basic structure of the internet and explore the +building blocks that make it possible. -.. class:: image-credit - -image: The Wandering Angel http://www.flickr.com/photos/wandering_angel/1467802750/ - CC-BY But First --------- -.. class:: big-centered +Class presentations are available online for your use -A look at some of the cool mashups you built over the week. +.. class:: small +https://github.com/UWPCE-PythonCert/training.python_web -But First ---------- +.. class:: incremental -Clean up the git situation. +Licensed with Creative Commons BY-NC-SA +.. class:: small incremental -But First ---------- +* You must attribute the work +* You may not use the work for commercial purposes +* You have to share your versions just like this one -Before you leave the classroom today, please complete the following tasks: +.. class:: incremental -1. Create a virtualenv called ``flaskenv`` -2. Activate that virtualenv -3. ``pip install flask`` to your virtualenv +Find mistakes? See improvements? Make a pull request. -You will need this for some of your homework this week. But First --------- -A special note to pay attention to the readings. You will be expected to have -read the basics on Jinja2, SQLite3 and Flask **before** class starts. - -Previously ----------- +Classroom Protocol .. class:: incremental -* You've learned about passing messages back and forth with sockets -* You've created a simple HTTP server using sockets -* You may even have made your server *dynamic* by returning the output of a - python script. +Questions to ask: .. class:: incremental -What if you want to pass information to that script? +* What did you just say? +* Please explain what we just did again? +* Why didn't that work for me? +* Is that a typo? -.. class:: incremental -How can you give the script access to information about the HTTP request -itself? - - -Stepping Away -------------- - -A computer has an *environment*: +But First +--------- -.. container:: incremental +Classroom Protocol - in \*nix, you can see this in a shell: - - .. class:: small - - :: - - $ printenv - TERM_PROGRAM=iTerm.app - ... +.. class:: incremental -.. container:: incremental +Questions **not** to ask: - or in Windows at the command prompt: - - .. class:: small - - :: - - C:\> set - ALLUSERSPROFILE=C:\ProgramData - ... +.. class:: incremental +* **Hypotheticals**: What happens if I do X? +* **Research**: Can Python do Y? +* **Syllabus**: Are we going to cover Z in class? +* **Marketing questions**: please just don't. +* **Performance questions**: Is Python fast enough? +* **Unpythonic**: Why doesn't Python do it some other way? +* **Show off**: Look what I just did! -Setting The Environment ------------------------ -This can be manipulated: +But First +--------- -.. container:: incremental +.. class:: big-centered - In a ``bash`` shell we can do this: - - .. class:: small - - :: - - $ export VARIABLE='some value' - $ echo $VARIABLE - some value +Introductions -.. container:: incremental - or at a Windows command prompt: - - .. class:: small - - :: - - C:\Users\Administrator\> set VARIABLE='some value' - C:\Users\Administrator\> echo %VARIABLE% - 'some value' +Computer Communications +----------------------- +.. image:: img/network_topology.png + :align: left + :width: 40% -Viewing the Results -------------------- +.. class:: incremental -These new values are now part of the *environment* +* processes can communicate -.. container:: incremental +* inside one machine - \*nix: - - .. class:: small - - :: - - $ printenv - TERM_PROGRAM=iTerm.app - ... - VARIABLE=some value +* between two machines -.. container:: incremental +* among many machines - Windows: - - .. class:: small - - :: - - C:\> set - ALLUSERSPROFILE=C:\ProgramData - ... - VARIABLE='some value' +.. class:: image-credit -Environment in Python ---------------------- +image: http://en.wikipedia.org/wiki/Internet_Protocol_Suite -We can see this *environment* in Python, too:: - $ python +Computer Communications +----------------------- -.. code-block:: python +.. image:: img/data_in_tcpip_stack.png + :align: left + :width: 55% - >>> import os - >>> print os.environ['VARIABLE'] - some_value - >>> print os.environ.keys() - ['VERSIONER_PYTHON_PREFER_32_BIT', 'VARIABLE', - 'LOGNAME', 'USER', 'PATH', ...] +.. class:: incremental -Altering the Environment ------------------------- +* Process divided into 'layers' -You can alter os environment values while in Python: +* 'Layers' are mostly arbitrary -.. code-block:: python - :class: small +* Different descriptions have different layers - >>> os.environ['VARIABLE'] = 'new_value' - >>> print os.environ['VARIABLE'] - new_value +* Most common is the 'TCP/IP Stack' -.. container:: incremental +.. class:: image-credit - But that doesn't change the original value, *outside* Python: - - .. class:: small - - :: +image: http://en.wikipedia.org/wiki/Internet_Protocol_Suite - >>> ^D - $ echo this is the value: $VARIABLE - this is the value: some_value - - C:\> \Users\Administrator\> echo %VARIABLE% - 'some value' +The TCP/IP Stack - Link +----------------------- -Lessons Learned ---------------- +The bottom layer is the 'Link Layer' .. class:: incremental -* Subprocesses inherit their environment from their Parent -* Parents do not see changes to environment in subprocesses -* In Python, you can actually set the environment for a subprocess explicitly +* Deals with the physical connections between machines, 'the wire' -.. class:: incremental small +* Packages data for physical transport -:: +* Executes transmission over a physical medium - subprocess.Popen(args, bufsize=0, executable=None, - stdin=None, stdout=None, stderr=None, - preexec_fn=None, close_fds=False, - shell=False, cwd=None, env=None, # <------- - universal_newlines=False, startupinfo=None, - creationflags=0) + * what that medium is is arbitrary +* Implemented in the Network Interface Card(s) (NIC) in your computer -Web Environment ---------------- -.. class:: big-centered +The TCP/IP Stack - Internet +--------------------------- -CGI is little more than a set of standard environmental variables +Moving up, we have the 'Internet Layer' +.. class:: incremental -RFC 3875 --------- +* Deals with addressing and routing -First discussed in 1993, formalized in 1997, the current version (1.1) has -been in place since 2004. + * Where are we going and how do we get there? -From the preamble: +* Agnostic as to physical medium (IP over Avian Carrier - IPoAC) -.. class:: center +* Makes no promises of reliability -*This memo provides information for the Internet community. It does not specify -an Internet standard of any kind.* +* Two addressing systems -.. class:: image-credit + .. class:: incremental -RFC 3875 - CGI Version 1.1: http://tools.ietf.org/html/rfc3875 + * IPv4 (current, limited '192.168.1.100') + * IPv6 (future, 3.4 x 10^38 addresses, '2001:0db8:85a3:0042:0000:8a2e:0370:7334') -Meta-Variables --------------- -.. class:: small +The TCP/IP Stack - Internet +--------------------------- -:: +.. class:: big-centered - 4. The CGI Request . . . . . . . . . . . . . . . . . . . . . . . 10 - 4.1. Request Meta-Variables . . . . . . . . . . . . . . . . . 10 - 4.1.1. AUTH_TYPE. . . . . . . . . . . . . . . . . . . . 11 - 4.1.2. CONTENT_LENGTH . . . . . . . . . . . . . . . . . 12 - 4.1.3. CONTENT_TYPE . . . . . . . . . . . . . . . . . . 12 - 4.1.4. GATEWAY_INTERFACE. . . . . . . . . . . . . . . . 13 - 4.1.5. PATH_INFO. . . . . . . . . . . . . . . . . . . . 13 - 4.1.6. PATH_TRANSLATED. . . . . . . . . . . . . . . . . 14 - 4.1.7. QUERY_STRING . . . . . . . . . . . . . . . . . . 15 - 4.1.8. REMOTE_ADDR. . . . . . . . . . . . . . . . . . . 15 - 4.1.9. REMOTE_HOST. . . . . . . . . . . . . . . . . . . 16 - 4.1.10. REMOTE_IDENT . . . . . . . . . . . . . . . . . . 16 - 4.1.11. REMOTE_USER. . . . . . . . . . . . . . . . . . . 16 - 4.1.12. REQUEST_METHOD . . . . . . . . . . . . . . . . . 17 - 4.1.13. SCRIPT_NAME. . . . . . . . . . . . . . . . . . . 17 - 4.1.14. SERVER_NAME. . . . . . . . . . . . . . . . . . . 17 - 4.1.15. SERVER_PORT. . . . . . . . . . . . . . . . . . . 18 - 4.1.16. SERVER_PROTOCOL. . . . . . . . . . . . . . . . . 18 - 4.1.17. SERVER_SOFTWARE. . . . . . . . . . . . . . . . . 19 - - -Running CGI ------------ +That's 4.3 x 10^28 addresses *per person alive today* -You have a couple of options: -.. class:: incremental +The TCP/IP Stack - Transport +---------------------------- -* Python Standard Library CGIHTTPServer -* Apache -* IIS (on Windows) -* Some other HTTP server that implements CGI (lighttpd, ...?) +Next up is the 'Transport Layer' .. class:: incremental -Let's keep it simple by using the Python module +* Deals with transmission and reception of data + * error correction, flow control, congestion management -Preparations ------------- +* Common protocols include TCP & UDP -In the class resources, you'll find a directory named ``cgi``. Make a copy of -that folder in your class working directory. + * TCP: Tranmission Control Protocol -.. class:: incremental small red + * UDP: User Datagram Protocol -Windows Users, you will have to edit the first line of -``cgi/cgi-bin/cgi_1.py`` to point to your python executable. +* Not all Transport Protocols are 'reliable' -.. class:: incremental + .. class:: incremental -* Open *two* terminal windows in this ``cgi`` directory -* In the first terminal, run ``python -m CGIHTTPServer`` -* Open a web browser and load ``http://localhost:8000/`` -* Click on *CGI Test 1* + * TCP ensures that dropped packets are resent + * UDP makes no such assurance + + * Reliability is slow and expensive -Did that work? --------------- -* If nothing at all happens, check your terminal window -* Look for this: ``OSError: [Errno 13] Permission denied`` -* If you see something like that, check permissions for ``cgi-bin`` *and* - ``cgi_1.py`` -* The file must be executable, the directory needs to be readable *and* - executable. +The TCP/IP Stack - Transport +---------------------------- +The 'Transport Layer' also establishes the concept of a **port** .. class:: incremental -Remember that you can use the bash ``chmod`` command to change permissions in -\*nix +* IP Addresses designate a specific *machine* on the network -.. class:: incremental +* A **port** provides addressing for individual *applications* in a single host -Windows users, use the 'properties' context menu to get to permissions, just -grant 'full' +* 192.168.1.100:80 (the *:80* part is the **port**) -Break It --------- - -Problems with permissions can lead to failure. So can scripting errors +* [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443 (*:443* is the **port**) .. class:: incremental -* Open ``cgi/cgi-bin/cgi_1.py`` in an editor -* Before where it says ``cgi.test()``, add a single line: +This means that you don't have to worry about information intended for your +web browser being accidentally read by your email client. -.. code-block:: python - :class: incremental - 1 / 0 +The TCP/IP Stack - Transport +---------------------------- + +There are certain **ports** which are commonly understood to belong to given +applications or protocols: .. class:: incremental -Reload your browser, what happens now? +* 80/443 - HTTP/HTTPS +* 20 - FTP +* 22 - SSH +* 23 - Telnet +* 25 - SMTP +* ... +.. class:: incremental -Errors in CGI -------------- +These ports are often referred to as **well-known ports** -CGI is famously difficult to debug. There are reasons for this: +.. class:: small -.. class:: incremental +(see http://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers) -* CGI is designed to provide access to runnable processes to *the internet* -* The internet is a wretched hive of scum and villainy -* Revealing error conditions can expose data that could be exploited -Viewing Errors in Python CGI +The TCP/IP Stack - Transport ---------------------------- -Back in your editor, add the following lines, just below ``import cgi``: - -.. code-block:: python - :class: incremental - - import cgitb - cgitb.enable() +Ports are grouped into a few different classes .. class:: incremental -Now, reload again. +* Ports numbered 0 - 1023 are *reserved* -cgitb Output ------------- +* Ports numbered 1024 - 65535 are *open* -.. image:: img/cgitb_output.png - :align: center - :width: 100% +* Ports numbered 1024 - 49151 may be *registered* +* Ports numbered 49152 - 65535 are called *ephemeral* -Repair the Error ----------------- -Let's fix the error from our traceback. Edit your ``cgi_1.py`` file to match: +The TCP/IP Stack - Application +------------------------------ -.. code-block:: python - :class: small +The topmost layer is the 'Application Layer' - #!/usr/bin/python - import cgi - import cgitb +.. class:: incremental - cgitb.enable() +* Deals directly with data produced or consumed by an application - cgi.test() +* Reads or writes data using a set of understood, well-defined **protocols** -.. class:: incremental + * HTTP, SMTP, FTP etc. -Notice the first line of that script: ``#!/usr/bin/python``. This is called a -*shebang* (short for hash-bang) and it tells the system what executable -program to use when running the script. +* Does not know (or need to know) about lower layer functionality + * The exception to this rule is **endpoint** data (or IP:Port) -CGI Process Execution ---------------------- -When a web server like ``CGIHTTPServer`` or ``Apache`` runs a CGI script, it -simply attempts to run the script as if it were a normal system user. This is -just like you calling:: +The TCP/IP Stack - Application +------------------------------ - $ ./cgi_bin/cgi_1.py +.. class:: big-centered -.. class:: incremental +this is where we live and work -In fact try that now in your second terminal (use the real path), what do you -get? -.. class:: incremental small center +Sockets +------- -Windows folks, you may need ``C:\>python cgi_1.py`` +Think back for a second to what we just finished discussing, the TCP/IP stack. .. class:: incremental -What is missing? +* The *Internet* layer gives us an **IP Address** +* The *Transport* layer establishes the idea of a **port**. -CGI Process Execution ---------------------- +* The *Application* layer doesn't care about what happens below... -There are a couple of important facts that are related to the way CGI -processes are run: +* *Except for* **endpoint data** (IP:Port) .. class:: incremental -* The script **must** include a *shebang* so that the system knows how to run - it. -* The script **must** be executable. -* The *executable* named in the *shebang* will be called as the *nobody* user. -* This is a security feature to prevent CGI scripts from running as a user - with any privileges. -* This means that the *executable* from the script *shebang* must be one that - *anyone* can run. +A **Socket** is the software representation of that endpoint. +.. class:: incremental -The CGI Environment -------------------- +Opening a **socket** creates a kind of transceiver that can send and/or +receive *bytes* at a given IP address and Port. -CGI is largely a set of agreed-upon environmental variables. -.. class:: incremental +Sockets in Python +----------------- -We've seen how environmental variables are found in python in ``os.environ`` +Python provides a standard library module which provides socket functionality. +It is called **socket**. .. class:: incremental -We've also seen that at least some of the variables in CGI are **not** in the -standard set of environment variables. +The library is really just a very thin wrapper around the system +implementation of *BSD Sockets* .. class:: incremental -Where do they come from? +Let's spend a few minutes getting to know this module. +.. class:: incremental -CGI Servers ------------ - -Let's find 'em. In a terminal (on your local machine, please) fire up python: +We're going to do this next part together, so open up a terminal and start a +python interpreter -.. code-block:: - >>> import CGIHTTPServer - >>> CGIHTTPServer.__file__ - '/big/giant/path/to/lib/python2.6/CGIHTTPServer.py' +Sockets in Python +----------------- -.. class:: incremental +The Python sockets library allows us to find out what port a *service* uses: -Copy this path and open the file it points to in your text editor +.. class:: small + >>> import socket + >>> socket.getservbyname('ssh') + 22 -Environmental Set Up --------------------- +.. class:: incremental -From CGIHTTPServer.py, in the CGIHTTPServer.run_cgi method: +You can also do a *reverse lookup*, finding what service uses a given *port*: -.. code-block:: python - :class: tiny - - # Reference: http://hoohoo.ncsa.uiuc.edu/cgi/env.html - # XXX Much of the following could be prepared ahead of time! - env = {} - env['SERVER_SOFTWARE'] = self.version_string() - env['SERVER_NAME'] = self.server.server_name - env['GATEWAY_INTERFACE'] = 'CGI/1.1' - env['SERVER_PROTOCOL'] = self.protocol_version - env['SERVER_PORT'] = str(self.server.server_port) - env['REQUEST_METHOD'] = self.command - ... - ua = self.headers.getheader('user-agent') - if ua: - env['HTTP_USER_AGENT'] = ua - ... - os.environ.update(env) - ... +.. class:: incremental small + >>> socket.getservbyport(80) + 'http' -CGI Scripts ------------ -And that's it, the big secret. The server takes care of setting up the -environment so it has what is needed. +Sockets in Python +----------------- -.. class:: incremental +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:: -Now, in reverse. How does the information that a script creates end up in your -browser? + >>> socket.gethostname() + 'heffalump.local' + >>> socket.gethostbyname(socket.gethostname()) + '10.211.55.2' -.. class:: incremental -A CGI Script must print its results to stdout. +Sockets in Python +----------------- -.. class:: incremental +You can also find out about machines that are located elsewhere, assuming you +know their hostname. For example:: -Use the same method as above to import and open the source file for the -``cgi`` module. Note what ``test`` does for an example of this. + >>> socket.gethostbyname('google.com') + '173.194.33.4' + >>> socket.gethostbyname('uw.edu') + '128.95.155.135' + >>> socket.gethostbyname('crisewing.com') + '108.59.11.99' -Recap: ------- +Sockets in Python +----------------- -What the Server Does: +The ``gethostbyname_ex`` method of the ``socket`` library provides more +information about the machines we are exploring:: -.. class:: incremental small + >>> 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 -* parses the request -* sets up the environment, including HTTP and SERVER variables -* figures out if the URI points to a CGI script and runs it -* builds an appropriate HTTP Response first line ('HTTP/1.1 200 OK\\r\\n') -* appends what comes from the script on stdout and sends that back -What the Script Does: +Sockets in Python +----------------- -.. class:: incremental small +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):: -* names appropriate *executable* in it's *shebang* line -* uses os.environ to read information from the HTTP request -* builds *any and all* appropriate **HTTP Headers** (Content-type:, - Content-length:, ...) -* prints headers, empty line and script output (body) to stdout + >>> foo = socket.socket() + >>> foo + -In-Class Exercise +Sockets in Python ----------------- -You've seen the output from the ``cgi.test()`` method from the ``cgi`` module. -Let's make our own version of this. +A socket has some properties that are immediately important to us. These +include the *family*, *type* and *protocol* of the socket:: -.. class:: incremental small + >>> foo.family + 2 + >>> foo.type + 1 + >>> foo.proto + 0 -* In the directory ``cgi-bin`` you will find the file ``cgi_2.py``. -* Open that file in your editor. -* The script contains some html with text naming elements of the CGI - environment. -* You should use the values in os.environ to fill in the blanks. -* You should be able to view the results of your work by loading - ``http://localhost:8000/`` and clicking on *Exercise One* +.. class:: incremental -.. class:: incremental center +You might notice that the values for these properties are integers. In fact, +these integers are **constants** defined in the socket library. -**GO** +A quick utility method +---------------------- -User Provided Data ------------------- +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: -All this is well and good, but where's the *dynamic* stuff? +.. class:: small -.. class:: incremental +:: -It'd be nice if a user could pass form data to our script for it to use. + >>> 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) + ... ) + ... + >>> -.. container:: incremental +.. class:: small - In HTTP, these types of inputs show up in the URL *query* (the part after - the ``?``):: +(you can also find this in ``resources/session01/session1.py``) - http://myhost.com/script.py?a=23&b=37 +Socket Families +--------------- -Form Data in CGI ----------------- +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: -In the ``cgi`` module, we get access to this with the ``FieldStorage`` class: +.. class:: incremental -.. code-block:: python - :class: incremental small +* IPv4 ('192.168.1.100') - import cgi - - form = cgi.FieldStorage() - stringval = form.getvalue('a', None) - listval = form.getlist('b') +* IPv6 ('2001:0db8:85a3:0042:0000:8a2e:0370:7334') .. class:: incremental -* The values in the ``FieldStorage`` are *always* strings -* ``getvalue`` allows you to return a default, in case the field isn't present -* ``getlist`` always returns a list: empty, one-valued, or as many values as - are present +The **family** of a socket corresponds to the *addressing system* it uses for +connecting. -In-Class Exercise ------------------ +Socket Families +--------------- -Let's create a dynamic adding machine. +Families defined in the ``socket`` library are prefixed by ``AF_``:: -.. class:: incremental + >>> 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 -* In the ``cgi-bin`` directory you'll find ``cgi_sums.py``. -* In the ``index.html`` file in the ``cgi`` directory, the third link leads to - this file. -* You will use the structure of that link, and what you learned just now about - ``cgi.FieldStorage``. -* Complete the cgi script in ``cgi_sums.py`` so that the result of adding all - operands sent via the url query is returned. +*Your results may vary* .. class:: incremental -For extra fun, return the results in ``json`` format (mimetype: -'application/json'). +Of all of these, the ones we care most about are ``2`` (IPv4) and ``30`` (IPv6). -My Solution ------------ +Unix Domain Sockets +------------------- -.. code-block:: python - :class: small incremental +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: - form = cgi.FieldStorage() - operands = form.getlist('operand') - total = 0 - for operand in operands: - try: - value = int(operand) - except ValueError: - value = 0 - total += value +.. class:: incremental - output = {'result': total} - json_output = json.dumps(output) +* connect processes **on the same machine** - print "Content-Type: application/json" - print "Content-Length: %s" % len(json_output) - print - print json_output +* 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 -Stopping Point --------------- +* use an 'address' that looks like a pathname ('/tmp/foo.sock') -.. class:: big-centered -Let's take a break here, before continuing +Test your skills +---------------- +What is the *default* family for the socket we created just a moment ago? -CGI Problems ------------- +.. class:: incremental -CGI is great, but there are problems: +(remember we bound the socket to the symbol ``foo``) -.. class:: incremental +.. class:: incremental center -* Code is executed *in a new process* -* **Every** call to a CGI script starts a new process on the server -* Starting a new process is expensive in terms of server resources -* *Especially for interpreted languages like Python* +How did you figure this out? -.. class:: incremental -How do we overcome this problem? +Socket Types +------------ +The socket *type* determines the semantics of socket communications. -Alternatives to CGI -------------------- +Look up socket type constants with the ``SOCK_`` prefix:: -The most popular approach is to have a long-running process *inside* the -server that handles CGI scripts. + >>> types = get_constants('SOCK_') + >>> types + {1: 'SOCK_STREAM', 2: 'SOCK_DGRAM', + ...} .. class:: incremental -FastCGI and SCGI are existing implementations of CGI in this fashion. The -Apache module **mod_python** offers a similar capability for Python code. +The most common are ``1`` (Stream communication (TCP)) and ``2`` (Datagram +communication (UDP)). -.. class:: incremental -* Each of these options has a specific API -* None are compatible with each-other -* Code written for one is **not portable** to another +Test your skills +---------------- -.. class:: incremental +What is the *default* type for our generic socket, ``foo``? -This makes it much more difficult to *share resources* +Socket Protocols +---------------- -WSGI ----- +A socket also has a designated *protocol*. The constants for these are +prefixed by ``IPPROTO_``:: -Enter WSGI, the Web Server Gateway Interface. + >>> protocols = get_constants('IPPROTO_') + >>> protocols + {0: 'IPPROTO_IP', 1: 'IPPROTO_ICMP', + ..., + 255: 'IPPROTO_RAW'} .. class:: incremental -Where other alternatives are specific implementations of the CGI standard, -WSGI is itself a new standard, not an implementation. +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``? -.. class:: incremental -WSGI is generalized to describe a set of interactions, so that developers can -write WSGI-capable apps and deploy them on any WSGI server. +Test your skills +---------------- -.. class:: incremental +What is the *default* protocol used by our generic socket, ``foo``? -Read the WSGI spec: http://www.python.org/dev/peps/pep-0333 +Custom Sockets +-------------- -WSGI: Apps and Servers ----------------------- +These three properties of a socket correspond to the three positional +arguments you may pass to the socket constructor. -.. class:: small +.. container:: incremental -WSGI consists of two parts, a *server* and an *application*. + Using them allows you to create sockets with specific communications + profiles:: + + >>> bar = socket.socket(socket.AF_INET, + ... socket.SOCK_DGRAM, + ... socket.IPPROTO_UDP) + ... + >>> bar + -.. class:: small -A WSGI Server must: +Break Time +---------- -.. class:: incremental small +So far we have: -* set up an environment, much like the one in CGI -* provide a method ``start_response(status, headers, exc_info=None)`` -* build a response body by calling an *application*, passing - ``environment`` and ``start_response`` as args -* return a response with the status, headers and body +.. class:: incremental -.. class:: small +* 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. -A WSGI Appliction must: +.. class:: incremental -.. class:: incremental small +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. -* Be a callable (function, method, class) -* Take an environment and a ``start_response`` callable as arguments -* Call the ``start_response`` method. -* Return an iterable of 0 or more strings, which are treated as the body of - the response. +.. class:: incremental +Take a few minutes now to clear your head (do not quit your python +interpreter). -Simplified WSGI Server ----------------------- -.. code-block:: python - :class: small +Address Information +------------------- - from some_application import simple_app - - def build_env(request): - # put together some environment info from the reqeuest - return env - - def handle_request(request, app): - environ = build_env(request) - iterable = app(environ, start_response) - for data in iterable: - # send data to client here - - def start_response(status, headers): - # start an HTTP response, sending status and headers - - # listen for HTTP requests and pass on to handle_request() - serve(simple_app) +When you are creating a socket to communicate with a remote service, the +remote socket will have a specific communications profile. +.. class:: incremental -Simple WSGI Application ------------------------ +The local socket you create must match that communications profile. -Where the simplified server above is **not** functional, this *is* a complete -app: +.. class:: incremental -.. code-block:: python +How can you determine the *correct* values to use? - def application(environ, start_response) - status = "200 OK" - body = "Hello World\n" - response_headers = [('Content-type', 'text/plain'), - ('Content-length', len(body))] - start_response(status, response_headers) - return [body] +.. class:: incremental center +You ask. -WSGI Middleware ---------------- -A third part of the puzzle is something called WSGI *middleware* +Address Information +------------------- -.. class:: incremental +The function ``socket.getaddrinfo`` provides information about available +connections on a given host. -* Middleware implements both the *server* and *application* interfaces -* Middleware acts as a server when viewed from an application -* Middleware acts as an application when viewed from a server +.. code-block:: python + :class: small -.. image:: img/wsgi_middleware_onion.png - :align: center - :width: 38% - :class: incremental + socket.getaddrinfo('127.0.0.1', 80) +.. class:: incremental -Flowcharts ----------- +This provides all you need to make a proper connection to a socket on a remote +host. The value returned is a tuple of: -WSGI Servers: +.. class:: incremental -.. class:: center incremental +* socket family +* socket type +* socket protocol +* canonical name (usually empty, unless requested by flag) +* socket address (tuple of IP and Port) -**HTTP <---> WSGI** -.. class:: incremental +A quick utility method +---------------------- -WSGI Applications: +Again, let's create a utility method in-place so we can see this in action: -.. class:: center incremental +.. class:: small -**WSGI <---> app code** +:: + >>> 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 + ... + >>> -The Whole Enchilada -------------------- +.. class:: small -The WSGI *Stack* can thus be expressed like so: +(you can also find this in ``resources/session01/session1.py``) -.. class:: incremental big-centered -**HTTP <---> WSGI <---> app code** +On Your Own Machine +------------------- +Now, ask your own machine what possible connections are available for 'http':: -Using wsgiref -------------- + >>> 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 + ... + >>> -The Python standard lib provides a reference implementation of WSGI: +.. class:: incremental -.. image:: img/wsgiref_flow.png - :align: center - :width: 80% - :class: incremental +What answers do you get? -Apache mod_wsgi +On the Internet --------------- -You can also deploy with Apache as your HTTP server, using **mod_wsgi**: +:: -.. image:: img/mod_wsgi_flow.png - :align: center - :width: 80% - :class: incremental + >>> get_address_info('crisewing.com', 'http') + family: AF_INET + type: SOCK_DGRAM + ... + family: AF_INET + type: SOCK_STREAM + ... + >>> -Proxied WSGI Servers --------------------- +.. class:: incremental -Finally, it is also common to see WSGI apps deployed via a proxied WSGI -server: +Try a few other servers you know about. -.. image:: img/proxy_wsgi.png - :align: center - :width: 80% - :class: incremental +First Steps +----------- -The WSGI Environment --------------------- +.. class:: big-centered -.. class:: small +Let's put this to use -REQUEST_METHOD - The HTTP request method, such as "GET" or "POST". This cannot ever be an - empty string, and so is always required. -SCRIPT_NAME - The initial portion of the request URL's "path" that corresponds to the - application object, so that the application knows its virtual "location". - This may be an empty string, if the application corresponds to the "root" of - the server. -PATH_INFO - The remainder of the request URL's "path", designating the virtual - "location" of the request's target within the application. This may be an - empty string, if the request URL targets the application root and does not - have a trailing slash. -QUERY_STRING - The portion of the request URL that follows the "?", if any. May be empty or - absent. -CONTENT_TYPE - The contents of any Content-Type fields in the HTTP request. May be empty or - absent. - - -The WSGI Environment --------------------- -.. class:: small +Construct a Socket +------------------ -CONTENT_LENGTH - The contents of any Content-Length fields in the HTTP request. May be empty - or absent. -SERVER_NAME, SERVER_PORT - When combined with SCRIPT_NAME and PATH_INFO, these variables can be used to - complete the URL. Note, however, that HTTP_HOST, if present, should be used - in preference to SERVER_NAME for reconstructing the request URL. See the URL - Reconstruction section below for more detail. SERVER_NAME and SERVER_PORT - can never be empty strings, and so are always required. -SERVER_PROTOCOL - The version of the protocol the client used to send the request. Typically - this will be something like "HTTP/1.0" or "HTTP/1.1" and may be used by the - application to determine how to treat any HTTP request headers. (This - variable should probably be called REQUEST_PROTOCOL, since it denotes the - protocol used in the request, and is not necessarily the protocol that will - be used in the server's response. However, for compatibility with CGI we - have to keep the existing name.) - - -The WSGI Environment --------------------- +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 -HTTP\_ Variables - Variables corresponding to the client-supplied HTTP request headers (i.e., - variables whose names begin with "HTTP\_"). The presence or absence of these - variables should correspond with the presence or absence of the appropriate - HTTP header in the request. - -.. class:: center incremental +:: -**Seem Familiar?** + >>> 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]) -A Bit of Repetition +Connecting a Socket ------------------- -Let's start simply. We'll begin by repeating our first CGI exercise in WSGI +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 -* Find the ``wsgi`` directory in the class resources. Copy it to your working - directory. -* Open the file ``wsgi_1.py`` in your text editor. -* We will fill in the missing values using the wsgi ``environ``, just as we - use ``os.environ`` in cgi +* a successful connection returns ``None`` -.. class:: incremental center +* a failed connection raises an error -**But First** +* you can use the *type* of error returned to tell why the connection failed. -Orientation ------------ +Sending a Message +----------------- -.. code-block:: python - :class: small +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):: - if __name__ == '__main__': - from wsgiref.simple_server import make_server - srv = make_server('localhost', 8080, application) - srv.serve_forever() + >>> msg = "GET / HTTP/1.1\r\n" + >>> msg += "Host: crisewing.com\r\n\r\n" + >>> cewing_socket.sendall(msg) + >>> -.. class:: incremental +.. class:: incremental small -Note that we pass our ``application`` function to the server factory +* the transmission continues until all data is sent or an error occurs -.. class:: incremental +* success returns ``None`` -We don't have to write a server, ``wsgiref`` does that for us. +* failure to send raises an error -.. class:: incremental +* you can use the type of error to figure out why the transmission failed -In fact, you should *never* have to write a WSGI server. +* if an error occurs you **cannot** know how much, if any, of your data was + sent -Orientation ------------ +Receiving a Reply +----------------- -.. code-block:: python - :class: small +Whatever reply we get is received by the socket we created. We can read it +back out (again, **do not type this yet**):: - def application(environ, start_response): - response_body = body % ( - environ.get('SERVER_NAME', 'Unset'), # server name - ... - ) - status = '200 OK' - response_headers = [('Content-Type', 'text/html'), - ('Content-Length', str(len(response_body)))] - start_response(status, response_headers) - return [response_body] + >>> response = cewing_socket.recv(4096) + >>> response + 'HTTP/1.1 200 OK\r\nDate: Thu, 03 Jan 2013 05:56:53 + ... -.. class:: incremental +.. class:: incremental small -We do not define ``start_response``, the application does that. +* 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``. -.. class:: incremental -We *are* responsible for determining the HTTP status. +Cleaning Up +----------- +When you are finished with a connection, you should always close it:: -Running a WSGI Script ---------------------- + >>> cewing_socket.close() -You can run this script with python:: - $ python wsgi_1.py +Putting it all together +----------------------- -.. class:: incremental +First, connect and send a message: -This will start a wsgi server. What host and port will it use? +.. class:: small -.. class:: incremental +:: -Point your browser at ``http://localhost:8080/``. Did it work? + >>> 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) -.. class:: incremental -Go ahead and fill in the missing bits. Use the ``environ`` passed into -``application`` +Putting it all together +----------------------- +Then, receive a reply, iterating until it is complete: -Some Tips ---------- +:: -Because WSGI is a long-running process, the file you are editing is *not* -reloaded after you edit it. + >>> 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:: incremental +.. class:: big-centered -You'll need to quit and re-run the script between edits. +What about the other half of the equation? -.. class:: incremental +Construct a Socket +------------------ -You may also want to consider using ``print environ`` in your application so -you can see the dictionary. +**For the moment, stop typing this into your interpreter.** -.. class:: incremental +.. container:: incremental -If you do that, where will the printed environment appear? + 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 + -A More Complex Example ----------------------- -Let's create a multi-page wsgi application. It will serve a small database of -python books. +Bind the Socket +--------------- -.. class:: incremental +Our server socket needs to be bound to an address. This is the IP Address and +Port to which clients must connect:: -The database (with a very simple api) can be found in ``wsgi/bookdb.py`` + >>> address = ('127.0.0.1', 50000) + >>> server_socket.bind(address) .. class:: incremental -* We'll need a listing page that shows the titles of all the books -* Each title will link to a details page for that book -* The details page for each book will display all the information and have a - link back to the list +**Terminology Note**: In a server/client relationship, the server *binds* to +an address and port. The client *connects* -Some Questions to Ponder ------------------------- +Listen for Connections +---------------------- -.. class:: incremental +Once our socket is bound to an address, we can listen for attempted +connections:: -When viewing our first wsgi app, do we see the name of the wsgi application -script anywhere in the URL? + >>> server_socket.listen(1) .. class:: incremental -In our wsgi application script, how many applications did we actually have? +* The argument to ``listen`` is the *backlog* -.. class:: incremental +* The *backlog* is the **maximum** number of connection requests that the + socket will queue -How are we going to serve different types of information out of a single -application? +* Once the limit is reached, the socket refuses new connections. -Dispatch --------- - -We have to write an app that will map our incoming request path to some code -that can handle that request. - -.. class:: incremental - -This process is called ``dispatch``. There are many possible approaches +Accept Incoming Messages +------------------------ -.. class:: incremental +When a socket is listening, it can receive incoming connection requests:: -Let's begin by designing this piece of it. + >>> connection, client_address = server_socket.accept() + ... # this blocks until a client connects + >>> connection.recv(16) .. class:: incremental -Open ``bookapp.py`` from the ``wsgi`` folder. We'll do our work here. +* 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 -PATH ----- +* When a connection request is 'accepted', it is removed from the backlog + queue. -The wsgi environment gives us access to *PATH_INFO*, which maps to the URI the -user requested when they loaded the page. -.. class:: incremental - -We can design the URLs that our app will use to assist us in routing. +Send a Reply +------------ -.. class:: incremental +The same socket that received a message from the client may be used to return +a reply:: -Let's declare that any request for ``/`` will map to the list page + >>> connection.sendall("message received") -.. container:: incremental - We can also say that the URL for a book will look like this:: - - http://localhost:8080/book/ +Clean Up +-------- -Writing resolve_path --------------------- +Once a transaction between the client and server is complete, the +``connection`` socket should be closed:: -Let's write a function, called ``resolve_path`` in our application file. + >>> connection.close() .. class:: incremental -* It should take the *PATH_INFO* value from environ as an argument. -* It should return the function that will be called. -* It should also return any arguments needed to call that function. -* This implies of course that the arguments should be part of the PATH +Note that the ``server_socket`` is *never* closed as long as the server +continues to run. -My Solution ------------ - -.. code-block:: python - :class: small incremental - - def resolve_path(path): - urls = [(r'^$', books), - (r'^book/(id[\d]+)$', book)] - matchpath = path.lstrip('/') - for regexp, func in urls: - match = re.match(regexp, matchpath) - if match is None: - continue - args = match.groups([]) - return func, args - # we get here if no url matches - raise NameError - - -Application Updates -------------------- +Getting the Flow +---------------- -We need to hook our new router into the application. +The flow of this interaction can be a bit confusing. Let's see it in action +step-by-step. .. class:: incremental -* The path should be extracted from ``environ``. -* The router should be used to get a function and arguments -* The body to return should come from calling that function with those - arguments -* If an error is raised by calling the function, an appropriate response - should be returned -* If the router raises a NameError, the application should return a 404 - response - - -My Solution ------------ - -.. code-block:: python - :class: small incremental - - def application(environ, start_response): - headers = [("Content-type", "text/html")] - try: - path = environ.get('PATH_INFO', None) - if path is None: - raise NameError - func, args = resolve_path(path) - body = func(*args) - status = "200 OK" - except NameError: - status = "404 Not Found" - body = "

    Not Found

    " - except Exception: - status = "500 Internal Server Error" - body = "

    Internal Server Error

    " - finally: - headers.append(('Content-length', str(len(body)))) - start_response(status, headers) - return [body] - - -Test Your Work --------------- - -Once you've got your script settled, run it:: +Open a second python interpreter and place it next to your first so you can +see both of them at the same time. - $ python bookapp.py -.. class:: incremental +Create a Server +--------------- -Then point your browser at ``http://localhost:8080/`` +In your first python interpreter, create a server socket and prepare it for +connections:: -.. class:: incremental + >>> 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() -* ``http://localhost/book/id3`` -* ``http://localhost/book/id73/`` -* ``http://localhost/sponge/damp`` - .. class:: incremental -Did that all work as you would have expected? +At this point, you should **not** get back a prompt. The server socket is +waiting for a connection to be made. -Building the List ------------------ +Create a Client +--------------- -The function ``books`` should return an html list of book titles where each -title is a link to the detail page for that book +In your second interpreter, create a client socket and prepare to send a +message:: -.. class:: incremental + >>> import socket + >>> client_socket = socket.socket( + ... socket.AF_INET, + ... socket.SOCK_STREAM, + ... socket.IPPROTO_IP) -* You'll need all the ids and titles from the book database. -* You'll need to build a list in HTML using this information -* Each list item should have the book title as a link -* The href for the link should be of the form ``/book/`` +.. container:: incremental + Before connecting, keep your eye on the server interpreter:: -My Solution ------------ + >>> client_socket.connect(('127.0.0.1', 50000)) -.. code-block:: python - :class: incremental small - def books(): - all_books = DB.titles() - body = ['

    My Bookshelf

    ', '
      '] - item_template = '
    • {title}
    • ' - for book in all_books: - body.append(item_template.format(**book)) - body.append('
    ') - return '\n'.join(body) +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. -Test Your Work --------------- +.. class:: incremental -Quit and then restart your application script:: +When you're ready, type the following in the *client* interpreter. - $ python bookapp.py +.. class:: incremental -.. container:: incremental +:: - Then reload the root of your application:: + >>> client_socket.sendall("Hey, can you hear me?") - http://localhost:8080/ -.. class:: incremental +Receive and Respond +------------------- -You should see a nice list of the books in the database. Do you? +Back in your server interpreter, go ahead and receive the message from your +client:: -.. class:: incremental + >>> conn.recv(32) + 'Hey, can you hear me?' -Click on a link to view the detail page. Does it load without error? +Send a message back, and then close up your connection:: + >>> conn.sendall("Yes, I hear you.") + >>> conn.close() -Showing Details ---------------- -The next step of course is to polish up those detail pages. +Finish Up +--------- -.. class:: incremental +Back in your client interpreter, take a look at the response to your message, +then be sure to close your client socket too:: -* You'll need to retrieve a single book from the database -* You'll need to format the details about that book and return them as HTML -* You'll need to guard against ids that do not map to books + >>> client_socket.recv(32) + 'Yes, I hear you.' + >>> client_socket.close() -.. class:: incremental +And now that we're done, we can close up the server too (back in the server +interpreter):: -In this last case, what's the right HTTP response code to send? + >>> server_socket.close() -My Solution ------------ +Congratulations! +---------------- -.. code-block:: python - :class: incremental small +.. class:: big-centered - def book(book_id): - page = """ -

    {title}

    - - - - -
    Author{author}
    Publisher{publisher}
    ISBN{isbn}
    - Back to the list - """ - book = DB.title_info(book_id) - if book is None: - raise NameError - return page.format(**book) +You've run your first client-server interaction -Revel in Your Success ---------------------- +Homework +-------- -Quit and restart your script one more time +Your homework assignment for this week is to take what you've learned here +and build a simple "echo" server. .. class:: incremental -Then poke around at your application and see the good you've made +The server should automatically return to any client that connects *exactly* +what it receives (it should **echo** all messages). .. class:: incremental -And your application is portable and sharable +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 -It should run equally well under any `wsgi server -`_ - - -A Few Steps Further -------------------- +Finally, you'll do all of this so that it can be tested. -Next steps for an app like this might be: -* Create a shared full page template and incorporate it into your app -* Improve the error handling by emitting error codes other than 404 and 500 -* Swap out the basic backend here with a different one, maybe a Web Service? -* Think about ways to make the application less tightly coupled to the pages - it serves +What You Have +------------- +In our class repository, there is a folder ``assignments/session01``. -Homework --------- +.. class:: incremental -For your homework this week, you'll be creating a wsgi application of your -own. +Inside that folder, you should find: .. class:: incremental -As the source of your data, use the mashup you created last week. +* A file ``tasks.txt`` that contains these instructions -.. class:: incremental +* A skeleton for your server in ``echo_server.py`` -Your application should have at least two separate "pages" in it. +* A skeleton for your client script in ``echo_client.py`` + +* Some simple tests in ``tests.py`` .. class:: incremental -The HTML you produce does not need to be pretty, but it should be something -that shows up in a browser. +Your task is to make the tests pass. -Submitting Your Homework ------------------------- +Running the tests +----------------- -To submit your homework: +To run the tests, you'll have to set the server running in one terminal: .. class:: small -* Create a new python script in ``assignments/session04``. It should be - something I can run with: +:: -.. class:: small + $ python echo_server.py -:: +.. container:: incremental - $ python your_script.py + Then, in a second terminal, you will execute the tests: + + .. class:: small + + :: + + $ python tests.py -.. class:: small +.. container:: incremental -* Once your script is running, I should be able to view your application in my - browser. + You should see output like this: + + .. class:: small + + :: + + [...] + FAILED (failures=2) -* Include all instructions I need to successfully run and view your script. -* Add tests for your code. I should be able to run the tests like so: +Submitting Your Homework +------------------------ -.. class:: small +To submit your homework: -:: +.. class:: incremental - $ python tests.py +* In github, make a fork of my repository into *your* account. -.. class:: small +* Clone your fork of my repository to your computer. -* Commit your changes to your fork of the repo in github, then open a pull - request. +* 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. -But Wait, There's More ----------------------- +.. class:: incremental -In addition, read and step through the quick tutorials on templates and -database persistence in the assignments directory. +I will review your work when I receive your pull requests, make comments on it +there, and then close the pull request. -Use your flaskenv Python, it has everything you need installed. +Going Further +------------- -Wrap-Up -------- +In ``assignments/session01/tasks.txt`` you'll find a few extra problems to try. -For educational purposes, you might wish to take a look at the source code for -the ``wsgiref`` module. It's the canonical example of a simple wsgi server +.. class:: incremental - >>> import wsgiref - >>> wsgiref.__file__ - '/full/path/to/your/copy/of/wsgiref.py' - ... +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 center +.. class:: incremental -**See you Next Time** +They are not required, but if you include solutions in your pull request, I'll +review your work. From 01ab9336d039ce754a1434a4a570a19ab178914c Mon Sep 17 00:00:00 2001 From: cewing Date: Tue, 30 Dec 2014 19:37:36 -0800 Subject: [PATCH 031/223] start a new session 1 file --- source/presentations/session01.rst | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 source/presentations/session01.rst diff --git a/source/presentations/session01.rst b/source/presentations/session01.rst new file mode 100644 index 00000000..7380e0ae --- /dev/null +++ b/source/presentations/session01.rst @@ -0,0 +1,6 @@ +Python Web Programming +====================== + +.. image:: img/python.png + :align: left + :width: 33% From b3f463c88710768c311015a9a20766b93ffa99da Mon Sep 17 00:00:00 2001 From: cewing Date: Tue, 30 Dec 2014 19:50:32 -0800 Subject: [PATCH 032/223] update for 2015 run --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 0b22e372..d79a8a4e 100644 --- a/README.rst +++ b/README.rst @@ -10,9 +10,9 @@ 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 2014 instance of the course, Taught by `Cris Ewing`_ +the Winter 2015 instance of the course, Taught by `Cris Ewing`_ -.. _Internet Programming in Python: http://www.pce.uw.edu/courses/internet-programming-python/downtown-seattle-winter-2014/ +.. _Internet Programming in Python: http://www.pce.uw.edu/courses/internet-programming-python/downtown-seattle-winter-2015/ .. _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 From 1cc4198e72b18c4274691cf393d533838a21a288 Mon Sep 17 00:00:00 2001 From: cewing Date: Fri, 2 Jan 2015 16:50:21 -0800 Subject: [PATCH 033/223] begin converting buildout to use hieroglyph for prettier slides and better slide/text integration --- .gitignore | 3 +++ Makefile | 42 +++++++++++++++++++++++++++++----- buildout.cfg | 58 ++++++++++++----------------------------------- commands/build.in | 14 ------------ 4 files changed, 54 insertions(+), 63 deletions(-) delete mode 100644 commands/build.in diff --git a/.gitignore b/.gitignore index 86ecdd9d..663f268d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ development *.db *.sublime-project *.sublime-workspace +.mr.developer.cfg +outline_improvements.txt +src diff --git a/Makefile b/Makefile index 11bcf2d7..20506467 100644 --- a/Makefile +++ b/Makefile @@ -3,10 +3,15 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXBUILD = ./bin/sphinx-build PAPER = BUILDDIR = build +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter @@ -29,17 +34,20 @@ help: @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: - -rm -rf $(BUILDDIR)/* + rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @@ -77,17 +85,17 @@ qthelp: @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/InternetProgrammingwithPython.qhcp" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PythonWebProgramming.qhcp" @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/InternetProgrammingwithPython.qhc" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PythonWebProgramming.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/InternetProgrammingwithPython" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/InternetProgrammingwithPython" + @echo "# mkdir -p $$HOME/.local/share/devhelp/PythonWebProgramming" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PythonWebProgramming" @echo "# devhelp" epub: @@ -108,6 +116,12 @@ latexpdf: $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @@ -151,3 +165,19 @@ doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + + +slides: + $(SPHINXBUILD) -b slides $(ALLSPHINXOPTS) $(BUILDDIR)/slides + @echo "Build finished. The HTML slides are in $(BUILDDIR)/slides." + diff --git a/buildout.cfg b/buildout.cfg index f8537e31..a69c7287 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -2,13 +2,13 @@ # Buildout to set-up Sphinx # [buildout] -parts = -# venv -# venv-pygments - build_s5 - executable +parts = sphinx +extensions = mr.developer +auto-checkout = * +always-checkout = force + allow-picked-versions = true show-picked-versions = true @@ -18,55 +18,23 @@ script-in = ${buildout:directory}/commands/build.in [sphinx] recipe = collective.recipe.sphinxbuilder -#doc-directory = . -outputs = +outputs = html source = ${buildout:directory}/source/main build = ${buildout:directory}/build eggs = Sphinx docutils - roman Pygments - -[venv] -recipe = rjm.recipe.venv -venv_options = --no-site-packages -distutils_urls = - http://pypi.python.org/packages/source/d/docutils/docutils-0.9.1.tar.gz - -[build_s5] -recipe = collective.recipe.template[genshi]:genshi -input = ${buildout:script-in} -output = ${buildout:directory}/bin/build_s5 -build-suffix = html -build-directory = ${buildout:directory}/build/html/presentations -build-cmd = ${buildout:directory}/bin/rst2s5.py - -[executable] -recipe = collective.recipe.cmd -on_install = true -on_update = true -cmds = - chmod 744 ${build_s5:output} - -# manually install Pygments into the docutils venv so it will be there for -# colorizing slide code examples. -[venv-pygments] -recipe = collective.recipe.cmd -on_install = true -on_update = false -cmds = - ${buildout:directory}/bin/easy_install Pygments - + hieroglyph + ipython [versions] # pin versions for continued sanity -Jinja2 = 2.6 +Jinja2 = 2.7.2 Pygments = 1.6 -Sphinx = 1.1.3 -collective.recipe.sphinxbuilder = 0.7.1 -roman = 1.4.0 +Sphinx = 1.2.2 +collective.recipe.sphinxbuilder = 0.8.2 #Required by: #collective.recipe.sphinxbuilder 0.7.1 @@ -89,3 +57,7 @@ rjm.recipe.venv = 0.8 #Required by: #rjm.recipe.venv 0.8 virtualenv = 1.10 + + +[sources] +hieroglyph = git https://github.com/nyergler/hieroglyph.git diff --git a/commands/build.in b/commands/build.in deleted file mode 100644 index cc365279..00000000 --- a/commands/build.in +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -shopt -s nullglob -SRC=rst -DEST=${options['build-suffix']} - -cp -R ${parts.buildout.directory}/source/ui ${options['build-directory']}/ -cp -R ${parts.buildout.directory}/source/img ${options['build-directory']}/ - -for RST in ${parts.buildout.directory}/source/presentations/*.rst -do - BASE=`basename $$RST` - OUT=${options['build-directory']}/$${BASE%.$$SRC}.$$DEST - ${options['build-cmd']} $$RST $$OUT -done From a674a7d6474333e085dd25e159a5a0a69d7204b5 Mon Sep 17 00:00:00 2001 From: cewing Date: Fri, 2 Jan 2015 17:09:02 -0800 Subject: [PATCH 034/223] remove unused packages from buildout --- buildout.cfg | 6 ------ 1 file changed, 6 deletions(-) diff --git a/buildout.cfg b/buildout.cfg index a69c7287..ed81b6b9 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -48,12 +48,6 @@ zc.buildout = 1.5.2 #collective.recipe.sphinxbuilder 0.7.1 zc.recipe.egg = 1.3.2 -Genshi = 0.6 -collective.recipe.cmd = 0.5 -collective.recipe.template = 1.9 -rjm.recipe.venv = 0.8 - - #Required by: #rjm.recipe.venv 0.8 virtualenv = 1.10 From 05f3997314b3bde3044770a8e6bf97b6408004e5 Mon Sep 17 00:00:00 2001 From: cewing Date: Fri, 2 Jan 2015 17:43:21 -0800 Subject: [PATCH 035/223] cleanup --- source/{main => }/index.rst | 0 source/main/conf.py | 284 -------------- source/{main => }/outline.rst | 0 source/{main => }/readings.rst | 0 source/ui/default/blank.gif | Bin 49 -> 0 bytes source/ui/default/framing.css | 25 -- source/ui/default/iepngfix.htc | 42 --- source/ui/default/opera.css | 8 - source/ui/default/outline.css | 16 - source/ui/default/pretty.css | 120 ------ source/ui/default/print.css | 24 -- source/ui/default/s5-core.css | 11 - source/ui/default/slides.css | 10 - source/ui/default/slides.js | 558 ---------------------------- source/ui/uw_pce_theme/blank.gif | Bin 49 -> 0 bytes source/ui/uw_pce_theme/framing.css | 25 -- source/ui/uw_pce_theme/iepngfix.htc | 42 --- source/ui/uw_pce_theme/opera.css | 8 - source/ui/uw_pce_theme/outline.css | 16 - source/ui/uw_pce_theme/pretty.css | 252 ------------- source/ui/uw_pce_theme/print.css | 24 -- source/ui/uw_pce_theme/s5-core.css | 11 - source/ui/uw_pce_theme/slides.css | 10 - source/ui/uw_pce_theme/slides.js | 558 ---------------------------- 24 files changed, 2044 deletions(-) rename source/{main => }/index.rst (100%) delete mode 100644 source/main/conf.py rename source/{main => }/outline.rst (100%) rename source/{main => }/readings.rst (100%) delete mode 100644 source/ui/default/blank.gif delete mode 100644 source/ui/default/framing.css delete mode 100644 source/ui/default/iepngfix.htc delete mode 100644 source/ui/default/opera.css delete mode 100644 source/ui/default/outline.css delete mode 100644 source/ui/default/pretty.css delete mode 100644 source/ui/default/print.css delete mode 100644 source/ui/default/s5-core.css delete mode 100644 source/ui/default/slides.css delete mode 100644 source/ui/default/slides.js delete mode 100644 source/ui/uw_pce_theme/blank.gif delete mode 100644 source/ui/uw_pce_theme/framing.css delete mode 100644 source/ui/uw_pce_theme/iepngfix.htc delete mode 100644 source/ui/uw_pce_theme/opera.css delete mode 100644 source/ui/uw_pce_theme/outline.css delete mode 100644 source/ui/uw_pce_theme/pretty.css delete mode 100644 source/ui/uw_pce_theme/print.css delete mode 100644 source/ui/uw_pce_theme/s5-core.css delete mode 100644 source/ui/uw_pce_theme/slides.css delete mode 100644 source/ui/uw_pce_theme/slides.js diff --git a/source/main/index.rst b/source/index.rst similarity index 100% rename from source/main/index.rst rename to source/index.rst diff --git a/source/main/conf.py b/source/main/conf.py deleted file mode 100644 index 4c55833d..00000000 --- a/source/main/conf.py +++ /dev/null @@ -1,284 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Internet Programming with Python documentation build configuration file, created by -# sphinx-quickstart on Sat Nov 3 13:22:19 2012. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys, os - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ----------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = [] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'Python Web Programming Workshop' -copyright = u'2012-2013, Cris Ewing' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '1.0' -# The full version, including alpha/beta/rc tags. -release = '1.0' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# -- Options for HTML output --------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'default' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = 'InternetProgrammingwithPythondoc' - - -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', 'InternetProgrammingwithPython.tex', u'Internet Programming with Python Documentation', - u'Cris Ewing', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'internetprogrammingwithpython', u'Internet Programming with Python Documentation', - [u'Cris Ewing'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'InternetProgrammingwithPython', u'Internet Programming with Python Documentation', - u'Cris Ewing', 'InternetProgrammingwithPython', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - - -# -- Options for Epub output --------------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = u'Internet Programming with Python' -epub_author = u'Cris Ewing' -epub_publisher = u'Cris Ewing' -epub_copyright = u'2012, Cris Ewing' - -# The language of the text. It defaults to the language option -# or en if the language is not set. -#epub_language = '' - -# The scheme of the identifier. Typical schemes are ISBN or URL. -#epub_scheme = '' - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -#epub_identifier = '' - -# A unique identification for the text. -#epub_uid = '' - -# A tuple containing the cover image and cover page html template filenames. -#epub_cover = () - -# HTML files that should be inserted before the pages created by sphinx. -# The format is a list of tuples containing the path and title. -#epub_pre_files = [] - -# HTML files shat should be inserted after the pages created by sphinx. -# The format is a list of tuples containing the path and title. -#epub_post_files = [] - -# A list of files that should not be packed into the epub file. -#epub_exclude_files = [] - -# The depth of the table of contents in toc.ncx. -#epub_tocdepth = 3 - -# Allow duplicate toc entries. -#epub_tocdup = True diff --git a/source/main/outline.rst b/source/outline.rst similarity index 100% rename from source/main/outline.rst rename to source/outline.rst diff --git a/source/main/readings.rst b/source/readings.rst similarity index 100% rename from source/main/readings.rst rename to source/readings.rst diff --git a/source/ui/default/blank.gif b/source/ui/default/blank.gif deleted file mode 100644 index 75b945d2553848b8b6f41fe5e24599c0687b8472..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49 zcmZ?wbhEHbWMp7unE0RJ|Ns9C3=9Vj8~~DvKUo+V7?>DzfNY>Fh|Ltj$Y2csQN9XW diff --git a/source/ui/default/framing.css b/source/ui/default/framing.css deleted file mode 100644 index c4727f30..00000000 --- a/source/ui/default/framing.css +++ /dev/null @@ -1,25 +0,0 @@ -/* This file has been placed in the public domain. */ -/* The following styles size, place, and layer the slide components. - Edit these if you want to change the overall slide layout. - The commented lines can be uncommented (and modified, if necessary) - to help you with the rearrangement process. */ - -/* target = 1024x768 */ - -div#header, div#footer, .slide {width: 100%; top: 0; left: 0;} -div#header {position: fixed; top: 0; height: 3em; z-index: 1;} -div#footer {top: auto; bottom: 0; height: 2.5em; z-index: 5;} -.slide {top: 0; width: 92%; padding: 2.5em 4% 4%; z-index: 2;} -div#controls {left: 50%; bottom: 0; width: 50%; z-index: 100;} -div#controls form {position: absolute; bottom: 0; right: 0; width: 100%; - margin: 0;} -#currentSlide {position: absolute; width: 10%; left: 45%; bottom: 1em; - z-index: 10;} -html>body #currentSlide {position: fixed;} - -/* -div#header {background: #FCC;} -div#footer {background: #CCF;} -div#controls {background: #BBD;} -div#currentSlide {background: #FFC;} -*/ diff --git a/source/ui/default/iepngfix.htc b/source/ui/default/iepngfix.htc deleted file mode 100644 index 9f3d628b..00000000 --- a/source/ui/default/iepngfix.htc +++ /dev/null @@ -1,42 +0,0 @@ - - - - - \ No newline at end of file diff --git a/source/ui/default/opera.css b/source/ui/default/opera.css deleted file mode 100644 index c9d1148b..00000000 --- a/source/ui/default/opera.css +++ /dev/null @@ -1,8 +0,0 @@ -/* This file has been placed in the public domain. */ -/* DO NOT CHANGE THESE unless you really want to break Opera Show */ -.slide { - visibility: visible !important; - position: static !important; - page-break-before: always; -} -#slide0 {page-break-before: avoid;} diff --git a/source/ui/default/outline.css b/source/ui/default/outline.css deleted file mode 100644 index fa767e22..00000000 --- a/source/ui/default/outline.css +++ /dev/null @@ -1,16 +0,0 @@ -/* This file has been placed in the public domain. */ -/* Don't change this unless you want the layout stuff to show up in the - outline view! */ - -.layout div, #footer *, #controlForm * {display: none;} -#footer, #controls, #controlForm, #navLinks, #toggle { - display: block; visibility: visible; margin: 0; padding: 0;} -#toggle {float: right; padding: 0.5em;} -html>body #toggle {position: fixed; top: 0; right: 0;} - -/* making the outline look pretty-ish */ - -#slide0 h1, #slide0 h2, #slide0 h3, #slide0 h4 {border: none; margin: 0;} -#toggle {border: 1px solid; border-width: 0 0 1px 1px; background: #FFF;} - -.outline {display: inline ! important;} diff --git a/source/ui/default/pretty.css b/source/ui/default/pretty.css deleted file mode 100644 index 1cede72d..00000000 --- a/source/ui/default/pretty.css +++ /dev/null @@ -1,120 +0,0 @@ -/* This file has been placed in the public domain. */ -/* Following are the presentation styles -- edit away! */ - -html, body {margin: 0; padding: 0;} -body {background: white; color: black;} -/* Replace the background style above with the style below (and again for - div#header) for a graphic: */ -/* background: white url(bodybg.gif) -16px 0 no-repeat; */ -:link, :visited {text-decoration: none; color: #00C;} -#controls :active {color: #88A !important;} -#controls :focus {outline: 1px dotted #227;} -h1, h2, h3, h4 {font-size: 100%; margin: 0; padding: 0; font-weight: inherit;} - -blockquote {padding: 0 2em 0.5em; margin: 0 1.5em 0.5em;} -blockquote p {margin: 0;} - -kbd {font-weight: bold; font-size: 1em;} -sup {font-size: smaller; line-height: 1px;} - -.slide pre {padding: 0; margin-left: 0; margin-right: 0; font-size: 90%;} -.slide ul ul li {list-style: square;} -.slide img.leader {display: block; margin: 0 auto;} -.slide tt {font-size: 90%;} - -div#header, div#footer {background: #005; color: #AAB; font-family: sans-serif;} -/* background: #005 url(bodybg.gif) -16px 0 no-repeat; */ -div#footer {font-size: 0.5em; font-weight: bold; padding: 1em 0;} -#footer h1 {display: block; padding: 0 1em;} -#footer h2 {display: block; padding: 0.8em 1em 0;} - -.slide {font-size: 1.2em;} -.slide h1 {position: absolute; top: 0.45em; z-index: 1; - margin: 0; padding-left: 0.7em; white-space: nowrap; - font: bold 150% sans-serif; color: #DDE; background: #005;} -.slide h2 {font: bold 120%/1em sans-serif; padding-top: 0.5em;} -.slide h3 {font: bold 100% sans-serif; padding-top: 0.5em;} -h1 abbr {font-variant: small-caps;} - -div#controls {position: absolute; left: 50%; bottom: 0; - width: 50%; text-align: right; font: bold 0.9em sans-serif;} -html>body div#controls {position: fixed; padding: 0 0 1em 0; top: auto;} -div#controls form {position: absolute; bottom: 0; right: 0; width: 100%; - margin: 0; padding: 0;} -#controls #navLinks a {padding: 0; margin: 0 0.5em; - background: #005; border: none; color: #779; cursor: pointer;} -#controls #navList {height: 1em;} -#controls #navList #jumplist {position: absolute; bottom: 0; right: 0; - background: #DDD; color: #227;} - -#currentSlide {text-align: center; font-size: 0.5em; color: #449; - font-family: sans-serif; font-weight: bold;} - -#slide0 {padding-top: 1.5em} -#slide0 h1 {position: static; margin: 1em 0 0; padding: 0; color: #000; - font: bold 2em sans-serif; white-space: normal; background: transparent;} -#slide0 h2 {font: bold italic 1em sans-serif; margin: 0.25em;} -#slide0 h3 {margin-top: 1.5em; font-size: 1.5em;} -#slide0 h4 {margin-top: 0; font-size: 1em;} - -ul.urls {list-style: none; display: inline; margin: 0;} -.urls li {display: inline; margin: 0;} -.external {border-bottom: 1px dotted gray;} -html>body .external {border-bottom: none;} -.external:after {content: " \274F"; font-size: smaller; color: #77B;} - -.incremental, .incremental *, .incremental *:after {visibility: visible; - color: white; border: 0;} -img.incremental {visibility: hidden;} -.slide .current {color: green;} - -.slide-display {display: inline ! important;} - -.huge {font-family: sans-serif; font-weight: bold; font-size: 150%;} -.big {font-family: sans-serif; font-weight: bold; font-size: 120%;} -.small {font-size: 75%;} -.tiny {font-size: 50%;} -.huge tt, .big tt, .small tt, .tiny tt {font-size: 115%;} -.huge pre, .big pre, .small pre, .tiny pre {font-size: 115%;} - -.maroon {color: maroon;} -.red {color: red;} -.magenta {color: magenta;} -.fuchsia {color: fuchsia;} -.pink {color: #FAA;} -.orange {color: orange;} -.yellow {color: yellow;} -.lime {color: lime;} -.green {color: green;} -.olive {color: olive;} -.teal {color: teal;} -.cyan {color: cyan;} -.aqua {color: aqua;} -.blue {color: blue;} -.navy {color: navy;} -.purple {color: purple;} -.black {color: black;} -.gray {color: gray;} -.silver {color: silver;} -.white {color: white;} - -.left {text-align: left ! important;} -.center {text-align: center ! important;} -.right {text-align: right ! important;} - -.animation {position: relative; margin: 1em 0; padding: 0;} -.animation img {position: absolute;} - -/* Docutils-specific overrides */ - -.slide table.docinfo {margin: 1em 0 0.5em 2em;} - -pre.literal-block, pre.doctest-block {background-color: white;} - -tt.docutils {background-color: white;} - -/* diagnostics */ -/* -li:after {content: " [" attr(class) "]"; color: #F88;} -div:before {content: "[" attr(class) "]"; color: #F88;} -*/ diff --git a/source/ui/default/print.css b/source/ui/default/print.css deleted file mode 100644 index 9d057cc8..00000000 --- a/source/ui/default/print.css +++ /dev/null @@ -1,24 +0,0 @@ -/* This file has been placed in the public domain. */ -/* The following rule is necessary to have all slides appear in print! - DO NOT REMOVE IT! */ -.slide, ul {page-break-inside: avoid; visibility: visible !important;} -h1 {page-break-after: avoid;} - -body {font-size: 12pt; background: white;} -* {color: black;} - -#slide0 h1 {font-size: 200%; border: none; margin: 0.5em 0 0.25em;} -#slide0 h3 {margin: 0; padding: 0;} -#slide0 h4 {margin: 0 0 0.5em; padding: 0;} -#slide0 {margin-bottom: 3em;} - -#header {display: none;} -#footer h1 {margin: 0; border-bottom: 1px solid; color: gray; - font-style: italic;} -#footer h2, #controls {display: none;} - -.print {display: inline ! important;} - -/* The following rule keeps the layout stuff out of print. - Remove at your own risk! */ -.layout, .layout * {display: none !important;} diff --git a/source/ui/default/s5-core.css b/source/ui/default/s5-core.css deleted file mode 100644 index 6965f5e8..00000000 --- a/source/ui/default/s5-core.css +++ /dev/null @@ -1,11 +0,0 @@ -/* This file has been placed in the public domain. */ -/* Do not edit or override these styles! - The system will likely break if you do. */ - -div#header, div#footer, div#controls, .slide {position: absolute;} -html>body div#header, html>body div#footer, - html>body div#controls, html>body .slide {position: fixed;} -.handout {display: none;} -.layout {display: block;} -.slide, .hideme, .incremental {visibility: hidden;} -#slide0 {visibility: visible;} diff --git a/source/ui/default/slides.css b/source/ui/default/slides.css deleted file mode 100644 index 82bdc0ee..00000000 --- a/source/ui/default/slides.css +++ /dev/null @@ -1,10 +0,0 @@ -/* This file has been placed in the public domain. */ - -/* required to make the slide show run at all */ -@import url(s5-core.css); - -/* sets basic placement and size of slide components */ -@import url(framing.css); - -/* styles that make the slides look good */ -@import url(pretty.css); diff --git a/source/ui/default/slides.js b/source/ui/default/slides.js deleted file mode 100644 index 81e04e5d..00000000 --- a/source/ui/default/slides.js +++ /dev/null @@ -1,558 +0,0 @@ -// S5 v1.1 slides.js -- released into the Public Domain -// Modified for Docutils (http://docutils.sf.net) by David Goodger -// -// Please see http://www.meyerweb.com/eric/tools/s5/credits.html for -// information about all the wonderful and talented contributors to this code! - -var undef; -var slideCSS = ''; -var snum = 0; -var smax = 1; -var slideIDs = new Array(); -var incpos = 0; -var number = undef; -var s5mode = true; -var defaultView = 'slideshow'; -var controlVis = 'visible'; - -var isIE = navigator.appName == 'Microsoft Internet Explorer' ? 1 : 0; -var isOp = navigator.userAgent.indexOf('Opera') > -1 ? 1 : 0; -var isGe = navigator.userAgent.indexOf('Gecko') > -1 && navigator.userAgent.indexOf('Safari') < 1 ? 1 : 0; - -function hasClass(object, className) { - if (!object.className) return false; - return (object.className.search('(^|\\s)' + className + '(\\s|$)') != -1); -} - -function hasValue(object, value) { - if (!object) return false; - return (object.search('(^|\\s)' + value + '(\\s|$)') != -1); -} - -function removeClass(object,className) { - if (!object) return; - object.className = object.className.replace(new RegExp('(^|\\s)'+className+'(\\s|$)'), RegExp.$1+RegExp.$2); -} - -function addClass(object,className) { - if (!object || hasClass(object, className)) return; - if (object.className) { - object.className += ' '+className; - } else { - object.className = className; - } -} - -function GetElementsWithClassName(elementName,className) { - var allElements = document.getElementsByTagName(elementName); - var elemColl = new Array(); - for (var i = 0; i< allElements.length; i++) { - if (hasClass(allElements[i], className)) { - elemColl[elemColl.length] = allElements[i]; - } - } - return elemColl; -} - -function isParentOrSelf(element, id) { - if (element == null || element.nodeName=='BODY') return false; - else if (element.id == id) return true; - else return isParentOrSelf(element.parentNode, id); -} - -function nodeValue(node) { - var result = ""; - if (node.nodeType == 1) { - var children = node.childNodes; - for (var i = 0; i < children.length; ++i) { - result += nodeValue(children[i]); - } - } - else if (node.nodeType == 3) { - result = node.nodeValue; - } - return(result); -} - -function slideLabel() { - var slideColl = GetElementsWithClassName('*','slide'); - var list = document.getElementById('jumplist'); - smax = slideColl.length; - for (var n = 0; n < smax; n++) { - var obj = slideColl[n]; - - var did = 'slide' + n.toString(); - if (obj.getAttribute('id')) { - slideIDs[n] = obj.getAttribute('id'); - } - else { - obj.setAttribute('id',did); - slideIDs[n] = did; - } - if (isOp) continue; - - var otext = ''; - var menu = obj.firstChild; - if (!menu) continue; // to cope with empty slides - while (menu && menu.nodeType == 3) { - menu = menu.nextSibling; - } - if (!menu) continue; // to cope with slides with only text nodes - - var menunodes = menu.childNodes; - for (var o = 0; o < menunodes.length; o++) { - otext += nodeValue(menunodes[o]); - } - list.options[list.length] = new Option(n + ' : ' + otext, n); - } -} - -function currentSlide() { - var cs; - var footer_nodes; - var vis = 'visible'; - if (document.getElementById) { - cs = document.getElementById('currentSlide'); - footer_nodes = document.getElementById('footer').childNodes; - } else { - cs = document.currentSlide; - footer = document.footer.childNodes; - } - cs.innerHTML = '' + snum + '<\/span> ' + - '\/<\/span> ' + - '' + (smax-1) + '<\/span>'; - if (snum == 0) { - vis = 'hidden'; - } - cs.style.visibility = vis; - for (var i = 0; i < footer_nodes.length; i++) { - if (footer_nodes[i].nodeType == 1) { - footer_nodes[i].style.visibility = vis; - } - } -} - -function go(step) { - if (document.getElementById('slideProj').disabled || step == 0) return; - var jl = document.getElementById('jumplist'); - var cid = slideIDs[snum]; - var ce = document.getElementById(cid); - if (incrementals[snum].length > 0) { - for (var i = 0; i < incrementals[snum].length; i++) { - removeClass(incrementals[snum][i], 'current'); - removeClass(incrementals[snum][i], 'incremental'); - } - } - if (step != 'j') { - snum += step; - lmax = smax - 1; - if (snum > lmax) snum = lmax; - if (snum < 0) snum = 0; - } else - snum = parseInt(jl.value); - var nid = slideIDs[snum]; - var ne = document.getElementById(nid); - if (!ne) { - ne = document.getElementById(slideIDs[0]); - snum = 0; - } - if (step < 0) {incpos = incrementals[snum].length} else {incpos = 0;} - if (incrementals[snum].length > 0 && incpos == 0) { - for (var i = 0; i < incrementals[snum].length; i++) { - if (hasClass(incrementals[snum][i], 'current')) - incpos = i + 1; - else - addClass(incrementals[snum][i], 'incremental'); - } - } - if (incrementals[snum].length > 0 && incpos > 0) - addClass(incrementals[snum][incpos - 1], 'current'); - ce.style.visibility = 'hidden'; - ne.style.visibility = 'visible'; - jl.selectedIndex = snum; - currentSlide(); - number = 0; -} - -function goTo(target) { - if (target >= smax || target == snum) return; - go(target - snum); -} - -function subgo(step) { - if (step > 0) { - removeClass(incrementals[snum][incpos - 1],'current'); - removeClass(incrementals[snum][incpos], 'incremental'); - addClass(incrementals[snum][incpos],'current'); - incpos++; - } else { - incpos--; - removeClass(incrementals[snum][incpos],'current'); - addClass(incrementals[snum][incpos], 'incremental'); - addClass(incrementals[snum][incpos - 1],'current'); - } -} - -function toggle() { - var slideColl = GetElementsWithClassName('*','slide'); - var slides = document.getElementById('slideProj'); - var outline = document.getElementById('outlineStyle'); - if (!slides.disabled) { - slides.disabled = true; - outline.disabled = false; - s5mode = false; - fontSize('1em'); - for (var n = 0; n < smax; n++) { - var slide = slideColl[n]; - slide.style.visibility = 'visible'; - } - } else { - slides.disabled = false; - outline.disabled = true; - s5mode = true; - fontScale(); - for (var n = 0; n < smax; n++) { - var slide = slideColl[n]; - slide.style.visibility = 'hidden'; - } - slideColl[snum].style.visibility = 'visible'; - } -} - -function showHide(action) { - var obj = GetElementsWithClassName('*','hideme')[0]; - switch (action) { - case 's': obj.style.visibility = 'visible'; break; - case 'h': obj.style.visibility = 'hidden'; break; - case 'k': - if (obj.style.visibility != 'visible') { - obj.style.visibility = 'visible'; - } else { - obj.style.visibility = 'hidden'; - } - break; - } -} - -// 'keys' code adapted from MozPoint (http://mozpoint.mozdev.org/) -function keys(key) { - if (!key) { - key = event; - key.which = key.keyCode; - } - if (key.which == 84) { - toggle(); - return; - } - if (s5mode) { - switch (key.which) { - case 10: // return - case 13: // enter - if (window.event && isParentOrSelf(window.event.srcElement, 'controls')) return; - if (key.target && isParentOrSelf(key.target, 'controls')) return; - if(number != undef) { - goTo(number); - break; - } - case 32: // spacebar - case 34: // page down - case 39: // rightkey - case 40: // downkey - if(number != undef) { - go(number); - } else if (!incrementals[snum] || incpos >= incrementals[snum].length) { - go(1); - } else { - subgo(1); - } - break; - case 33: // page up - case 37: // leftkey - case 38: // upkey - if(number != undef) { - go(-1 * number); - } else if (!incrementals[snum] || incpos <= 0) { - go(-1); - } else { - subgo(-1); - } - break; - case 36: // home - goTo(0); - break; - case 35: // end - goTo(smax-1); - break; - case 67: // c - showHide('k'); - break; - } - if (key.which < 48 || key.which > 57) { - number = undef; - } else { - if (window.event && isParentOrSelf(window.event.srcElement, 'controls')) return; - if (key.target && isParentOrSelf(key.target, 'controls')) return; - number = (((number != undef) ? number : 0) * 10) + (key.which - 48); - } - } - return false; -} - -function clicker(e) { - number = undef; - var target; - if (window.event) { - target = window.event.srcElement; - e = window.event; - } else target = e.target; - if (target.href != null || hasValue(target.rel, 'external') || isParentOrSelf(target, 'controls') || isParentOrSelf(target,'embed') || isParentOrSelf(target, 'object')) return true; - if (!e.which || e.which == 1) { - if (!incrementals[snum] || incpos >= incrementals[snum].length) { - go(1); - } else { - subgo(1); - } - } -} - -function findSlide(hash) { - var target = document.getElementById(hash); - if (target) { - for (var i = 0; i < slideIDs.length; i++) { - if (target.id == slideIDs[i]) return i; - } - } - return null; -} - -function slideJump() { - if (window.location.hash == null || window.location.hash == '') { - currentSlide(); - return; - } - if (window.location.hash == null) return; - var dest = null; - dest = findSlide(window.location.hash.slice(1)); - if (dest == null) { - dest = 0; - } - go(dest - snum); -} - -function fixLinks() { - var thisUri = window.location.href; - thisUri = thisUri.slice(0, thisUri.length - window.location.hash.length); - var aelements = document.getElementsByTagName('A'); - for (var i = 0; i < aelements.length; i++) { - var a = aelements[i].href; - var slideID = a.match('\#.+'); - if ((slideID) && (slideID[0].slice(0,1) == '#')) { - var dest = findSlide(slideID[0].slice(1)); - if (dest != null) { - if (aelements[i].addEventListener) { - aelements[i].addEventListener("click", new Function("e", - "if (document.getElementById('slideProj').disabled) return;" + - "go("+dest+" - snum); " + - "if (e.preventDefault) e.preventDefault();"), true); - } else if (aelements[i].attachEvent) { - aelements[i].attachEvent("onclick", new Function("", - "if (document.getElementById('slideProj').disabled) return;" + - "go("+dest+" - snum); " + - "event.returnValue = false;")); - } - } - } - } -} - -function externalLinks() { - if (!document.getElementsByTagName) return; - var anchors = document.getElementsByTagName('a'); - for (var i=0; i' + - '