diff options
| author | Michael Merickel <michael@merickel.org> | 2016-02-18 02:39:43 -0600 |
|---|---|---|
| committer | Michael Merickel <michael@merickel.org> | 2016-02-18 02:39:43 -0600 |
| commit | 5dc1c80046b7eb83fb7c51105bed0e73b2ab759c (patch) | |
| tree | a66d8ee8f8282a72a5ada4fc5d903486caac6a85 /docs/tutorials | |
| parent | 3eb1c354d320536ee470b79dcb930d20da93d97d (diff) | |
| parent | 66fabb4ac707b5b4289db0094756f1a1af7269cc (diff) | |
| download | pyramid-5dc1c80046b7eb83fb7c51105bed0e73b2ab759c.tar.gz pyramid-5dc1c80046b7eb83fb7c51105bed0e73b2ab759c.tar.bz2 pyramid-5dc1c80046b7eb83fb7c51105bed0e73b2ab759c.zip | |
Merge pull request #2334 from mmerickel/feature/alchemy-scaffold-update-tweaks
object-level security and tons of other small improvements to the wiki2 tutorial
Diffstat (limited to 'docs/tutorials')
135 files changed, 3583 insertions, 2147 deletions
diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst new file mode 100644 index 000000000..1b18e5c55 --- /dev/null +++ b/docs/tutorials/wiki2/authentication.rst @@ -0,0 +1,296 @@ +.. _wiki2_adding_authentication: + +===================== +Adding authentication +===================== + +:app:`Pyramid` provides facilities for :term:`authentication` and +:term:`authorization`. In this section we'll focus solely on the +authentication APIs to add login/logout functionality to our wiki. + +We will implement authentication with the following steps: + +* Add an :term:`authentication policy` and a ``request.user`` computed + property (``security.py``). +* Add routes for /login and /logout (``routes.py``). +* Add login and logout views (``views/auth.py``). +* Add a login template (``login.jinja2``). +* Add "Login" and "Logout" links to every page based on the user's + authenticated state (``layout.jinja2``). +* Make the existing views verify user state (``views/default.py``). +* Redirect to /login when a user is denied access to any of the views + that require permission, instead of a default "403 Forbidden" page + (``views/auth.py``). + +Authenticating requests +----------------------- + +The core of :app:`Pyramid` authentication is a :term:`authentication policy` +which is used to identify authentication information from a ``request``, +as well as handling the low-level login/logout operations required to +track users across requests (via cookies or headers or whatever else you can +imagine). + +Add the authentication policy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create a new file ``tutorial/security.py``: + +.. literalinclude:: src/authentication/tutorial/security.py + :linenos: + :emphasize-lines: 9,14,21-27 + :language: python + +Here we've defined: + +* A new authentication policy named ``MyAuthenticationPolicy`` which is + subclassed from pyramid's + :class:`pyramid.authentication.AuthTktAuthenticationPolicy` which tracks + the :term:`userid` using a signed cookie. +* A ``get_user`` function which can convert the ``unauthenticated_userid`` + from the policy into a ``User`` object from our database. +* The ``get_user`` is registered on the request as ``request.user`` + to be used throughout our application as the authenticated ``User`` object + for the logged-in user. + +The logic in this file is a little bit interesting and so we'll go into +detail about what's happening here: + +First, the default authentication policies all provide a method named +``unauthenticated_userid`` which is responsible for the low-level parsing +of the information in the request (cookies, headers, etc). If a ``userid`` +is found then it is returned from this method. This is named +``unauthenticated_userid`` because at the lowest level it knows the value of +the userid in the cookie but it doesn't know if it's actually a user in our +system (remember, anything the user sends to our app is untrusted). + +Second, our application should only care about ``authenticated_userid`` and +``request.user`` which have gone through our application-specific process of +validating that the user is logged-in. + +In order to provide an ``authenticated_userid`` we need a verification step. +That can happen anywhere, so we've elected to do it inside of the cached +``request.user`` computed property. This is a convenience that makes +``request.user`` the source of truth in our system. It is either ``None`` or +a ``User`` object from our database. This is why the ``get_user`` function +uses the ``unauthenticated_userid`` to check the database + +Configure the app +~~~~~~~~~~~~~~~~~ + +Since we've added a new ``tutorial/security.py`` module we need to include it. + +Open the file ``tutorial/__init__.py`` and edit the following lines: + +.. literalinclude:: src/authentication/tutorial/__init__.py + :linenos: + :emphasize-lines: 11 + :language: python + +Our authentication policy is expecting a new setting, ``auth.secret``. Open +the file ``development.ini`` and add the highlighted line below: + +.. literalinclude:: src/authentication/development.ini + :lines: 18-20 + :emphasize-lines: 3 + :lineno-match: + :language: ini + +Finally best-practices tell us to use a different secret for production so +open ``production.ini`` and add a different secret: + +.. literalinclude:: src/authentication/production.ini + :lines: 15-17 + :emphasize-lines: 3 + :lineno-match: + :language: ini + +Add permission checks +~~~~~~~~~~~~~~~~~~~~~ + +:app:`Pyramid` has full support for declarative authorization which we'll +cover in the next chapter. However many people looking to get their feet +wet are just interested in authentication with some basic form of +home-grown authorization. We'll show below how to accomplish the simple +security goals of our wiki now that we can track the logged-in state of users. + +Remember our goals: + +* Allow only ``editor`` and ``basic`` logged-in users to create new pages. +* Only allow ``editor`` users and the page creator (possibly a ``basic`` user) + to edit pages. + +Open the file ``tutorial/views/default.py`` and fix the following imports: + +.. literalinclude:: src/authentication/tutorial/views/default.py + :lines: 5-13 + :lineno-match: + :emphasize-lines: 2,9 + :language: python + +Only the highlighted lines need to be changed. + +Now edit the ``add_page`` view function: + +.. literalinclude:: src/authentication/tutorial/views/default.py + :lines: 62-76 + :lineno-match: + :emphasize-lines: 3-5,10 + :language: python + +Only the highlighted lines need to be changed. + +If the user is not logged in or is not in the ``basic`` or ``editor`` roles +then we raise ``HTTPForbidden`` which will return a "403 Forbidden" response +to the user. However we hook this later to redirect to the login page. Also, +now that we have ``request.user`` we no longer have to hard-code the creator +as the ``editor`` user so we can finally drop that hack. + +Now edit the ``edit_page`` view function: + +.. literalinclude:: src/authentication/tutorial/views/default.py + :lines: 45-60 + :lineno-match: + :emphasize-lines: 5-7 + :language: python + +Only the highlighted lines need to be changed. + +If the user is not logged in or the user is not the page's creator **and** +not an ``editor`` then we raise ``HTTPForbidden``. + +These simple checks should protect our views. + +Login, logout +------------- + +Now that we've got the ability to detect logged-in users, we need to +add the /login and /logout views so that they can actually login! + +Add routes for /login and /logout +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Go back to ``tutorial/routes.py`` and add these two routes as highlighted: + +.. literalinclude:: src/authentication/tutorial/routes.py + :lines: 3-6 + :lineno-match: + :emphasize-lines: 2-3 + :language: python + +.. note:: The preceding lines must be added *before* the following + ``view_page`` route definition: + + .. literalinclude:: src/authentication/tutorial/routes.py + :lines: 6 + :language: python + + This is because ``view_page``'s route definition uses a catch-all + "replacement marker" ``/{pagename}`` (see :ref:`route_pattern_syntax`) + which will catch any route that was not already caught by any route + registered before it. Hence, for ``login`` and ``logout`` views to + have the opportunity of being matched (or "caught"), they must be above + ``/{pagename}``. + +Add login, logout and forbidden views +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create a new file ``tutorial/views/auth.py`` where we will add the following +code: + +.. literalinclude:: src/authentication/tutorial/views/auth.py + :linenos: + :language: python + +This code adds 3 new views to application: + +- The ``login`` view renders a login form and processes the post from the + login form, checking credentials against our ``users`` table in the database. + + The check is done by first finding a ``User`` record in the database and + then using our ``user.check_password`` method to compare the passwords. + + If the credentials are valid then we use our authentication policy to + store the user's id in the response using :meth:`pyramid.security.remember`. + + Finally, the user is redirected back to the page they were trying to access + (``next``) or the front page as a fallback. This parameter is used by + our forbidden view as explained below to finish the login workflow. + +- The ``logout`` view handles requests to /logout by clearing the credentials + using :meth:`pyramid.security.forget` and then redirecting them to the front + page. + +- The ``forbidden_view`` is registered using the + :class:`pyramid.view.forbidden_view_config` decorator. This is a special + :term:`exception view` which is invoked when a + :class:`pyramid.httpexceptions.HTTPForbidden` exception is raised. + + This view will handle a forbidden error by redirecting the user to /login. + As a convenience it also sets the ``next=`` query string to the current url + (the one that is forbidding access). This way if the user successfully logs + in they will be sent back to the page they had been trying to access. + +Add the ``login.jinja2`` template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create ``tutorial/templates/login.jinja2`` with the following content: + +.. literalinclude:: src/authentication/tutorial/templates/login.jinja2 + :language: html + +The above template is referenced in the login view that we just added in +``tutorial/views/auth.py``. + +Add a "Login" and "Logout" links +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Open ``tutorial/templates/layout.jinja2`` and add the following code as +indicated by the highlighted lines. + +.. literalinclude:: src/authentication/tutorial/templates/layout.jinja2 + :lines: 35-46 + :lineno-match: + :emphasize-lines: 2-10 + :language: html + +The ``request.user`` will be ``None`` if the user is not authenticated, or a +``tutorial.models.User`` object if the user is authenticated. This +check will make the logout link active only when the user is logged in and +vice versa the login link is only active when the user is logged out. + +Viewing the application in a browser +------------------------------------ + +We can finally examine our application in a browser (See +:ref:`wiki2-start-the-application`). Launch a browser and visit each of the +following URLs, checking that the result is as expected: + +- http://localhost:6543/ invokes the ``view_wiki`` view. This always + redirects to the ``view_page`` view of the ``FrontPage`` page object. It + is executable by any user. + +- http://localhost:6543/FrontPage invokes the ``view_page`` view of the + ``FrontPage`` page object. There is a "Login" link in the upper right corner. + +- http://localhost:6543/FrontPage/edit_page invokes the edit view for the + FrontPage object. It is executable by only the ``editor`` user. If a + different user (or the anonymous user) invokes it, a login form will be + displayed. Supplying the credentials with the username ``editor``, password + ``editor`` will display the edit page form. + +- http://localhost:6543/add_page/SomePageName invokes the add view for a page. + It is executable by the ``editor`` or ``basic`` user. If a different user + (or the anonymous user) invokes it, a login form will be displayed. Supplying + the credentials with the username ``basic``, password ``basic`` will display + the edit page form. + +- http://localhost:6543/SomePageName/edit_page is editable by the ``basic`` + if the page was created by that user in the previous step. If, instead, the + page was created by ``editor`` then the login page should be shown for the + ``basic`` user. + +- After logging in (as a result of hitting an edit or add page and submitting + the login form with the ``editor`` credentials), we'll see a Logout link in + the upper right hand corner. When we click it, we're logged out, and + redirected back to the front page. diff --git a/docs/tutorials/wiki2/authorization.rst b/docs/tutorials/wiki2/authorization.rst index e40433497..eb9269dff 100644 --- a/docs/tutorials/wiki2/authorization.rst +++ b/docs/tutorials/wiki2/authorization.rst @@ -4,378 +4,217 @@ Adding authorization ==================== -:app:`Pyramid` provides facilities for :term:`authentication` and -:term:`authorization`. We'll make use of both features to provide security -to our application. Our application currently allows anyone with access to -the server to view, edit, and add pages to our wiki. We'll change that to -allow only people who are members of a *group* named ``group:editors`` to add -and edit wiki pages but we'll continue allowing anyone with access to the -server to view pages. - -We will also add a login page and a logout link on all the pages. The login -page will be shown when a user is denied access to any of the views that -require permission, instead of a default "403 Forbidden" page. - -We will implement the access control with the following steps: - -* Add users and groups (``security/default.py``, a new subpackage). -* Add an :term:`ACL` (``models/mymodel.py`` and ``__init__.py``). -* Add an :term:`authentication policy` and an :term:`authorization policy` - (``__init__.py``). -* Add :term:`permission` declarations to the ``edit_page`` and ``add_page`` - views (``views/default.py``). - -Then we will add the login and logout feature: - -* Add routes for /login and /logout (``__init__.py``). -* Add ``login`` and ``logout`` views (``views/default.py``). -* Add a login template (``login.jinja2``). -* Make the existing views return a ``logged_in`` flag to the renderer +In the last chapter we built :term:`authentication` into our wiki2. We also +went one step further and used the ``request.user`` object to perform some explicit :term:`authorization` checks. This is fine for a lot of +applications but :app:`Pyramid` provides some facilities for cleaning this +up and decoupling the constraints from the view function itself. + +We will implement access control with the following steps: + +* Update the :term:`authentication policy` to break down the + :term:`userid` into a list of :term:`principals <principal>` + (``security.py``). +* Define an :term:`authorization policy` for mapping users, resources and + permissions (``security.py``). +* Add new :term:`resource` definitions that will be used as the + :term:`context` for the wiki pages (``routes.py``). +* Add an :term:`ACL` to each resource (``routes.py``). +* Replace the inline checks on the views with :term:`permission` declarations (``views/default.py``). -* Add a "Logout" link to be shown when logged in and viewing or editing a page - (``view.jinja2``, ``edit.jinja2``). +Add user principals +------------------- -Access control --------------- +A :term:`principal` is a level of abstraction on top of the raw +:term:`userid` that describes the user in terms of capabilities, roles or +other identifiers that are easier to generalize. The permissions are then +written against the principals without focusing on the exact user involved. -Add users and groups -~~~~~~~~~~~~~~~~~~~~ +:app:`Pyramid` defines two builtin principals used in every application: +:attr:`pyramid.security.Everyone` and :attr:`pyramid.security.Authenticated`. +On top of these we have already mentioned the required principals for this +application in the original design. The user has two possible roles: +``editor`` and ``basic``. These will be prefixed by the ``role:`` +string to avoid clasing with any other types of principals. -Create a new ``tutorial/tutorial/security/default.py`` subpackage with the -following content: +Open the file ``tutorial/security.py`` and edit the following lines: -.. literalinclude:: src/authorization/tutorial/security/default.py +.. literalinclude:: src/authorization/tutorial/security.py :linenos: + :emphasize-lines: 3-6,17-24 :language: python -The ``groupfinder`` function accepts a userid and a request and -returns one of these values: - -- If the userid exists in the system, it will return a sequence of group - identifiers (or an empty sequence if the user isn't a member of any groups). -- If the userid *does not* exist in the system, it will return ``None``. - -For example, ``groupfinder('editor', request )`` returns ``['group:editor']``, -``groupfinder('viewer', request)`` returns ``[]``, and ``groupfinder('admin', -request)`` returns ``None``. We will use ``groupfinder()`` as an -:term:`authentication policy` "callback" that will provide the -:term:`principal` or principals for a user. +Only the highlighted lines need to be added. -In a production system, user and group data will most often come from a -database, but here we use "dummy" data to represent user and groups sources. +Note that the role comes from the ``User`` object and finally we also +add the ``user.id`` as a principal for when we want to allow that exact +user to edit page's they've created. -Add an ACL -~~~~~~~~~~ +Add the authorization policy +---------------------------- -Open ``tutorial/tutorial/models/mymodel.py`` and add the following import -statement just after the ``Base`` import at the top: +We already added the :term:`authorization policy` in the previous chapter +because :app:`Pyramid` requires one when adding an +:term:`authentication policy`. However, it was not used anywhere and so we'll +mention it now. -.. literalinclude:: src/authorization/tutorial/models/mymodel.py - :lines: 3-6 - :linenos: - :lineno-start: 3 - :language: python +Open the file ``tutorial/security.py`` and notice the following lines: -Add the following class definition at the end: - -.. literalinclude:: src/authorization/tutorial/models/mymodel.py - :lines: 22-26 - :linenos: - :lineno-start: 22 +.. literalinclude:: src/authorization/tutorial/security.py + :lines: 38-40 + :lineno-match: + :emphasize-lines: 2 :language: python -We import :data:`~pyramid.security.Allow`, an action that means that -permission is allowed, and :data:`~pyramid.security.Everyone`, a special -:term:`principal` that is associated to all requests. Both are used in the -:term:`ACE` entries that make up the ACL. - -The ACL is a list that needs to be named `__acl__` and be an attribute of a -class. We define an :term:`ACL` with two :term:`ACE` entries. The first entry -allows any user (``Everyone``) the `view` permission. The second entry allows -the ``group:editors`` principal the `edit` permission. +We're using the :class:`pyramid.authorization.ACLAuthorizationPolicy` which +will suffice for most applications. It uses the :term:`context` to define +the mapping between a :term:`principal` and :term:`permission` for the +current request via the ``__acl__``. -The ``RootFactory`` class that contains the ACL is a :term:`root factory`. We -need to associate it to our :app:`Pyramid` application, so the ACL is provided -to each view in the :term:`context` of the request as the ``context`` -attribute. +Add resources and ACLs +---------------------- -Open ``tutorial/tutorial/__init__.py`` and add a ``root_factory`` parameter to -our :term:`Configurator` constructor, that points to the class we created -above: +Resources are the hidden gem of :app:`Pyramid`. You've made it! -.. literalinclude:: src/authorization/tutorial/__init__.py - :lines: 13-14 - :emphasize-lines: 2 - :language: python +Every URL in a web application is representing a :term:`resource` +(the **R** in Uniform Resource Locator). Often the resource is something +in your data model but it could also be an abstraction over many models. -Only the highlighted line needs to be added. +Our wiki has two resources: -We are now providing the ACL to the application. See :ref:`assigning_acls` -for more information about what an :term:`ACL` represents. +#. A ``PageResource``. Represents a ``Page`` that is to be viewed or edited. + Only ``editor`` users as well as the original creator of the ``Page`` + may edit the ``PageResource`` but anyone may view it. -.. note:: Although we don't use the functionality here, the ``factory`` used - to create route contexts may differ per-route as opposed to globally. See - the ``factory`` argument to :meth:`pyramid.config.Configurator.add_route` - for more info. +#. A ``NewPage``. Represents a potential ``Page`` that does not exist. + Any logged-in user (roles ``basic`` or ``editor``) can create pages. -Add authentication and authorization policies -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. note:: -Open ``tutorial/tutorial/__init__.py`` and add the highlighted import -statements: + The wiki data model is simple enough that the ``PageResource`` is + actually mostly redundant with our ``models.Page`` SQLAlchemy class. It is + completely valid to combine these into one class. However, for this + tutorial they are explicitly separated to make it clear the + parts that :app:`Pyramid` cares about versus application-defined objects. -.. literalinclude:: src/authorization/tutorial/__init__.py - :lines: 1-5 - :linenos: - :emphasize-lines: 2-5 - :language: python +There are many ways to define these resources, and they can even be grouped +into collections with a hierarchy. However, we're keeping it simple here! -Now add those policies to the configuration: +Open the file ``tutorial/routes.py`` and edit the following lines: -.. literalinclude:: src/authorization/tutorial/__init__.py - :lines: 7-16 +.. literalinclude:: src/authorization/tutorial/routes.py :linenos: - :lineno-start: 7 - :emphasize-lines: 4-6,9-10 + :emphasize-lines: 1-7,14-50 :language: python -Only the highlighted lines need to be added. - -We are enabling an ``AuthTktAuthenticationPolicy``, which is based in an auth -ticket that may be included in the request. We are also enabling an -``ACLAuthorizationPolicy``, which uses an ACL to determine the *allow* or -*deny* outcome for a view. - -Note that the :class:`pyramid.authentication.AuthTktAuthenticationPolicy` -constructor accepts two arguments: ``secret`` and ``callback``. ``secret`` is -a string representing an encryption key used by the "authentication ticket" -machinery represented by this policy; it is required. The ``callback`` is the -``groupfinder()`` function that we created before. - - -Add permission declarations -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The highlighted lines need to be edited or added. -Open ``tutorial/tutorial/views/default.py`` and add a ``permission='view'`` -parameter to the ``@view_config`` decorator for ``view_wiki()`` and -``view_page()`` as follows: +The ``NewPage`` has an ``__acl__`` on it that returns a list of +mappings from :term:`principal` to :term:`permission`. This defines **who** +can do **what** with that :term:`resource`. In our case we want to only +allow users with the principals ``role:editor`` and ``role:basic`` to +have the ``create`` permission: -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 27-29 - :emphasize-lines: 1-2 +.. literalinclude:: src/authorization/tutorial/routes.py + :lines: 20-32 + :lineno-match: + :emphasize-lines: 11,12 :language: python -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 33-35 - :emphasize-lines: 1-2 - :language: python - -Only the highlighted lines, along with their preceding commas, need to be -edited and added. - -This allows anyone to invoke these two views. - -Add a ``permission='edit'`` parameter to the ``@view_config`` decorators for -``add_page()`` and ``edit_page()``: - -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 57-59 - :emphasize-lines: 1-2 - :language: python +The ``NewPage`` is loaded as the :term:`context` of the ``add_page`` +route by declaring a ``factory`` on the route: -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 72-74 - :emphasize-lines: 1-2 +.. literalinclude:: src/authorization/tutorial/routes.py + :lines: 15-16 + :lineno-match: + :emphasize-lines: 2 :language: python -Only the highlighted lines, along with their preceding commas, need to be -edited and added. - -The result is that only users who possess the ``edit`` permission at the time -of the request may invoke those two views. - -We are done with the changes needed to control access. The changes that -follow will add the login and logout feature. - -Login, logout -------------- +The ``PageResource`` defines the :term:`ACL` for a ``Page``. It uses an +actual ``Page`` object to determine **who** can do **what** to the page. -Add routes for /login and /logout -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Go back to ``tutorial/tutorial/__init__.py`` and add these two routes as -highlighted: - -.. literalinclude:: src/authorization/tutorial/__init__.py - :lines: 20-23 - :emphasize-lines: 2-3 +.. literalinclude:: src/authorization/tutorial/routes.py + :lines: 34-50 + :lineno-match: + :emphasize-lines: 14-16 :language: python -.. note:: The preceding lines must be added *before* the following - ``view_page`` route definition: - - .. literalinclude:: src/authorization/tutorial/__init__.py - :lines: 23 - :language: python +The ``PageResource`` is loaded as the :term:`context` of the ``view_page`` +and ``edit_page`` route by declaring a ``factory`` on the routes: - This is because ``view_page``'s route definition uses a catch-all - "replacement marker" ``/{pagename}`` (see :ref:`route_pattern_syntax`) - which will catch any route that was not already caught by any route listed - above it in ``__init__.py``. Hence, for ``login`` and ``logout`` views to - have the opportunity of being matched (or "caught"), they must be above - ``/{pagename}``. +.. literalinclude:: src/authorization/tutorial/routes.py + :lines: 14-18 + :lineno-match: + :emphasize-lines: 1,4-5 + :language: python -Add login and logout views -~~~~~~~~~~~~~~~~~~~~~~~~~~ +Add view permissions +-------------------- -We'll add a ``login`` view which renders a login form and processes the post -from the login form, checking credentials. +At this point we've modified our application to load the ``PageResource``, +including the actual ``Page`` model in the ``page_factory``. The +``PageResource`` is now the :term:`context` for all ``view_page`` and +``edit_page`` views. Similarly the ``NewPage`` will be the context for +the ``add_page`` view. -We'll also add a ``logout`` view callable to our application and provide a -link to it. This view will clear the credentials of the logged in user and -redirect back to the front page. +Open the file ``views/default.py``. -Add the following import statements to ``tutorial/tutorial/views/default.py`` -after the import from ``pyramid.httpexceptions``: +First, you can drop a few imports that are no longer necessary: .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 10-20 - :emphasize-lines: 1-11 + :lines: 5-7 + :lineno-match: + :emphasize-lines: 1 :language: python -All the highlighted lines need to be added or edited. - -:meth:`~pyramid.view.forbidden_view_config` will be used to customize the -default 403 Forbidden page. :meth:`~pyramid.security.remember` and -:meth:`~pyramid.security.forget` help to create and expire an auth ticket -cookie. - -Now add the ``login`` and ``logout`` views at the end of the file: +Edit the ``view_page`` view to declare the ``view`` permission and remove +the explicit checks within the view: .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 88-121 + :lines: 18-23 + :lineno-match: + :emphasize-lines: 2,4 :language: python -``login()`` has two decorators: - -- a ``@view_config`` decorator which associates it with the ``login`` route - and makes it visible when we visit ``/login``, and -- a ``@forbidden_view_config`` decorator which turns it into a - :term:`forbidden view`. ``login()`` will be invoked when a user tries to - execute a view callable for which they lack authorization. For example, if - a user has not logged in and tries to add or edit a wiki page, they will be - shown the login form before being allowed to continue. - -The order of these two :term:`view configuration` decorators is unimportant. - -``logout()`` is decorated with a ``@view_config`` decorator which associates -it with the ``logout`` route. It will be invoked when we visit ``/logout``. - -Add the ``login.jinja2`` template -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Create ``tutorial/tutorial/templates/login.jinja2`` with the following content: - -.. literalinclude:: src/authorization/tutorial/templates/login.jinja2 - :language: html - -The above template is referenced in the login view that we just added in -``views/default.py``. - -Return a ``logged_in`` flag to the renderer -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The work of loading the page has already been done in the factory so we +can just pull the ``page`` object out of the ``PageResource`` loaded as +``request.context``. Our factory also guarantees we will have a ``Page`` as it +raises ``HTTPNotFound`` otherwise - again simplifying the view logic. -Open ``tutorial/tutorial/views/default.py`` again. Add a ``logged_in`` -parameter to the return value of ``view_page()``, ``add_page()``, and -``edit_page()`` as follows: +Edit the ``edit_page`` view to declare the ``edit`` permission: .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 54-55 - :emphasize-lines: 1-2 + :lines: 38-42 + :lineno-match: + :emphasize-lines: 2,4 :language: python -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 69-70 - :emphasize-lines: 1-2 - :language: python +Edit the ``add_page`` view to declare the ``create`` permission: .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 82-86 - :emphasize-lines: 3-4 + :lines: 52-56 + :lineno-match: + :emphasize-lines: 2,4 :language: python -Only the highlighted lines need to be added or edited. - -The :meth:`pyramid.request.Request.authenticated_userid` will be ``None`` if -the user is not authenticated, or a userid if the user is authenticated. - -Add a "Logout" link when logged in -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Open ``tutorial/tutorial/templates/edit.jinja2`` and -``tutorial/tutorial/templates/view.jinja2`` and add the following code as -indicated by the highlighted lines. +Note the ``pagename`` here is pulled off of the context instead of +``request.matchdict``. The factory has done a lot of work for us to hide the +actual route pattern. -.. literalinclude:: src/authorization/tutorial/templates/edit.jinja2 - :lines: 34-40 - :emphasize-lines: 3-7 - :language: html +The ACLs defined on each :term:`resource` are used by the +:term:`authorization policy` to determine if any +:term:`principal` is allowed to have some :term:`permission`. If this check +fails (for example, the user is not logged in) then a ``HTTPForbidden`` +exception will be raised automatically, thus we're able to drop those +exceptions and checks from the views themselves. Rather we've defined them in +terms of operations on a resource. -The attribute ``logged_in`` will make the element be included when -``logged_in`` is any user id. The link will invoke the logout view. The above -element will not be included if ``logged_in`` is ``None``, such as when a user -is not authenticated. - -Reviewing our changes ---------------------- - -Our ``tutorial/tutorial/__init__.py`` will look like this when we're done: - -.. literalinclude:: src/authorization/tutorial/__init__.py - :linenos: - :emphasize-lines: 2-3,5,10-12,14-16,21-22 - :language: python - -Only the highlighted lines need to be added or edited. - -Our ``tutorial/tutorial/models/mymodel.py`` will look like this when we're done: - -.. literalinclude:: src/authorization/tutorial/models/mymodel.py - :linenos: - :emphasize-lines: 3-6,22-26 - :language: python - -Only the highlighted lines need to be added or edited. - -Our ``tutorial/tutorial/views/default.py`` will look like this when we're done: +The final ``tutorial/views/default.py`` should look like the following: .. literalinclude:: src/authorization/tutorial/views/default.py :linenos: - :emphasize-lines: 10-20,27-28,33-34,54-55,57-58,69-70,72-73,84-85,88-121 :language: python -Only the highlighted lines need to be added or edited. - -Our ``tutorial/tutorial/templates/edit.jinja2`` template will look like this when -we're done: - -.. literalinclude:: src/authorization/tutorial/templates/edit.jinja2 - :linenos: - :emphasize-lines: 36-40 - :language: html - -Only the highlighted lines need to be added or edited. - -Our ``tutorial/tutorial/templates/view.jinja2`` template will look like this when -we're done: - -.. literalinclude:: src/authorization/tutorial/templates/view.jinja2 - :linenos: - :emphasize-lines: 36-40 - :language: html - -Only the highlighted lines need to be added or edited. - Viewing the application in a browser ------------------------------------ diff --git a/docs/tutorials/wiki2/basiclayout.rst b/docs/tutorials/wiki2/basiclayout.rst index e3d0a0a3c..1ae51eb93 100644 --- a/docs/tutorials/wiki2/basiclayout.rst +++ b/docs/tutorials/wiki2/basiclayout.rst @@ -16,29 +16,30 @@ package. We use ``__init__.py`` both as a marker, indicating the directory in which it's contained is a package, and to contain application configuration code. -Open ``tutorial/tutorial/__init__.py``. It should already contain the +Open ``tutorial/__init__.py``. It should already contain the following: - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :linenos: - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :linenos: + :language: py Let's go over this piece-by-piece. First, we need some imports to support later code: - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :end-before: main - :linenos: - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :end-before: main + :linenos: + :lineno-match: + :language: py ``__init__.py`` defines a function named ``main``. Here is the entirety of the ``main`` function we've defined in our ``__init__.py``: - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :pyobject: main - :lineno-start: 4 - :linenos: - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :pyobject: main + :linenos: + :lineno-match: + :language: py When you invoke the ``pserve development.ini`` command, the ``main`` function above is executed. It accepts some settings and returns a :term:`WSGI` @@ -46,10 +47,10 @@ application. (See :ref:`startup_chapter` for more about ``pserve``.) Next in ``main``, construct a :term:`Configurator` object: - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 7 - :lineno-start: 7 - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :lines: 7 + :lineno-match: + :language: py ``settings`` is passed to the Configurator as a keyword argument with the dictionary values passed as the ``**settings`` argument. This will be a @@ -60,48 +61,32 @@ deployment-related values such as ``pyramid.reload_templates``, Next include :term:`Jinja2` templating bindings so that we can use renderers with the ``.jinja2`` extension within our project. - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 8 - :lineno-start: 8 - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :lines: 8 + :lineno-match: + :language: py -Next include the module ``meta`` from the package ``models`` using a dotted -Python path. +Next include the the package ``models`` using a dotted Python path. The +exact setup of the models will be covered later. - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 9 - :lineno-start: 9 - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :lines: 9 + :lineno-match: + :language: py -``main`` now calls :meth:`pyramid.config.Configurator.add_static_view` with -two arguments: ``static`` (the name), and ``static`` (the path): +Next include the ``routes`` module using a dotted Python path. This module +will be explained in the next section. - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 10 - :lineno-start: 10 - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :lines: 10 + :lineno-match: + :language: py -This registers a static resource view which will match any URL that starts -with the prefix ``/static`` (by virtue of the first argument to -``add_static_view``). This will serve up static resources for us from within -the ``static`` directory of our ``tutorial`` package, in this case, via -``http://localhost:6543/static/`` and below (by virtue of the second argument -to ``add_static_view``). With this declaration, we're saying that any URL that -starts with ``/static`` should go to the static view; any remainder of its -path (e.g. the ``/foo`` in ``/static/foo``) will be used to compose a path to -a static file resource, such as a CSS file. - -Using the configurator ``main`` also registers a :term:`route configuration` -via the :meth:`pyramid.config.Configurator.add_route` method that will be -used when the URL is ``/``: - - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 11 - :lineno-start: 11 - :language: py +.. note:: -Since this route has a ``pattern`` equaling ``/``, it is the route that will -be matched when the URL ``/`` is visited, e.g., ``http://localhost:6543/``. + Pyramid's :meth:`pyramid.config.Configurator.include` method is the + primary mechanism for extending the configurator and breaking your code + into feature-focused modules. ``main`` next calls the ``scan`` method of the configurator (:meth:`pyramid.config.Configurator.scan`), which will recursively scan our @@ -110,19 +95,49 @@ other special) decorators. When it finds a ``@view_config`` decorator, a view configuration will be registered, which will allow one of our application URLs to be mapped to some code. - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 12 - :lineno-start: 12 - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :lines: 11 + :lineno-match: + :language: py Finally ``main`` is finished configuring things, so it uses the :meth:`pyramid.config.Configurator.make_wsgi_app` method to return a :term:`WSGI` application: - .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 13 - :lineno-start: 13 - :language: py +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :lines: 12 + :lineno-match: + :language: py + + +Route declarations +------------------ + +Open the ``tutorials/routes.py`` file. It should already contain the +following: + +.. literalinclude:: src/basiclayout/tutorial/routes.py + :linenos: + :language: py + +First, on line 2, call :meth:`pyramid.config.Configurator.add_static_view` +with two arguments: ``static`` (the name), and ``static`` (the path). + +This registers a static resource view which will match any URL that starts +with the prefix ``/static`` (by virtue of the first argument to +``add_static_view``). This will serve up static resources for us from within +the ``static`` directory of our ``tutorial`` package, in this case, via +``http://localhost:6543/static/`` and below (by virtue of the second argument +to ``add_static_view``). With this declaration, we're saying that any URL that +starts with ``/static`` should go to the static view; any remainder of its +path (e.g. the ``/foo`` in ``/static/foo``) will be used to compose a path to +a static file resource, such as a CSS file. + +Second, on line 3, the module registers a :term:`route configuration` +via the :meth:`pyramid.config.Configurator.add_route` method that will be +used when the URL is ``/``. Since this route has a ``pattern`` equaling +``/``, it is the route that will be matched when the URL ``/`` is visited, +e.g., ``http://localhost:6543/``. View declarations via the ``views`` package @@ -133,12 +148,12 @@ The main function of a web framework is mapping each URL pattern to code (a corresponding :term:`route`. Our application uses the :meth:`pyramid.view.view_config` decorator to perform this mapping. -Open ``tutorial/tutorial/views/default.py`` in the ``views`` package. It +Open ``tutorial/views/default.py`` in the ``views`` package. It should already contain the following: - .. literalinclude:: src/basiclayout/tutorial/views/default.py - :linenos: - :language: py +.. literalinclude:: src/basiclayout/tutorial/views/default.py + :linenos: + :language: py The important part here is that the ``@view_config`` decorator associates the function it decorates (``my_view``) with a :term:`view configuration`, @@ -178,92 +193,46 @@ In a SQLAlchemy-based application, a *model* object is an object composed by querying the SQL database. The ``models`` package is where the ``alchemy`` scaffold put the classes that implement our models. -First, open ``tutorial/tutorial/models/__init__.py``, which should already -contain the following: - - .. literalinclude:: src/basiclayout/tutorial/models/__init__.py - :linenos: - :language: py - -Our ``__init__.py`` will perform some imports to support later code, then calls -the function :func:`sqlalchemy.orm.configure_mappers`. - -Next open ``tutorial/tutorial/models/meta.py``, which should already contain +First, open ``tutorial/models/meta.py``, which should already contain the following: - .. literalinclude:: src/basiclayout/tutorial/models/meta.py - :linenos: - :language: py +.. literalinclude:: src/basiclayout/tutorial/models/meta.py + :linenos: + :language: py -``meta.py`` contains imports that are used to support later code. We create a -dictionary ``NAMING_CONVENTION`` as well. +``meta.py`` contains imports and support code for defining the models. We +create a dictionary ``NAMING_CONVENTION`` as well for consistent naming of +support objects like indices and constraints. - .. literalinclude:: src/basiclayout/tutorial/models/meta.py - :end-before: metadata - :linenos: - :language: py +.. literalinclude:: src/basiclayout/tutorial/models/meta.py + :end-before: metadata + :linenos: + :language: py Next we create a ``metadata`` object from the class :class:`sqlalchemy.schema.MetaData`, using ``NAMING_CONVENTION`` as the value -for the ``naming_convention`` argument. We also need to create a declarative -``Base`` object to use as a base class for our model. Then our model classes -will inherit from the ``Base`` class so they can be associated with our -particular database connection. - - .. literalinclude:: src/basiclayout/tutorial/models/meta.py - :lines: 18-19 - :lineno-start: 18 - :linenos: - :language: py - -Next we define several functions, the first of which is ``includeme``, which -configures various database settings by calling subsequently defined functions. - - .. literalinclude:: src/basiclayout/tutorial/models/meta.py - :pyobject: includeme - :lineno-start: 22 - :linenos: - :language: py - -The function ``get_session`` registers a database session with a transaction -manager, and returns a ``dbsession`` object. With the transaction manager, our -application will automatically issue a transaction commit after every request -unless an exception is raised, in which case the transaction will be aborted. +for the ``naming_convention`` argument. + +A ``MetaData`` object represents the table and other schema definitions for +a single database. We also need to create a declarative ``Base`` object to use +as a base class for our models. Our models will inherit from this ``Base``, +which will attach the tables to the ``metadata`` we created, and define our +application's database schema. - .. literalinclude:: src/basiclayout/tutorial/models/meta.py - :pyobject: get_session - :lineno-start: 35 - :linenos: - :language: py - -The ``get_engine`` function creates an :term:`SQLAlchemy` database engine using -:func:`sqlalchemy.engine_from_config` from the ``sqlalchemy.``-prefixed -settings in the ``development.ini`` file's ``[app:main]`` section, which is a -URI, something like ``sqlite://``. - - .. literalinclude:: src/basiclayout/tutorial/models/meta.py - :pyobject: get_engine - :lineno-start: 42 - :linenos: - :language: py - -The function ``get_dbmaker`` accepts an :term:`SQLAlchemy` database engine, -and creates a database session object ``dbmaker`` from the :term:`SQLAlchemy` -class :class:`sqlalchemy.orm.session.sessionmaker`, which is then used for -creating a session with the database engine. - - .. literalinclude:: src/basiclayout/tutorial/models/meta.py - :pyobject: get_dbmaker - :lineno-start: 46 - :linenos: - :language: py - -To give a simple example of a model class, we define one named ``MyModel``: - - .. literalinclude:: src/basiclayout/tutorial/models/mymodel.py - :pyobject: MyModel - :linenos: - :language: py +.. literalinclude:: src/basiclayout/tutorial/models/meta.py + :lines: 15-16 + :lineno-match: + :linenos: + :language: py + +We've defined the ``models`` as a packge to make it straightforward to +define models separately in different modules. To give a simple example of a +model class, we define one named ``MyModel`` in a ``mymodel.py``: + +.. literalinclude:: src/basiclayout/tutorial/models/mymodel.py + :pyobject: MyModel + :linenos: + :language: py Our example model does not require an ``__init__`` method because SQLAlchemy supplies for us a default constructor if one is not already present, which @@ -279,8 +248,83 @@ The ``MyModel`` class has a ``__tablename__`` attribute. This informs SQLAlchemy which table to use to store the data representing instances of this class. +Finally, open ``tutorial/models/__init__.py``, which should already +contain the following: + +.. literalinclude:: src/basiclayout/tutorial/models/__init__.py + :linenos: + :language: py + +Our ``models/__init__.py`` module defines the primary API we will use for +configuring the database connections within our application, and it contains +several functions we will cover below. + +As we mentioned above, the purpose of the ``models.meta.metadata`` object is +to describe the schema of the database. This is done by defining models that +inherit from the ``Base`` attached to that ``metadata`` object. In Python, code +is only executed if it is imported, and so to attach the ``models`` table +defined in ``mymodel.py`` to the ``metadata``, we must import it. If we skip +this step, then later, when we run +:meth:`sqlalchemy.schema.MetaData.create_all`, the table will not be created +because the ``metadata`` does not know about it! + +Another important reason to import all of the models is that, when defining +relationships between models, they must all exist in order for SQLAlchemy to +find and build those internal mappings. This is why, after importing all the +models, we explicitly execute the function +:func:`sqlalchemy.orm.configure_mappers`, once we are sure all the models have +been defined and before we start creating connections. + +Next we define several functions for connecting to our database. The first and +lowest level is the ``get_engine`` function. This creates an :term:`SQLAlchemy` +database engine using :func:`sqlalchemy.engine_from_config` from the +``sqlalchemy.``-prefixed settings in the ``development.ini`` file's +``[app:main]`` section. This setting is a URI (something like ``sqlite://``). + +.. literalinclude:: src/basiclayout/tutorial/models/__init__.py + :pyobject: get_engine + :lineno-match: + :linenos: + :language: py + +The function ``get_session_factory`` accepts an :term:`SQLAlchemy` database +engine, and creates a ``session_factory`` from the :term:`SQLAlchemy` class +:class:`sqlalchemy.orm.session.sessionmaker`. This ``session_factory`` is then +used for creating sessions bound to the database engine. + +.. literalinclude:: src/basiclayout/tutorial/models/__init__.py + :pyobject: get_session_factory + :lineno-match: + :linenos: + :language: py + +The function ``get_tm_session`` registers a database session with a transaction +manager, and returns a ``dbsession`` object. With the transaction manager, our +application will automatically issue a transaction commit after every request, +unless an exception is raised, in which case the transaction will be aborted. + +.. literalinclude:: src/basiclayout/tutorial/models/__init__.py + :pyobject: get_tm_session + :lineno-match: + :linenos: + :language: py + +Finally, we define an ``includeme`` function, which is a hook for use with +:meth:`pyramid.config.Configurator.include` to activate code in a Pyramid +application add-on. It is the code that is executed above when we ran +``config.include('.models')`` in our application's ``main`` function. This +function will take the settings from the application, create an engine, +and define a ``request.dbsession`` property, which we can use to do work +on behalf of an incoming request to our application. + +.. literalinclude:: src/basiclayout/tutorial/models/__init__.py + :pyobject: includeme + :lineno-match: + :linenos: + :language: py + That's about all there is to it regarding models, views, and initialization code in our stock application. -The Index import and the Index object creation is not required for this -tutorial, and will be removed in the next step. +The ``Index`` import and the ``Index`` object creation in ``mymodel.py`` is +not required for this tutorial, and will be removed in the next step. diff --git a/docs/tutorials/wiki2/definingmodels.rst b/docs/tutorials/wiki2/definingmodels.rst index b38177d04..41f36fa26 100644 --- a/docs/tutorials/wiki2/definingmodels.rst +++ b/docs/tutorials/wiki2/definingmodels.rst @@ -3,71 +3,148 @@ Defining the Domain Model ========================= The first change we'll make to our stock ``pcreate``-generated application will -be to define a :term:`domain model` constructor representing a wiki page. -We'll do this inside our ``mymodel.py`` file. +be to define a wiki page :term:`domain model`. +.. note:: -Edit ``mymodel.py`` -------------------- + There is nothing special about the filename ``user.py`` or ``page.py`` except + that they are Python modules. A project may have many models throughout its + codebase in arbitrarily named modules. Modules implementing models often + have ``model`` in their names or they may live in a Python subpackage of + your application package named ``models`` (as we've done in this tutorial), + but this is only a convention and not a requirement. -.. note:: - There is nothing special about the filename ``mymodel.py``. A - project may have many models throughout its codebase in arbitrarily named - files. Files implementing models often have ``model`` in their filenames - or they may live in a Python subpackage of your application package named - ``models`` (as we've done in this tutorial), but this is only by convention. +Declaring dependencies in our ``setup.py`` file +=============================================== -Open the ``tutorial/tutorial/models/mymodel.py`` file and edit it to look like -the following: +The models code in our application will depend on a package which is not a +dependency of the original "tutorial" application. The original "tutorial" +application was generated by the ``pcreate`` command; it doesn't know +about our custom application requirements. + +We need to add a dependency on the ``bcrypt`` package to our ``tutorial`` +package's ``setup.py`` file by assigning this dependency to the ``requires`` +parameter in the ``setup()`` function. + +Open ``tutorial/setup.py`` and edit it to look like the following: -.. literalinclude:: src/models/tutorial/models/mymodel.py +.. literalinclude:: src/models/setup.py + :linenos: + :emphasize-lines: 12 + :language: python + +Only the highlighted line needs to be added. + + +Running ``setup.py develop`` +============================ + +Since a new software dependency was added, you will need to run ``python +setup.py develop`` again inside the root of the ``tutorial`` package to obtain +and register the newly added dependency distribution. + +Make sure your current working directory is the root of the project (the +directory in which ``setup.py`` lives) and execute the following command. + +On UNIX: + +.. code-block:: bash + + $ cd tutorial + $ $VENV/bin/python setup.py develop + +On Windows: + +.. code-block:: text + + c:\pyramidtut> cd tutorial + c:\pyramidtut\tutorial> %VENV%\Scripts\python setup.py develop + +Success executing this command will end with a line to the console something +like this:: + + Finished processing dependencies for tutorial==0.0 + + +Remove ``mymodel.py`` +--------------------- + +The first thing we'll do is delete the file ``tutorial/models/mymodel.py``. +The ``MyModel`` class is only a sample and we're not going to use it. + + +Add ``user.py`` +--------------- + +Create a new file ``tutorial/models/user.py`` with the following contents: + +.. literalinclude:: src/models/tutorial/models/user.py :linenos: :language: py - :emphasize-lines: 9-11,13,14 -The highlighted lines are the ones that need to be changed, as well as -removing lines that reference ``Index``. +This is a very basic model for a user who can authenticate with our wiki. -The first thing we've done is remove the stock ``MyModel`` class -from the generated ``models.py`` file. The ``MyModel`` class is only a -sample and we're not going to use it. +We discussed briefly in the previous chapter that our models will inherit +from a SQLAlchemy :func:`sqlalchemy.ext.declarative.declarative_base`. This +will attach the model to our schema. -Then we added a ``Page`` class. Because this is an SQLAlchemy application, -this class inherits from an instance of -:func:`sqlalchemy.ext.declarative.declarative_base`. +As you can see, our ``User`` class has a class-level attribute +``__tablename__`` which equals the string ``users``. Our ``User`` class +will also have class-level attributes named ``id``, ``name``, +``password_hash`` and ``role`` (all instances of +:class:`sqlalchemy.schema.Column`). These will map to columns in the ``users`` +table. The ``id`` attribute will be the primary key in the table. The ``name`` +attribute will be a text column, each value of which needs to be unique within +the column. The ``password_hash`` is a nullable text attribute that will +contain a securely hashed password [1]_. Finally, the ``role`` text attribute +will hold the role of the user. -.. literalinclude:: src/models/tutorial/models/mymodel.py - :pyobject: Page +There are two helper methods that will help us later when using the +user objects. The first is ``set_password`` which will take a raw password +and transform it using bcrypt_ into an irreversible representation. The +``check_password`` method will allow us to compare input passwords to +see if they resolve to the same hash signifying a match. + + +Add ``page.py`` +--------------- + +Create a new file ``tutorial/models/page.py`` with the following contents: + +.. literalinclude:: src/models/tutorial/models/page.py :linenos: - :language: python + :language: py -As you can see, our ``Page`` class has a class level attribute -``__tablename__`` which equals the string ``'pages'``. This means that -SQLAlchemy will store our wiki data in a SQL table named ``pages``. Our -``Page`` class will also have class-level attributes named ``id``, ``name``, -and ``data`` (all instances of :class:`sqlalchemy.schema.Column`). These will -map to columns in the ``pages`` table. The ``id`` attribute will be the -primary key in the table. The ``name`` attribute will be a text attribute, -each value of which needs to be unique within the column. The ``data`` -attribute is a text attribute that will hold the body of each page. +As you can see, our ``Page`` class is very similar to the ``User`` defined +above except with attributes focused on storing information about a wiki +page including ``id``, ``name``, and ``data``. The only new construct +introduced here is the ``creator_id`` column which is a foreign key +referencing the ``users`` table. Foreign keys are very useful at the +schema-level but since we want to relate ``User`` objects with ``Page`` +objects we also define a the ``creator`` attribute which is an ORM-level +mapping between the two tables. SQLAlchemy will automatically populate this +value using the foreign key referencing the user. Since the foreign key +has ``nullable=False`` we are guaranteed that an instance of ``page`` will +have a corresponding ``page.creator`` which will be a ``User`` instance. Edit ``models/__init__.py`` --------------------------- Since we are using a package for our models, we also need to update our -``__init__.py`` file. +``__init__.py`` file to ensure that the models are attached to the metadata. -Open the ``tutorial/tutorial/models/__init__.py`` file and edit it to look like +Open the ``tutorial/models/__init__.py`` file and edit it to look like the following: .. literalinclude:: src/models/tutorial/models/__init__.py :linenos: :language: py - :emphasize-lines: 4 + :emphasize-lines: 8,9 -Here we need to align our import with the name of the model ``Page``. +Here we need to align our imports with the names of the models ``User``, +and ``Page``. Edit ``scripts/initializedb.py`` @@ -76,25 +153,23 @@ Edit ``scripts/initializedb.py`` We haven't looked at the details of this file yet, but within the ``scripts`` directory of your ``tutorial`` package is a file named ``initializedb.py``. Code in this file is executed whenever we run the ``initialize_tutorial_db`` -command, as we did in the installation step of this tutorial. +command, as we did in the installation step of this tutorial [2]_. Since we've changed our model, we need to make changes to our ``initializedb.py`` script. In particular, we'll replace our import of -``MyModel`` with one of ``Page`` and we'll change the very end of the script -to create a ``Page`` rather than a ``MyModel`` and add it to our -``DBSession``. +``MyModel`` with those of ``User`` and ``Page`` and we'll change the very end +of the script to create two ``User`` objects (``basic`` and ``editor``) and a +``Page`` rather than a ``MyModel`` and add them to our ``dbsession``. -Open ``tutorial/tutorial/scripts/initializedb.py`` and edit it to look like +Open ``tutorial/scripts/initializedb.py`` and edit it to look like the following: .. literalinclude:: src/models/tutorial/scripts/initializedb.py :linenos: :language: python - :emphasize-lines: 16,31,41 + :emphasize-lines: 18,44-57 -Only the highlighted lines need to be changed, as well as removing the lines -referencing ``pyramid.scripts.common`` and ``options`` under the ``main`` -function. +Only the highlighted lines need to be changed. Installing the project and re-initializing the database @@ -107,28 +182,49 @@ made to both the models.py file and to the initializedb.py file. See Success will look something like this:: - 2015-05-24 15:34:14,542 INFO [sqlalchemy.engine.base.Engine:1192][MainThread] SELECT CAST('test plain returns' AS VARCHAR(60)) AS anon_1 - 2015-05-24 15:34:14,542 INFO [sqlalchemy.engine.base.Engine:1193][MainThread] () - 2015-05-24 15:34:14,543 INFO [sqlalchemy.engine.base.Engine:1192][MainThread] SELECT CAST('test unicode returns' AS VARCHAR(60)) AS anon_1 - 2015-05-24 15:34:14,543 INFO [sqlalchemy.engine.base.Engine:1193][MainThread] () - 2015-05-24 15:34:14,543 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] PRAGMA table_info("pages") - 2015-05-24 15:34:14,544 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] () - 2015-05-24 15:34:14,544 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] - CREATE TABLE pages ( - id INTEGER NOT NULL, - name TEXT, - data TEXT, - PRIMARY KEY (id), - UNIQUE (name) - ) - - - 2015-05-24 15:34:14,545 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] () - 2015-05-24 15:34:14,546 INFO [sqlalchemy.engine.base.Engine:686][MainThread] COMMIT - 2015-05-24 15:34:14,548 INFO [sqlalchemy.engine.base.Engine:646][MainThread] BEGIN (implicit) - 2015-05-24 15:34:14,549 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] INSERT INTO pages (name, data) VALUES (?, ?) - 2015-05-24 15:34:14,549 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] ('FrontPage', 'This is the front page') - 2015-05-24 15:34:14,550 INFO [sqlalchemy.engine.base.Engine:686][MainThread] COMMIT + 2016-02-12 01:06:35,855 INFO [sqlalchemy.engine.base.Engine:1192][MainThread] SELECT CAST('test plain returns' AS VARCHAR(60)) AS anon_1 + 2016-02-12 01:06:35,855 INFO [sqlalchemy.engine.base.Engine:1193][MainThread] () + 2016-02-12 01:06:35,855 INFO [sqlalchemy.engine.base.Engine:1192][MainThread] SELECT CAST('test unicode returns' AS VARCHAR(60)) AS anon_1 + 2016-02-12 01:06:35,855 INFO [sqlalchemy.engine.base.Engine:1193][MainThread] () + 2016-02-12 01:06:35,856 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] PRAGMA table_info("pages") + 2016-02-12 01:06:35,856 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] () + 2016-02-12 01:06:35,856 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] PRAGMA table_info("users") + 2016-02-12 01:06:35,856 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] () + 2016-02-12 01:06:35,857 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] + CREATE TABLE users ( + id INTEGER NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL, + password_hash TEXT, + CONSTRAINT pk_users PRIMARY KEY (id), + CONSTRAINT uq_users_name UNIQUE (name) + ) + + + 2016-02-12 01:06:35,857 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] () + 2016-02-12 01:06:35,858 INFO [sqlalchemy.engine.base.Engine:686][MainThread] COMMIT + 2016-02-12 01:06:35,858 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] + CREATE TABLE pages ( + id INTEGER NOT NULL, + name TEXT NOT NULL, + data INTEGER NOT NULL, + creator_id INTEGER NOT NULL, + CONSTRAINT pk_pages PRIMARY KEY (id), + CONSTRAINT uq_pages_name UNIQUE (name), + CONSTRAINT fk_pages_creator_id_users FOREIGN KEY(creator_id) REFERENCES users (id) + ) + + + 2016-02-12 01:06:35,859 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] () + 2016-02-12 01:06:35,859 INFO [sqlalchemy.engine.base.Engine:686][MainThread] COMMIT + 2016-02-12 01:06:36,383 INFO [sqlalchemy.engine.base.Engine:646][MainThread] BEGIN (implicit) + 2016-02-12 01:06:36,384 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] INSERT INTO users (name, role, password_hash) VALUES (?, ?, ?) + 2016-02-12 01:06:36,384 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] ('editor', 'editor', '$2b$12$bSr5QR3wFs1LAnld7R94e.TXPj7DVoTxu2hA1kY6rm.Q3cAhD.AQO') + 2016-02-12 01:06:36,384 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] INSERT INTO users (name, role, password_hash) VALUES (?, ?, ?) + 2016-02-12 01:06:36,384 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] ('basic', 'basic', '$2b$12$.v0BQK2xWEQOnywbX2BFs.qzXo5Qf9oZohGWux/MOSj6Z.pVaY2Z6') + 2016-02-12 01:06:36,385 INFO [sqlalchemy.engine.base.Engine:1097][MainThread] INSERT INTO pages (name, data, creator_id) VALUES (?, ?, ?) + 2016-02-12 01:06:36,385 INFO [sqlalchemy.engine.base.Engine:1100][MainThread] ('FrontPage', 'This is the front page', 1) + 2016-02-12 01:06:36,385 INFO [sqlalchemy.engine.base.Engine:686][MainThread] COMMIT View the application in a browser --------------------------------- @@ -144,3 +240,14 @@ up with a Python traceback on your console that ends with this exception: ImportError: cannot import name MyModel This will also happen if you attempt to run the tests. + +.. _bcrypt: https://pypi.python.org/pypi/bcrypt + +.. [1] We are using the bcrypt_ package from PyPI to hash our passwords + securely. There are other one-way hash algorithms for passwords if + bcrypt is an issue on your system. Just make sure that it's an + algorithm approved for storing passwords versus a generic one-way hash. + +.. [2] The command is named ``initialize_tutorial_db`` because of the mapping + defined in the ``[console_scripts]`` entry point of our project's + ``setup.py`` file. diff --git a/docs/tutorials/wiki2/definingviews.rst b/docs/tutorials/wiki2/definingviews.rst index 08fa8f16b..184f9e1fa 100644 --- a/docs/tutorials/wiki2/definingviews.rst +++ b/docs/tutorials/wiki2/definingviews.rst @@ -14,13 +14,12 @@ and a user visits ``http://example.com/foo/bar``, our pattern would be matched against ``/foo/bar`` and the ``matchdict`` would look like ``{'one':'foo', 'two':'bar'}``. -Declaring dependencies in our ``setup.py`` file -=============================================== +Adding the ``docutils`` dependency +================================== -The view code in our application will depend on a package which is not a -dependency of the original "tutorial" application. The original "tutorial" -application was generated by the ``pcreate`` command; it doesn't know -about our custom application requirements. +Remember in the previous chapter we added a new dependency on the ``bcrypt`` +package. Again, the view code in our application will depend on a package which +is not a dependency of the original "tutorial" application. We need to add a dependency on the ``docutils`` package to our ``tutorial`` package's ``setup.py`` file by assigning this dependency to the ``requires`` @@ -30,44 +29,91 @@ Open ``tutorial/setup.py`` and edit it to look like the following: .. literalinclude:: src/views/setup.py :linenos: - :emphasize-lines: 20 + :emphasize-lines: 13 :language: python Only the highlighted line needs to be added. -Running ``setup.py develop`` -============================ +Again, as we did in the previous chapter, the dependency now needs to be +installed so re-run the ``python setup.py develop`` command. -Since a new software dependency was added, you will need to run ``python -setup.py develop`` again inside the root of the ``tutorial`` package to obtain -and register the newly added dependency distribution. +Static assets +------------- + +Our templates name static assets, including CSS and images. We don't need +to create these files within our package's ``static`` directory because they +were provided at the time we created the project. -Make sure your current working directory is the root of the project (the -directory in which ``setup.py`` lives) and execute the following command. +As an example, the CSS file will be accessed via +``http://localhost:6543/static/theme.css`` by virtue of the call to the +``add_static_view`` directive we've made in the ``routes.py`` file. Any +number and type of static assets can be placed in this directory (or +subdirectories) and are just referred to by URL or by using the convenience +method ``static_url``, e.g., +``request.static_url('<package>:static/foo.css')`` within templates. -On UNIX: +Adding routes to ``routes.py`` +============================== -.. code-block:: bash +This is the URL Dispatch tutorial and so let's start by adding some +URL patterns to our app. Later we'll attach views to handle the URLs. - $ cd tutorial - $ $VENV/bin/python setup.py develop +The ``routes.py`` file contains +:meth:`pyramid.config.Configurator.add_route` calls which serve to add routes +to our application. First, we’ll get rid of the existing route created by +the template using the name ``'home'``. It’s only an example and isn’t +relevant to our application. -On Windows: +We then need to add four calls to ``add_route``. Note that the *ordering* of +these declarations is very important. ``route`` declarations are matched in +the order they're registered. -.. code-block:: text +#. Add a declaration which maps the pattern ``/`` (signifying the root URL) + to the route named ``view_wiki``. It maps to our ``view_wiki`` view + callable by virtue of the ``@view_config`` attached to the ``view_wiki`` + view function indicating ``route_name='view_wiki'``. + +#. Add a declaration which maps the pattern ``/{pagename}`` to the route named + ``view_page``. This is the regular view for a page. It maps + to our ``view_page`` view callable by virtue of the ``@view_config`` + attached to the ``view_page`` view function indicating + ``route_name='view_page'``. + +#. Add a declaration which maps the pattern ``/add_page/{pagename}`` to the + route named ``add_page``. This is the add view for a new page. It maps + to our ``add_page`` view callable by virtue of the ``@view_config`` + attached to the ``add_page`` view function indicating + ``route_name='add_page'``. + +#. Add a declaration which maps the pattern ``/{pagename}/edit_page`` to the + route named ``edit_page``. This is the edit view for a page. It maps + to our ``edit_page`` view callable by virtue of the ``@view_config`` + attached to the ``edit_page`` view function indicating + ``route_name='edit_page'``. + +As a result of our edits, the ``routes.py`` file should look something like: + +.. literalinclude:: src/views/tutorial/routes.py + :linenos: + :emphasize-lines: 3-6 + :language: python - c:\pyramidtut> cd tutorial - c:\pyramidtut\tutorial> %VENV%\Scripts\python setup.py develop +The highlighted lines are the ones that need to be added or edited. -Success executing this command will end with a line to the console something -like this:: +.. warning:: - Finished processing dependencies for tutorial==0.0 + The order of the routes is important! If you placed + ``/{pagename}/edit_page`` **before** ``/add_page/{pagename}`` then we would + never be able to add pages because the first route would always match + a request to ``/add_page/edit_page`` whereas we want ``/add_page/..`` to + have priority. This isn't a huge problem in this particular app because + wiki pages are always camel case but it's important to be aware of this + behavior in your own apps. Adding view functions in ``views/default.py`` ============================================= -It's time for a major change. Open ``tutorial/tutorial/views/default.py`` and +It's time for a major change. Open ``tutorial/views/default.py`` and edit it to look like the following: .. literalinclude:: src/views/tutorial/views/default.py @@ -81,7 +127,7 @@ We added some imports, and created a regular expression to find "WikiWords". We got rid of the ``my_view`` view function and its decorator that was added when we originally rendered the ``alchemy`` scaffold. It was only an example -and isn't relevant to our application. We also delated the ``db_err_msg`` +and isn't relevant to our application. We also deleted the ``db_err_msg`` string. Then we added four :term:`view callable` functions to our ``views/default.py`` @@ -96,11 +142,12 @@ We'll describe each one briefly in the following sections. .. note:: - There is nothing special about the filename ``default.py``. A project may - have many view callables throughout its codebase in arbitrarily named files. - Files implementing view callables often have ``view`` in their filenames (or + There is nothing special about the filename ``default.py`` exept that + it is a Python module. A project may have many view callables throughout + its codebase in arbitrarily named modules. + Modules implementing view callables often have ``view`` in their name (or may live in a Python subpackage of your application package named ``views``, - as in our case), but this is only by convention. + as in our case), but this is only by convention, not a requirement. The ``view_wiki`` view function ------------------------------- @@ -109,20 +156,20 @@ Following is the code for the ``view_wiki`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py :lines: 17-20 - :lineno-start: 17 + :lineno-match: :linenos: :language: python ``view_wiki()`` is the :term:`default view` that gets called when a request is -made to the root URL of our wiki. It always redirects to an URL which +made to the root URL of our wiki. It always redirects to a URL which represents the path to our "FrontPage". The ``view_wiki`` view callable always redirects to the URL of a Page resource named "FrontPage". To do so, it returns an instance of the -:class:`pyramid.httpexceptions.HTTPFound` class (instances of which implement -the :class:`pyramid.interfaces.IResponse` interface, like +:class:`pyramid.httpexceptions.HTTPFound` class (instances of which +implement the :class:`pyramid.interfaces.IResponse` interface, like :class:`pyramid.response.Response` does). It uses the -:meth:`pyramid.request.Request.route_url` API to construct an URL to the +:meth:`pyramid.request.Request.route_url` API to construct a URL to the ``FrontPage`` page (i.e., ``http://localhost:6543/FrontPage``), and uses it as the "location" of the ``HTTPFound`` response, forming an HTTP redirect. @@ -133,7 +180,7 @@ Here is the code for the ``view_page`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py :lines: 22-42 - :lineno-start: 22 + :lineno-match: :linenos: :language: python @@ -142,12 +189,12 @@ Here is the code for the ``view_page`` view function and its decorator: ``Page`` model object) as HTML. Then it substitutes an HTML anchor for each *WikiWord* reference in the rendered HTML using a compiled regular expression. -The curried function named ``check`` is used as the first argument to +The curried function named ``add_link`` is used as the first argument to ``wikiwords.sub``, indicating that it should be called to provide a value for each WikiWord match found in the content. If the wiki already contains a -page with the matched WikiWord name, ``check()`` generates a view +page with the matched WikiWord name, ``add_link()`` generates a view link to be used as the substitution value and returns it. If the wiki does -not already contain a page with the matched WikiWord name, ``check()`` +not already contain a page with the matched WikiWord name, ``add_link()`` generates an "add" link as the substitution value and returns it. As a result, the ``content`` variable is now a fully formed bit of HTML @@ -159,22 +206,76 @@ template, and we return a dictionary with a number of arguments. The fact that ``view_page()`` returns a dictionary (as opposed to a :term:`response` object) is a cue to :app:`Pyramid` that it should try to use a :term:`renderer` associated with the view configuration to render a response. In our case, the -renderer used will be the ``templates/view.jinja2`` template, as indicated in +renderer used will be the ``view.jinja2`` template, as indicated in the ``@view_config`` decorator that is applied to ``view_page()``. +If the page does not exist then we need to handle that by raising +:class:`pyramid.httpexceptions.HTTPNotFound`` to trigger our 404 handling +defined in ``tutorial/views/notfound.py``. + +.. note:: + + Using ``raise`` versus ``return`` with the http exceptions is an important + distinction that can commonly mess people up. In + ``tutorial/views/notfound.py`` there is an :term:`exception view` + registered for handling the ``HTTPNotFound`` exception. Exception views + are only triggered for raised exceptions. If the ``HTTPNotFound`` is + returned then it has an internal "stock" template that it will use + to render itself as a response. If you aren't seeing your exception + view being executed this is probably the problem! See + :ref:`special_exceptions_in_callables` for more information about + exception views. + +The ``edit_page`` view function +------------------------------- + +Here is the code for the ``edit_page`` view function and its decorator: + +.. literalinclude:: src/views/tutorial/views/default.py + :lines: 44-56 + :lineno-match: + :linenos: + :language: python + +``edit_page()`` is invoked when a user clicks the "Edit this +Page" button on the view form. It renders an edit form, but it also acts as +the handler for the form it renders. The ``matchdict`` attribute of the +request passed to the ``edit_page`` view will have a ``'pagename'`` key +matching the name of the page the user wants to edit. + +If the view execution *is* a result of a form submission (i.e., the expression +``'form.submitted' in request.params`` is ``True``), the view grabs the +``body`` element of the request parameters and sets it as the ``data`` +attribute of the page object. It then redirects to the ``view_page`` view +of the wiki page. + +If the view execution is *not* a result of a form submission (i.e., the +expression ``'form.submitted' in request.params`` is ``False``), the view +simply renders the edit form, passing the page object and a ``save_url`` +which will be used as the action of the generated form. + +.. note:: + + Since our ``request.dbsession`` defined in the previous chapter is + registered with the ``pyramid_tm`` transaction manager any changes we make + to objects managed by the that session will be committed automatically. + In the event that there was an error (even later, in our template code) the + changes would be aborted. This means the view itself does not need to + concern itself with commit/rollback logic. + The ``add_page`` view function ------------------------------ Here is the code for the ``add_page`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 44-55 - :lineno-start: 44 + :lines: 58-70 + :lineno-match: :linenos: :language: python ``add_page()`` is invoked when a user clicks on a *WikiWord* which -isn't yet represented as a page in the system. The ``check`` function +isn't yet represented as a page in the system. The ``add_link`` function within the ``view_page`` view generates URLs to this view. ``add_page()`` also acts as a handler for the form that is generated when we want to add a page object. The ``matchdict`` attribute of the @@ -190,8 +291,12 @@ If the view execution *is* a result of a form submission (i.e., the expression ``'form.submitted' in request.params`` is ``True``), we grab the page body from the form data, create a Page object with this page body and the name taken from ``matchdict['pagename']``, and save it into the database using -``request.dbession.add``. We then redirect back to the ``view_page`` view for -the newly created page. +``request.dbession.add``. Since we have not yet covered authentication we +don't have a logged-in user to add as the page's ``creator``. Until we +get to that point in the tutorial we'll just assume that all pages are created +by the ``editor`` user so we query that object and set it on ``page.creator``. +Finally, we redirect the client back to the ``view_page`` view for the newly +created page. If the view execution is *not* a result of a form submission (i.e., the expression ``'form.submitted' in request.params`` is ``False``), the view @@ -203,34 +308,6 @@ in order to satisfy the edit form's desire to have *some* page object exposed as ``page``. :app:`Pyramid` will render the template associated with this view to a response. -The ``edit_page`` view function -------------------------------- - -Here is the code for the ``edit_page`` view function and its decorator: - -.. literalinclude:: src/views/tutorial/views/default.py - :lines: 57-69 - :lineno-start: 57 - :linenos: - :language: python - -``edit_page()`` is invoked when a user clicks the "Edit this -Page" button on the view form. It renders an edit form, but it also acts as -the handler for the form it renders. The ``matchdict`` attribute of the -request passed to the ``edit_page`` view will have a ``'pagename'`` key -matching the name of the page the user wants to edit. - -If the view execution *is* a result of a form submission (i.e., the expression -``'form.submitted' in request.params`` is ``True``), the view grabs the -``body`` element of the request parameters and sets it as the ``data`` -attribute of the page object. It then redirects to the ``view_page`` view -of the wiki page. - -If the view execution is *not* a result of a form submission (i.e., the -expression ``'form.submitted' in request.params`` is ``False``), the view -simply renders the edit form, passing the page object and a ``save_url`` -which will be used as the action of the generated form. - Adding templates ================ @@ -240,113 +317,119 @@ These templates will live in the ``templates`` directory of our tutorial package. Jinja2 templates must have a ``.jinja2`` extension to be recognized as such. +The ``layout.jinja2`` template +------------------------------ + +Replace ``tutorial/templates/layout.jinja2`` with the following content: + +.. literalinclude:: src/views/tutorial/templates/layout.jinja2 + :linenos: + :emphasize-lines: 11,36 + :language: html + +Since we're using a templating engine we can factor common boilerplate out of +our page templates into reusable components. One method for doing this +is template inheritance via blocks. + +- We have defined 2 placeholders in the layout template where a child template + can override the content. These blocks are named ``subtitle`` (line 11) and + ``content`` (line 36). +- Please refer to the Jinja2_ documentation for more information about + template inheritance. + The ``view.jinja2`` template ---------------------------- -Create ``tutorial/tutorial/templates/view.jinja2`` and add the following -content: +Create ``tutorial/templates/view.jinja2`` and add the following content: .. literalinclude:: src/views/tutorial/templates/view.jinja2 :linenos: - :emphasize-lines: 36,38-40 + :emphasize-lines: 1,3,6 :language: html -This template is used by ``view_page()`` for displaying a single -wiki page. It includes: +This template is used by ``view_page()`` for displaying a single wiki page. +It includes: +- We begin by extending the ``layout.jinja2`` template defined above + which provides the skeleton of the page (line 1). +- We override the ``subtitle`` block from the base layout to insert the + page name of the page into the page's title (line 3). +- We override the ``content`` block from the base layout to insert our markup + into the body (line 5-18). - A variable that is replaced with the ``content`` value provided by the view - (line 36). ``content`` contains HTML, so the ``|safe`` filter is used to + (line 6). ``content`` contains HTML, so the ``|safe`` filter is used to prevent escaping it (e.g., changing ">" to ">"). - A link that points at the "edit" URL which invokes the ``edit_page`` view for - the page being viewed (lines 38-40). + the page being viewed (line 9). The ``edit.jinja2`` template ---------------------------- -Create ``tutorial/tutorial/templates/edit.jinja2`` and add the following -content: +Create ``tutorial/templates/edit.jinja2`` and add the following content: .. literalinclude:: src/views/tutorial/templates/edit.jinja2 :linenos: - :emphasize-lines: 42,44,47 + :emphasize-lines: 3,12,14,17 :language: html -This template is used by ``add_page()`` and ``edit_page()`` for adding and -editing a wiki page. It displays a page containing a form that includes: +This template serves two use-cases. It is used by ``add_page()`` and +``edit_page()`` for adding and editing a wiki page. It displays a page +containing a form that includes: +- Again, we are extending the ``layout.jinja2`` template which provides + the skeleton of the page (line 1). +- Override the ``subtitle`` block to affect the ``<title>`` tag in the + ``head`` of the page (line 3). - A 10-row by 60-column ``textarea`` field named ``body`` that is filled with - any existing page data when it is rendered (line 44). -- A submit button that has the name ``form.submitted`` (line 47). + any existing page data when it is rendered (line 14). +- A submit button that has the name ``form.submitted`` (line 17). The form POSTs back to the ``save_url`` argument supplied by the view (line -42). The view will use the ``body`` and ``form.submitted`` values. +12). The view will use the ``body`` and ``form.submitted`` values. -.. note:: Our templates use a ``request`` object that none of our tutorial - views return in their dictionary. ``request`` is one of several names that - are available "by default" in a template when a template renderer is used. - See :ref:`renderer_system_values` for information about other names that - are available by default when a template is used as a renderer. - -Static assets -------------- +The ``404.jinja2`` template +--------------------------- -Our templates name static assets, including CSS and images. We don't need -to create these files within our package's ``static`` directory because they -were provided at the time we created the project. - -As an example, the CSS file will be accessed via -``http://localhost:6543/static/theme.css`` by virtue of the call to the -``add_static_view`` directive we've made in the ``__init__.py`` file. Any -number and type of static assets can be placed in this directory (or -subdirectories) and are just referred to by URL or by using the convenience -method ``static_url``, e.g., -``request.static_url('<package>:static/foo.css')`` within templates. +Replace ``tutorial/templates/404.jinja2`` with the following content: -Adding routes to ``__init__.py`` -================================ - -The ``__init__.py`` file contains -:meth:`pyramid.config.Configurator.add_route` calls which serve to add routes -to our application. First, we’ll get rid of the existing route created by -the template using the name ``'home'``. It’s only an example and isn’t -relevant to our application. - -We then need to add four calls to ``add_route``. Note that the *ordering* of -these declarations is very important. ``route`` declarations are matched in -the order they're found in the ``__init__.py`` file. - -#. Add a declaration which maps the pattern ``/`` (signifying the root URL) - to the route named ``view_wiki``. It maps to our ``view_wiki`` view - callable by virtue of the ``@view_config`` attached to the ``view_wiki`` - view function indicating ``route_name='view_wiki'``. - -#. Add a declaration which maps the pattern ``/{pagename}`` to the route named - ``view_page``. This is the regular view for a page. It maps - to our ``view_page`` view callable by virtue of the ``@view_config`` - attached to the ``view_page`` view function indicating - ``route_name='view_page'``. - -#. Add a declaration which maps the pattern ``/add_page/{pagename}`` to the - route named ``add_page``. This is the add view for a new page. It maps - to our ``add_page`` view callable by virtue of the ``@view_config`` - attached to the ``add_page`` view function indicating - ``route_name='add_page'``. - -#. Add a declaration which maps the pattern ``/{pagename}/edit_page`` to the - route named ``edit_page``. This is the edit view for a page. It maps - to our ``edit_page`` view callable by virtue of the ``@view_config`` - attached to the ``edit_page`` view function indicating - ``route_name='edit_page'``. +.. literalinclude:: src/views/tutorial/templates/404.jinja2 + :linenos: + :language: html -As a result of our edits, the ``__init__.py`` file should look -something like: +This template is linked from the ``notfound_view`` defined in +``tutorial/views/notfound.py`` as shown here: -.. literalinclude:: src/views/tutorial/__init__.py +.. literalinclude:: src/views/tutorial/views/notfound.py :linenos: - :emphasize-lines: 11-14 + :emphasize-lines: 6 :language: python -The highlighted lines are the ones that need to be added or edited. +There are several important things to note about this configuration: + +- The ``notfound_view`` in the above snippet is called an + :term:`exception view`. For more information see + :ref:`special_exceptions_in_callables`. +- The ``notfound_view`` sets the response status to 404. It's possible + to affect the response object used by the renderer via + :ref:`request_response_attr`. +- The ``notfound_view`` is registered as an exception view and will be invoked + **only** if ``pyramid.httpexceptions.HTTPNotFound`` is raised as an + exception. This means it will not be invoked for any responses returned + from a view normally. For example, on line 27 of + ``tutorial/views/default.py`` the exception is raised which will trigger + the view. + +Finally, you may delete the ``tutorial/templates/mytemplate.jinja2`` +template that was provided by the ``alchemy`` scaffold as we have created +our own templates for the wiki. + +.. note:: + + Our templates use a ``request`` object that none of our tutorial + views return in their dictionary. ``request`` is one of several names that + are available "by default" in a template when a template renderer is used. + See :ref:`renderer_system_values` for information about other names that + are available by default when a template is used as a renderer. Viewing the application in a browser ==================================== @@ -370,3 +453,5 @@ each of the following URLs, checking that the result is as expected: will generate a ``NoResultFound: No row was found for one()`` error. You'll see an interactive traceback facility provided by :term:`pyramid_debugtoolbar`. + +.. _jinja2: http://jinja.pocoo.org/ diff --git a/docs/tutorials/wiki2/design.rst b/docs/tutorials/wiki2/design.rst index 8e3bb4c13..45e2fddd0 100644 --- a/docs/tutorials/wiki2/design.rst +++ b/docs/tutorials/wiki2/design.rst @@ -6,7 +6,7 @@ Following is a quick overview of the design of our wiki application to help us understand the changes that we will be making as we work through the tutorial. Overall -------- +======= We choose to use :term:`reStructuredText` markup in the wiki text. Translation from reStructuredText to HTML is provided by the widely used ``docutils`` @@ -14,16 +14,24 @@ Python module. We will add this module in the dependency list on the project ``setup.py`` file. Models ------- +====== We'll be using an SQLite database to hold our wiki data, and we'll be using :term:`SQLAlchemy` to access the data in this database. -Within the database, we define a single table named `pages`, whose elements -will store the wiki pages. There are two columns: `name` and `data`. +Within the database, we will define two tables: + +- The `users` table which will store the `id`, `name`, `password_hash` and + `role` of each wiki user. +- The `pages` table, whose elements will store the wiki pages. + There are four columns: `id`, `name`, `data` and `creator_id`. + +There is a one-to-many relationship between `users` and `pages` tracking +the user who created each wiki page defined by the `creator_id` column on the +`pages` table. -URLs like ``/PageName`` will try to find an element in the table that has a -corresponding name. +URLs like ``/PageName`` will try to find an element in the `pages` table that +has a corresponding name. To add a page to the wiki, a new row is created and the text is stored in `data`. @@ -32,8 +40,8 @@ A page named ``FrontPage`` containing the text *This is the front page*, will be created when the storage is initialized, and will be used as the wiki home page. -Views ------ +Wiki Views +========== There will be three views to handle the normal operations of adding, editing, and viewing wiki pages, plus one view for the wiki front page. Two templates @@ -45,38 +53,46 @@ designer-friendly templating language for Python, modeled after Django's templates. Security --------- - -We'll eventually be adding security to our application. The components we'll -use to do this are below. - -- USERS, a dictionary mapping :term:`userids <userid>` to their corresponding - passwords. - -- GROUPS, a dictionary mapping :term:`userids <userid>` to a list of groups to - which they belong. - -- ``groupfinder``, an *authorization callback* that looks up USERS and GROUPS. - It will be provided in a new ``security/default.py`` subpackage and file. - -- An :term:`ACL` is attached to the root :term:`resource`. Each row below - details an :term:`ACE`: - - +----------+----------------+----------------+ - | Action | Principal | Permission | - +==========+================+================+ - | Allow | Everyone | View | - +----------+----------------+----------------+ - | Allow | group:editors | Edit | - +----------+----------------+----------------+ - -- Permission declarations are added to the views to assert the security - policies as each request is handled. - -Two additional views and one template will handle the login and logout tasks. +======== + +We'll eventually be adding security to our application. To do this, we'll +be using a very simple role-based security model. We'll assign a single +role category to each user in our system. + +`basic` + An authenticated user who can view content and create new pages. A `basic` + user may also edit the pages they have created but not pages created by + other users. + +`editor` + An authenticated user who can create and edit any content in the system. + +In order to accomplish this we'll need to define an authentication policy +which can identify users by their :term:`userid` and role. Then we'll +need to define a page :term:`resource` which contains the appropriate +:term:`ACL`: + ++----------+--------------------+----------------+ +| Action | Principal | Permission | ++==========+====================+================+ +| Allow | Everyone | view | ++----------+--------------------+----------------+ +| Allow | group:basic | create | ++----------+--------------------+----------------+ +| Allow | group:editors | edit | ++----------+--------------------+----------------+ +| Allow | <creator of page> | edit | ++----------+--------------------+----------------+ + +Permission declarations will be added to the views to assert the security +policies as each request is handled. + +On the security side of the application there are two additional views for +handling login and logout as well as two exception views for handling +invalid access attempts and unhandled URLs. Summary -------- +======= The URL, actions, template, and permission associated to each view are listed in the following table: @@ -102,7 +118,7 @@ in the following table: | | submitted, redirect | | | | | | to /PageName | | | | +----------------------+-----------------------+-------------+----------------+------------+ -| /add_page/PageName | Create the page | add_page | edit.jinja2 | edit | +| /add_page/PageName | Create the page | add_page | edit.jinja2 | create | | | *PageName* in | | | | | | storage, display | | | | | | the edit form | | | | diff --git a/docs/tutorials/wiki2/distributing.rst b/docs/tutorials/wiki2/distributing.rst index fee50a1cf..ec90859a9 100644 --- a/docs/tutorials/wiki2/distributing.rst +++ b/docs/tutorials/wiki2/distributing.rst @@ -4,9 +4,8 @@ Distributing Your Application Once your application works properly, you can create a "tarball" from it by using the ``setup.py sdist`` command. The following commands assume your -current working directory is the ``tutorial`` package we've created and that -the parent directory of the ``tutorial`` package is a virtualenv representing -a :app:`Pyramid` environment. +current working directory contains the ``tutorial`` package and the +``setup.py`` file. On UNIX: diff --git a/docs/tutorials/wiki2/index.rst b/docs/tutorials/wiki2/index.rst index 0a3873dcd..74fb5bfd5 100644 --- a/docs/tutorials/wiki2/index.rst +++ b/docs/tutorials/wiki2/index.rst @@ -22,6 +22,7 @@ which corresponds to the same location if you have Pyramid sources. basiclayout definingmodels definingviews + authentication authorization tests distributing diff --git a/docs/tutorials/wiki2/installation.rst b/docs/tutorials/wiki2/installation.rst index 047c66c06..5d6d8e56b 100644 --- a/docs/tutorials/wiki2/installation.rst +++ b/docs/tutorials/wiki2/installation.rst @@ -298,6 +298,13 @@ Initializing the database We need to use the ``initialize_tutorial_db`` :term:`console script` to initialize our database. +.. note:: + + The ``initialize_tutorial_db`` command is not performing a migration but + rather simply creating missing tables and adding some dummy data. If you + already have a database, you should delete it before running + ``initialize_tutorial_db`` again. + Type the following command, making sure you are still in the ``tutorial`` directory (the directory with a ``development.ini`` in it): @@ -397,12 +404,17 @@ Decisions the ``alchemy`` scaffold has made for you Creating a project using the ``alchemy`` scaffold makes the following assumptions: -- you are willing to use :term:`SQLAlchemy` as a database access tool +- You are willing to use :term:`SQLAlchemy` as a database access tool. + +- You are willing to use :term:`URL dispatch` to map URLs to code. -- you are willing to use :term:`URL dispatch` to map URLs to code +- You want to use zope.sqlalchemy_, pyramid_tm_ and the transaction_ package + to scope sessions to requests. -- you want to use ``zope.sqlalchemy`` and ``pyramid_tm`` to scope - sessions to requests +- You want to use pyramid_jinja2_ to render your templates. + Different templating engines can be used but we had to choose one to + make the tutorial. See :ref:`available_template_system_bindings` for some + options. .. note:: @@ -411,3 +423,15 @@ assumptions: mechanism to map URLs to code (:term:`traversal`). However, for the purposes of this tutorial, we'll only be using URL dispatch and SQLAlchemy. + +.. _pyramid_jinja2: + http://docs.pylonsproject.org/projects/pyramid-jinja2/en/latest/ + +.. _pyramid_tm: + http://docs.pylonsproject.org/projects/pyramid-tm/en/latest/ + +.. _zope.sqlalchemy: + https://pypi.python.org/pypi/zope.sqlalchemy + +.. _transaction: + http://zodb.readthedocs.org/en/latest/transactions.html diff --git a/docs/tutorials/wiki2/src/authentication/CHANGES.txt b/docs/tutorials/wiki2/src/authentication/CHANGES.txt new file mode 100644 index 000000000..35a34f332 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/CHANGES.txt @@ -0,0 +1,4 @@ +0.0 +--- + +- Initial version diff --git a/docs/tutorials/wiki2/src/authentication/MANIFEST.in b/docs/tutorials/wiki2/src/authentication/MANIFEST.in new file mode 100644 index 000000000..42cd299b5 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/MANIFEST.in @@ -0,0 +1,2 @@ +include *.txt *.ini *.cfg *.rst +recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.jinja2 *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/docs/tutorials/wiki2/src/authentication/README.txt b/docs/tutorials/wiki2/src/authentication/README.txt new file mode 100644 index 000000000..68f430110 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/README.txt @@ -0,0 +1,14 @@ +tutorial README +================== + +Getting Started +--------------- + +- cd <directory containing this file> + +- $VENV/bin/python setup.py develop + +- $VENV/bin/initialize_tutorial_db development.ini + +- $VENV/bin/pserve development.ini + diff --git a/docs/tutorials/wiki2/src/authentication/development.ini b/docs/tutorials/wiki2/src/authentication/development.ini new file mode 100644 index 000000000..f3079727e --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/development.ini @@ -0,0 +1,73 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + pyramid_tm + +sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite + +auth.secret = seekrit + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +host = 127.0.0.1 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_tutorial] +level = DEBUG +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = INFO +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/authentication/production.ini b/docs/tutorials/wiki2/src/authentication/production.ini new file mode 100644 index 000000000..686dba48a --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/production.ini @@ -0,0 +1,62 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite + +auth.secret = real-seekrit + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_tutorial] +level = WARN +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/authentication/setup.py b/docs/tutorials/wiki2/src/authentication/setup.py new file mode 100644 index 000000000..57538f2d0 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/setup.py @@ -0,0 +1,54 @@ +import os + +from setuptools import setup, find_packages + +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, 'README.txt')) as f: + README = f.read() +with open(os.path.join(here, 'CHANGES.txt')) as f: + CHANGES = f.read() + +requires = [ + 'bcrypt', + 'docutils', + 'pyramid', + 'pyramid_jinja2', + 'pyramid_debugtoolbar', + 'pyramid_tm', + 'SQLAlchemy', + 'transaction', + 'zope.sqlalchemy', + 'waitress', + ] + +tests_require = [ + 'WebTest', +] + +setup(name='tutorial', + version='0.0', + description='tutorial', + long_description=README + '\n\n' + CHANGES, + classifiers=[ + "Programming Language :: Python", + "Framework :: Pyramid", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + ], + author='', + author_email='', + url='', + keywords='web wsgi bfg pylons pyramid', + packages=find_packages(), + include_package_data=True, + zip_safe=False, + test_suite='tutorial', + tests_require=tests_require, + install_requires=requires, + entry_points="""\ + [paste.app_factory] + main = tutorial:main + [console_scripts] + initialize_tutorial_db = tutorial.scripts.initializedb:main + """, + ) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py b/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py new file mode 100644 index 000000000..f5c033b8b --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py @@ -0,0 +1,13 @@ +from pyramid.config import Configurator + + +def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + config = Configurator(settings=settings) + config.include('pyramid_jinja2') + config.include('.models') + config.include('.routes') + config.include('.security') + config.scan() + return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py new file mode 100644 index 000000000..a8871f6f5 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py @@ -0,0 +1,74 @@ +from sqlalchemy import engine_from_config +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import configure_mappers +import zope.sqlalchemy + +# import or define all models here to ensure they are attached to the +# Base.metadata prior to any initialization routines +from .page import Page # flake8: noqa +from .user import User # flake8: noqa + +# run configure_mappers after defining all of the models to ensure +# all relationships can be setup +configure_mappers() + + +def get_engine(settings, prefix='sqlalchemy.'): + return engine_from_config(settings, prefix) + + +def get_session_factory(engine): + factory = sessionmaker() + factory.configure(bind=engine) + return factory + + +def get_tm_session(session_factory, transaction_manager): + """ + Get a ``sqlalchemy.orm.Session`` instance backed by a transaction. + + This function will hook the session to the transaction manager which + will take care of committing any changes. + + - When using pyramid_tm it will automatically be committed or aborted + depending on whether an exception is raised. + + - When using scripts you should wrap the session in a manager yourself. + For example:: + + import transaction + + engine = get_engine(settings) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + """ + dbsession = session_factory() + zope.sqlalchemy.register( + dbsession, transaction_manager=transaction_manager) + return dbsession + + +def includeme(config): + """ + Initialize the model for a Pyramid app. + + Activate this setup using ``config.include('tutorial.models')``. + + """ + settings = config.get_settings() + + # use pyramid_tm to hook the transaction lifecycle to the request + config.include('pyramid_tm') + + session_factory = get_session_factory(get_engine(settings)) + config.registry['dbsession_factory'] = session_factory + + # make request.dbsession available for use in Pyramid + config.add_request_method( + # r.tm is the transaction manager used by pyramid_tm + lambda r: get_tm_session(session_factory, r.tm), + 'dbsession', + reify=True + ) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/models/meta.py b/docs/tutorials/wiki2/src/authentication/tutorial/models/meta.py new file mode 100644 index 000000000..fc3e8f1dd --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/models/meta.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.schema import MetaData + +# Recommended naming convention used by Alembic, as various different database +# providers will autogenerate vastly different names making migrations more +# difficult. See: http://alembic.readthedocs.org/en/latest/naming.html +NAMING_CONVENTION = { + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" +} + +metadata = MetaData(naming_convention=NAMING_CONVENTION) +Base = declarative_base(metadata=metadata) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/models/page.py b/docs/tutorials/wiki2/src/authentication/tutorial/models/page.py new file mode 100644 index 000000000..4dd5b5721 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/models/page.py @@ -0,0 +1,20 @@ +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + Text, +) +from sqlalchemy.orm import relationship + +from .meta import Base + + +class Page(Base): + """ The SQLAlchemy declarative model class for a Page object. """ + __tablename__ = 'pages' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + data = Column(Integer, nullable=False) + + creator_id = Column(ForeignKey('users.id'), nullable=False) + creator = relationship('User', backref='created_pages') diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/models/user.py b/docs/tutorials/wiki2/src/authentication/tutorial/models/user.py new file mode 100644 index 000000000..6fb32a1b2 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/models/user.py @@ -0,0 +1,29 @@ +import bcrypt +from sqlalchemy import ( + Column, + Integer, + Text, +) + +from .meta import Base + + +class User(Base): + """ The SQLAlchemy declarative model class for a User object. """ + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + role = Column(Text, nullable=False) + + password_hash = Column(Text) + + def set_password(self, pw): + pwhash = bcrypt.hashpw(pw.encode('utf8'), bcrypt.gensalt()) + self.password_hash = pwhash + + def check_password(self, pw): + if self.password_hash is not None: + expected_hash = self.password_hash.encode('utf8') + actual_hash = bcrypt.hashpw(pw.encode('utf8'), expected_hash) + return expected_hash == actual_hash + return False diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/routes.py b/docs/tutorials/wiki2/src/authentication/tutorial/routes.py new file mode 100644 index 000000000..cb747244f --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/routes.py @@ -0,0 +1,8 @@ +def includeme(config): + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('view_wiki', '/') + config.add_route('login', '/login') + config.add_route('logout', '/logout') + config.add_route('view_page', '/{pagename}') + config.add_route('add_page', '/add_page/{pagename}') + config.add_route('edit_page', '/{pagename}/edit_page') diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/security/__init__.py b/docs/tutorials/wiki2/src/authentication/tutorial/scripts/__init__.py index 5bb534f79..5bb534f79 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/security/__init__.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/scripts/__init__.py diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initializedb.py b/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initializedb.py new file mode 100644 index 000000000..f3c0a6fef --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initializedb.py @@ -0,0 +1,57 @@ +import os +import sys +import transaction + +from pyramid.paster import ( + get_appsettings, + setup_logging, + ) + +from pyramid.scripts.common import parse_vars + +from ..models.meta import Base +from ..models import ( + get_engine, + get_session_factory, + get_tm_session, + ) +from ..models import Page, User + + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s <config_uri> [var=value]\n' + '(example: "%s development.ini")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) < 2: + usage(argv) + config_uri = argv[1] + options = parse_vars(argv[2:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + + engine = get_engine(settings) + Base.metadata.create_all(engine) + + session_factory = get_session_factory(engine) + + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + editor = User(name='editor', role='editor') + editor.set_password('editor') + dbsession.add(editor) + + basic = User(name='basic', role='basic') + basic.set_password('basic') + dbsession.add(basic) + + page = Page( + name='FrontPage', + creator=editor, + data='This is the front page', + ) + dbsession.add(page) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/security.py b/docs/tutorials/wiki2/src/authentication/tutorial/security.py new file mode 100644 index 000000000..8ea3858d2 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/security.py @@ -0,0 +1,27 @@ +from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.authorization import ACLAuthorizationPolicy + +from .models import User + + +class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): + def authenticated_userid(self, request): + user = request.user + if user is not None: + return user.id + +def get_user(request): + user_id = request.unauthenticated_userid + if user_id is not None: + user = request.dbsession.query(User).get(user_id) + return user + +def includeme(config): + settings = config.get_settings() + authn_policy = MyAuthenticationPolicy( + settings['auth.secret'], + hashalg='sha512', + ) + config.set_authentication_policy(authn_policy) + config.set_authorization_policy(ACLAuthorizationPolicy()) + config.add_request_method(get_user, 'user', reify=True) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/static/pyramid-16x16.png b/docs/tutorials/wiki2/src/authentication/tutorial/static/pyramid-16x16.png Binary files differnew file mode 100644 index 000000000..979203112 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/static/pyramid-16x16.png diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/static/pyramid.png b/docs/tutorials/wiki2/src/authentication/tutorial/static/pyramid.png Binary files differnew file mode 100644 index 000000000..4ab837be9 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/static/pyramid.png diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/static/theme.css b/docs/tutorials/wiki2/src/authentication/tutorial/static/theme.css new file mode 100644 index 000000000..0f4b1a4d4 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/static/theme.css @@ -0,0 +1,154 @@ +@import url(//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700); +body { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 300; + color: #ffffff; + background: #bc2131; +} +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 300; +} +p { + font-weight: 300; +} +.font-normal { + font-weight: 400; +} +.font-semi-bold { + font-weight: 600; +} +.font-bold { + font-weight: 700; +} +.starter-template { + margin-top: 250px; +} +.starter-template .content { + margin-left: 10px; +} +.starter-template .content h1 { + margin-top: 10px; + font-size: 60px; +} +.starter-template .content h1 .smaller { + font-size: 40px; + color: #f2b7bd; +} +.starter-template .content .lead { + font-size: 25px; + color: #f2b7bd; +} +.starter-template .content .lead .font-normal { + color: #ffffff; +} +.starter-template .links { + float: right; + right: 0; + margin-top: 125px; +} +.starter-template .links ul { + display: block; + padding: 0; + margin: 0; +} +.starter-template .links ul li { + list-style: none; + display: inline; + margin: 0 10px; +} +.starter-template .links ul li:first-child { + margin-left: 0; +} +.starter-template .links ul li:last-child { + margin-right: 0; +} +.starter-template .links ul li.current-version { + color: #f2b7bd; + font-weight: 400; +} +.starter-template .links ul li a, a { + color: #f2b7bd; + text-decoration: underline; +} +.starter-template .links ul li a:hover, a:hover { + color: #ffffff; + text-decoration: underline; +} +.starter-template .links ul li .icon-muted { + color: #eb8b95; + margin-right: 5px; +} +.starter-template .links ul li:hover .icon-muted { + color: #ffffff; +} +.starter-template .copyright { + margin-top: 10px; + font-size: 0.9em; + color: #f2b7bd; + text-transform: lowercase; + float: right; + right: 0; +} +@media (max-width: 1199px) { + .starter-template .content h1 { + font-size: 45px; + } + .starter-template .content h1 .smaller { + font-size: 30px; + } + .starter-template .content .lead { + font-size: 20px; + } +} +@media (max-width: 991px) { + .starter-template { + margin-top: 0; + } + .starter-template .logo { + margin: 40px auto; + } + .starter-template .content { + margin-left: 0; + text-align: center; + } + .starter-template .content h1 { + margin-bottom: 20px; + } + .starter-template .links { + float: none; + text-align: center; + margin-top: 60px; + } + .starter-template .copyright { + float: none; + text-align: center; + } +} +@media (max-width: 767px) { + .starter-template .content h1 .smaller { + font-size: 25px; + display: block; + } + .starter-template .content .lead { + font-size: 16px; + } + .starter-template .links { + margin-top: 40px; + } + .starter-template .links ul li { + display: block; + margin: 0; + } + .starter-template .links ul li .icon-muted { + display: none; + } + .starter-template .copyright { + margin-top: 20px; + } +} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/static/theme.min.css b/docs/tutorials/wiki2/src/authentication/tutorial/static/theme.min.css new file mode 100644 index 000000000..0d25de5b6 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/static/theme.min.css @@ -0,0 +1 @@ +@import url(//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700);body{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;color:#fff;background:#bc2131}h1,h2,h3,h4,h5,h6{font-family:"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300}p{font-weight:300}.font-normal{font-weight:400}.font-semi-bold{font-weight:600}.font-bold{font-weight:700}.starter-template{margin-top:250px}.starter-template .content{margin-left:10px}.starter-template .content h1{margin-top:10px;font-size:60px}.starter-template .content h1 .smaller{font-size:40px;color:#f2b7bd}.starter-template .content .lead{font-size:25px;color:#f2b7bd}.starter-template .content .lead .font-normal{color:#fff}.starter-template .links{float:right;right:0;margin-top:125px}.starter-template .links ul{display:block;padding:0;margin:0}.starter-template .links ul li{list-style:none;display:inline;margin:0 10px}.starter-template .links ul li:first-child{margin-left:0}.starter-template .links ul li:last-child{margin-right:0}.starter-template .links ul li.current-version{color:#f2b7bd;font-weight:400}.starter-template .links ul li a,a{color:#f2b7bd;text-decoration:underline}.starter-template .links ul li a:hover,a:hover{color:#fff;text-decoration:underline}.starter-template .links ul li .icon-muted{color:#eb8b95;margin-right:5px}.starter-template .links ul li:hover .icon-muted{color:#fff}.starter-template .copyright{margin-top:10px;font-size:.9em;color:#f2b7bd;text-transform:lowercase;float:right;right:0}@media (max-width:1199px){.starter-template .content h1{font-size:45px}.starter-template .content h1 .smaller{font-size:30px}.starter-template .content .lead{font-size:20px}}@media (max-width:991px){.starter-template{margin-top:0}.starter-template .logo{margin:40px auto}.starter-template .content{margin-left:0;text-align:center}.starter-template .content h1{margin-bottom:20px}.starter-template .links{float:none;text-align:center;margin-top:60px}.starter-template .copyright{float:none;text-align:center}}@media (max-width:767px){.starter-template .content h1 .smaller{font-size:25px;display:block}.starter-template .content .lead{font-size:16px}.starter-template .links{margin-top:40px}.starter-template .links ul li{display:block;margin:0}.starter-template .links ul li .icon-muted{display:none}.starter-template .copyright{margin-top:20px}} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 new file mode 100644 index 000000000..37b0a16b6 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 @@ -0,0 +1,8 @@ +{% extends "layout.jinja2" %} + +{% block content %} +<div class="content"> + <h1><span class="font-semi-bold">Pyramid tutorial wiki</span> <span class="smaller">(based on TurboGears 20-Minute Wiki)</span></h1> + <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> +</div> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 new file mode 100644 index 000000000..7db25c674 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 @@ -0,0 +1,20 @@ +{% extends 'layout.jinja2' %} + +{% block subtitle %}Edit {{pagename}} - {% endblock subtitle %} + +{% block content %} +<p> +Editing <strong>{{pagename}}</strong> +</p> +<p>You can return to the +<a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. +</p> +<form action="{{ save_url }}" method="post"> +<div class="form-group"> + <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> +</div> +<div class="form-group"> + <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> +</div> +</form> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 new file mode 100644 index 000000000..44d14304e --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<html lang="{{request.locale_name}}"> + <head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="description" content="pyramid web application"> + <meta name="author" content="Pylons Project"> + <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> + + <title>{% block subtitle %}{% endblock %}Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> + + <!-- Bootstrap core CSS --> + <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> + + <!-- Custom styles for this scaffold --> + <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> + + <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> + <!--[if lt IE 9]> + <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> + <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> + <![endif]--> + </head> + + <body> + + <div class="starter-template"> + <div class="container"> + <div class="row"> + <div class="col-md-2"> + <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> + </div> + <div class="col-md-10"> + <div class="content"> + {% if request.user is none %} + <p class="pull-right"> + <a href="{{ request.route_url('login') }}">Login</a> + </p> + {% else %} + <p class="pull-right"> + {{request.user.name}} <a href="{{request.route_url('logout')}}">Logout</a> + </p> + {% endif %} + {% block content %}{% endblock %} + </div> + </div> + </div> + <div class="row"> + <div class="copyright"> + Copyright © Pylons Project + </div> + </div> + </div> + </div> + + + <!-- Bootstrap core JavaScript + ================================================== --> + <!-- Placed at the end of the document so the pages load faster --> + <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> + <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> + </body> +</html> diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 new file mode 100644 index 000000000..1806de0ff --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 @@ -0,0 +1,26 @@ +{% extends 'layout.jinja2' %} + +{% block title %}Login - {% endblock title %} + +{% block content %} +<p> +<strong> + Login +</strong><br> +{{ message }} +</p> +<form action="{{ url }}" method="post"> +<input type="hidden" name="next" value="{{ next_url }}"> +<div class="form-group"> + <label for="login">Username</label> + <input type="text" name="login" value="{{ login }}"> +</div> +<div class="form-group"> + <label for="password">Password</label> + <input type="password" name="password"> +</div> +<div class="form-group"> + <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> +</div> +</form> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/view.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/view.jinja2 new file mode 100644 index 000000000..94419e228 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/view.jinja2 @@ -0,0 +1,18 @@ +{% extends 'layout.jinja2' %} + +{% block subtitle %}{{page.name}} - {% endblock subtitle %} + +{% block content %} +<p>{{ content|safe }}</p> +<p> +<a href="{{ edit_url }}"> + Edit this page +</a> +</p> +<p> + Viewing <strong>{{page.name}}</strong>, created by <strong>{{page.creator.name}}</strong>. +</p> +<p>You can return to the +<a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. +</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/tests.py b/docs/tutorials/wiki2/src/authentication/tutorial/tests.py new file mode 100644 index 000000000..c54945c28 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/tests.py @@ -0,0 +1,65 @@ +import unittest +import transaction + +from pyramid import testing + + +def dummy_request(dbsession): + return testing.DummyRequest(dbsession=dbsession) + + +class BaseTest(unittest.TestCase): + def setUp(self): + self.config = testing.setUp(settings={ + 'sqlalchemy.url': 'sqlite:///:memory:' + }) + self.config.include('.models') + settings = self.config.get_settings() + + from .models import ( + get_engine, + get_session_factory, + get_tm_session, + ) + + self.engine = get_engine(settings) + session_factory = get_session_factory(self.engine) + + self.session = get_tm_session(session_factory, transaction.manager) + + def init_database(self): + from .models import Base + Base.metadata.create_all(self.engine) + + def tearDown(self): + from .models.meta import Base + + testing.tearDown() + transaction.abort() + Base.metadata.drop_all(self.engine) + + +class TestMyViewSuccessCondition(BaseTest): + + def setUp(self): + super(TestMyViewSuccessCondition, self).setUp() + self.init_database() + + from .models import MyModel + + model = MyModel(name='one', value=55) + self.session.add(model) + + def test_passing_view(self): + from .views.default import my_view + info = my_view(dummy_request(self.session)) + self.assertEqual(info['one'].name, 'one') + self.assertEqual(info['project'], 'tutorial') + + +class TestMyViewFailureCondition(BaseTest): + + def test_failing_view(self): + from .views.default import my_view + info = my_view(dummy_request(self.session)) + self.assertEqual(info.status_int, 500) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/__init__.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/__init__.py diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py new file mode 100644 index 000000000..2b993b430 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py @@ -0,0 +1,46 @@ +from pyramid.httpexceptions import HTTPFound +from pyramid.security import ( + remember, + forget, + ) +from pyramid.view import ( + forbidden_view_config, + view_config, +) + +from ..models import User + + +@view_config(route_name='login', renderer='../templates/login.jinja2') +def login(request): + next_url = request.params.get('next', request.referrer) + if not next_url: + next_url = request.route_url('view_wiki') + message = '' + login = '' + if 'form.submitted' in request.params: + login = request.params['login'] + password = request.params['password'] + user = request.dbsession.query(User).filter_by(name=login).first() + if user is not None and user.check_password(password): + headers = remember(request, user.id) + return HTTPFound(location=next_url, headers=headers) + message = 'Failed login' + + return dict( + message=message, + url=request.route_url('login'), + next_url=next_url, + login=login, + ) + +@view_config(route_name='logout') +def logout(request): + headers = forget(request) + next_url = request.route_url('view_wiki') + return HTTPFound(location=next_url, headers=headers) + +@forbidden_view_config() +def forbidden_view(request): + next_url = request.route_url('login', _query={'next': request.url}) + return HTTPFound(location=next_url) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py new file mode 100644 index 000000000..ffe967f6e --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py @@ -0,0 +1,76 @@ +import cgi +import re +from docutils.core import publish_parts + +from pyramid.httpexceptions import ( + HTTPForbidden, + HTTPFound, + HTTPNotFound, + ) + +from pyramid.view import view_config + +from ..models import Page + +# regular expression used to find WikiWords +wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") + +@view_config(route_name='view_wiki') +def view_wiki(request): + next_url = request.route_url('view_page', pagename='FrontPage') + return HTTPFound(location=next_url) + +@view_config(route_name='view_page', renderer='../templates/view.jinja2') +def view_page(request): + pagename = request.matchdict['pagename'] + page = request.dbsession.query(Page).filter_by(name=pagename).first() + if page is None: + raise HTTPNotFound('No such page') + + def add_link(match): + word = match.group(1) + exists = request.dbsession.query(Page).filter_by(name=word).all() + if exists: + view_url = request.route_url('view_page', pagename=word) + return '<a href="%s">%s</a>' % (view_url, cgi.escape(word)) + else: + add_url = request.route_url('add_page', pagename=word) + return '<a href="%s">%s</a>' % (add_url, cgi.escape(word)) + + content = publish_parts(page.data, writer_name='html')['html_body'] + content = wikiwords.sub(add_link, content) + edit_url = request.route_url('edit_page', pagename=page.name) + return dict(page=page, content=content, edit_url=edit_url) + +@view_config(route_name='edit_page', renderer='../templates/edit.jinja2') +def edit_page(request): + pagename = request.matchdict['pagename'] + page = request.dbsession.query(Page).filter_by(name=pagename).one() + user = request.user + if user is None or (user.role != 'editor' and page.creator != user): + raise HTTPForbidden + if 'form.submitted' in request.params: + page.data = request.params['body'] + next_url = request.route_url('view_page', pagename=page.name) + return HTTPFound(location=next_url) + return dict( + pagename=page.name, + pagedata=page.data, + save_url=request.route_url('edit_page', pagename=page.name), + ) + +@view_config(route_name='add_page', renderer='../templates/edit.jinja2') +def add_page(request): + user = request.user + if user is None or user.role not in ('editor', 'basic'): + raise HTTPForbidden + pagename = request.matchdict['pagename'] + if 'form.submitted' in request.params: + body = request.params['body'] + page = Page(name=pagename, data=body) + page.creator = request.user + request.dbsession.add(page) + next_url = request.route_url('view_page', pagename=pagename) + return HTTPFound(location=next_url) + save_url = request.route_url('add_page', pagename=pagename) + return dict(pagename=pagename, pagedata='', save_url=save_url) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/notfound.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/notfound.py new file mode 100644 index 000000000..69d6e2804 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/notfound.py @@ -0,0 +1,7 @@ +from pyramid.view import notfound_view_config + + +@notfound_view_config(renderer='../templates/404.jinja2') +def notfound_view(request): + request.response.status = 404 + return {} diff --git a/docs/tutorials/wiki2/src/authorization/MANIFEST.in b/docs/tutorials/wiki2/src/authorization/MANIFEST.in index 81beba1b1..42cd299b5 100644 --- a/docs/tutorials/wiki2/src/authorization/MANIFEST.in +++ b/docs/tutorials/wiki2/src/authorization/MANIFEST.in @@ -1,2 +1,2 @@ include *.txt *.ini *.cfg *.rst -recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml +recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.jinja2 *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/docs/tutorials/wiki2/src/authorization/development.ini b/docs/tutorials/wiki2/src/authorization/development.ini index 99c4ff0fe..f3079727e 100644 --- a/docs/tutorials/wiki2/src/authorization/development.ini +++ b/docs/tutorials/wiki2/src/authorization/development.ini @@ -17,6 +17,8 @@ pyramid.includes = sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite +auth.secret = seekrit + # By default, the toolbar only appears for clients from IP addresses # '127.0.0.1' and '::1'. # debugtoolbar.hosts = 127.0.0.1 ::1 diff --git a/docs/tutorials/wiki2/src/authorization/production.ini b/docs/tutorials/wiki2/src/authorization/production.ini index 97acfbd7d..686dba48a 100644 --- a/docs/tutorials/wiki2/src/authorization/production.ini +++ b/docs/tutorials/wiki2/src/authorization/production.ini @@ -11,11 +11,11 @@ pyramid.debug_authorization = false pyramid.debug_notfound = false pyramid.debug_routematch = false pyramid.default_locale_name = en -pyramid.includes = - pyramid_tm sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite +auth.secret = real-seekrit + [server:main] use = egg:waitress#main host = 0.0.0.0 diff --git a/docs/tutorials/wiki2/src/authorization/setup.py b/docs/tutorials/wiki2/src/authorization/setup.py index d4e5a4072..57538f2d0 100644 --- a/docs/tutorials/wiki2/src/authorization/setup.py +++ b/docs/tutorials/wiki2/src/authorization/setup.py @@ -9,6 +9,8 @@ with open(os.path.join(here, 'CHANGES.txt')) as f: CHANGES = f.read() requires = [ + 'bcrypt', + 'docutils', 'pyramid', 'pyramid_jinja2', 'pyramid_debugtoolbar', @@ -17,9 +19,12 @@ requires = [ 'transaction', 'zope.sqlalchemy', 'waitress', - 'docutils', ] +tests_require = [ + 'WebTest', +] + setup(name='tutorial', version='0.0', description='tutorial', @@ -38,6 +43,7 @@ setup(name='tutorial', include_package_data=True, zip_safe=False, test_suite='tutorial', + tests_require=tests_require, install_requires=requires, entry_points="""\ [paste.app_factory] diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/__init__.py b/docs/tutorials/wiki2/src/authorization/tutorial/__init__.py index 084fee19f..f5c033b8b 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/__init__.py @@ -1,27 +1,13 @@ from pyramid.config import Configurator -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy -from security.default import groupfinder def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - authn_policy = AuthTktAuthenticationPolicy( - 'sosecret', callback=groupfinder, hashalg='sha512') - authz_policy = ACLAuthorizationPolicy() - config = Configurator(settings=settings, - root_factory='tutorial.models.mymodel.RootFactory') - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(authz_policy) + config = Configurator(settings=settings) config.include('pyramid_jinja2') - config.include('.models.meta') - config.add_static_view('static', 'static', cache_max_age=3600) - config.add_route('view_wiki', '/') - config.add_route('login', '/login') - config.add_route('logout', '/logout') - config.add_route('view_page', '/{pagename}') - config.add_route('add_page', '/add_page/{pagename}') - config.add_route('edit_page', '/{pagename}/edit_page') + config.include('.models') + config.include('.routes') + config.include('.security') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/authorization/tutorial/models/__init__.py index 7b1c62867..a8871f6f5 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/models/__init__.py @@ -1,7 +1,74 @@ +from sqlalchemy import engine_from_config +from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import configure_mappers -# import all models classes here for sqlalchemy mappers -# to pick up -from .mymodel import Page # flake8: noqa +import zope.sqlalchemy -# run configure mappers to ensure we avoid any race conditions +# import or define all models here to ensure they are attached to the +# Base.metadata prior to any initialization routines +from .page import Page # flake8: noqa +from .user import User # flake8: noqa + +# run configure_mappers after defining all of the models to ensure +# all relationships can be setup configure_mappers() + + +def get_engine(settings, prefix='sqlalchemy.'): + return engine_from_config(settings, prefix) + + +def get_session_factory(engine): + factory = sessionmaker() + factory.configure(bind=engine) + return factory + + +def get_tm_session(session_factory, transaction_manager): + """ + Get a ``sqlalchemy.orm.Session`` instance backed by a transaction. + + This function will hook the session to the transaction manager which + will take care of committing any changes. + + - When using pyramid_tm it will automatically be committed or aborted + depending on whether an exception is raised. + + - When using scripts you should wrap the session in a manager yourself. + For example:: + + import transaction + + engine = get_engine(settings) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + """ + dbsession = session_factory() + zope.sqlalchemy.register( + dbsession, transaction_manager=transaction_manager) + return dbsession + + +def includeme(config): + """ + Initialize the model for a Pyramid app. + + Activate this setup using ``config.include('tutorial.models')``. + + """ + settings = config.get_settings() + + # use pyramid_tm to hook the transaction lifecycle to the request + config.include('pyramid_tm') + + session_factory = get_session_factory(get_engine(settings)) + config.registry['dbsession_factory'] = session_factory + + # make request.dbsession available for use in Pyramid + config.add_request_method( + # r.tm is the transaction manager used by pyramid_tm + lambda r: get_tm_session(session_factory, r.tm), + 'dbsession', + reify=True + ) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/models/meta.py b/docs/tutorials/wiki2/src/authorization/tutorial/models/meta.py index 80ececd8c..fc3e8f1dd 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/models/meta.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/models/meta.py @@ -1,8 +1,5 @@ -from sqlalchemy import engine_from_config from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker from sqlalchemy.schema import MetaData -import zope.sqlalchemy # Recommended naming convention used by Alembic, as various different database # providers will autogenerate vastly different names making migrations more @@ -17,33 +14,3 @@ NAMING_CONVENTION = { metadata = MetaData(naming_convention=NAMING_CONVENTION) Base = declarative_base(metadata=metadata) - - -def includeme(config): - settings = config.get_settings() - dbmaker = get_dbmaker(get_engine(settings)) - - config.add_request_method( - lambda r: get_session(r.tm, dbmaker), - 'dbsession', - reify=True - ) - - config.include('pyramid_tm') - - -def get_session(transaction_manager, dbmaker): - dbsession = dbmaker() - zope.sqlalchemy.register(dbsession, - transaction_manager=transaction_manager) - return dbsession - - -def get_engine(settings, prefix='sqlalchemy.'): - return engine_from_config(settings, prefix) - - -def get_dbmaker(engine): - dbmaker = sessionmaker() - dbmaker.configure(bind=engine) - return dbmaker diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/models/mymodel.py b/docs/tutorials/wiki2/src/authorization/tutorial/models/mymodel.py deleted file mode 100644 index 03e2f90ca..000000000 --- a/docs/tutorials/wiki2/src/authorization/tutorial/models/mymodel.py +++ /dev/null @@ -1,26 +0,0 @@ -from .meta import Base - -from pyramid.security import ( - Allow, - Everyone, - ) - -from sqlalchemy import ( - Column, - Integer, - Text, - ) - - -class Page(Base): - """ The SQLAlchemy declarative model class for a Page object. """ - __tablename__ = 'pages' - id = Column(Integer, primary_key=True) - name = Column(Text, unique=True) - data = Column(Integer) - -class RootFactory(object): - __acl__ = [ (Allow, Everyone, 'view'), - (Allow, 'group:editors', 'edit') ] - def __init__(self, request): - pass
\ No newline at end of file diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/models/page.py b/docs/tutorials/wiki2/src/authorization/tutorial/models/page.py new file mode 100644 index 000000000..4dd5b5721 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tutorial/models/page.py @@ -0,0 +1,20 @@ +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + Text, +) +from sqlalchemy.orm import relationship + +from .meta import Base + + +class Page(Base): + """ The SQLAlchemy declarative model class for a Page object. """ + __tablename__ = 'pages' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + data = Column(Integer, nullable=False) + + creator_id = Column(ForeignKey('users.id'), nullable=False) + creator = relationship('User', backref='created_pages') diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/models/user.py b/docs/tutorials/wiki2/src/authorization/tutorial/models/user.py new file mode 100644 index 000000000..6fb32a1b2 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tutorial/models/user.py @@ -0,0 +1,29 @@ +import bcrypt +from sqlalchemy import ( + Column, + Integer, + Text, +) + +from .meta import Base + + +class User(Base): + """ The SQLAlchemy declarative model class for a User object. """ + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + role = Column(Text, nullable=False) + + password_hash = Column(Text) + + def set_password(self, pw): + pwhash = bcrypt.hashpw(pw.encode('utf8'), bcrypt.gensalt()) + self.password_hash = pwhash + + def check_password(self, pw): + if self.password_hash is not None: + expected_hash = self.password_hash.encode('utf8') + actual_hash = bcrypt.hashpw(pw.encode('utf8'), expected_hash) + return expected_hash == actual_hash + return False diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/routes.py b/docs/tutorials/wiki2/src/authorization/tutorial/routes.py new file mode 100644 index 000000000..c7c3a2120 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tutorial/routes.py @@ -0,0 +1,50 @@ +from pyramid.httpexceptions import HTTPNotFound +from pyramid.security import ( + Allow, + Everyone, +) + +from .models import Page + +def includeme(config): + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('view_wiki', '/') + config.add_route('login', '/login') + config.add_route('logout', '/logout') + config.add_route('view_page', '/{pagename}', factory=page_factory) + config.add_route('add_page', '/add_page/{pagename}', + factory=new_page_factory) + config.add_route('edit_page', '/{pagename}/edit_page', + factory=page_factory) + +def new_page_factory(request): + pagename = request.matchdict['pagename'] + return NewPage(pagename) + +class NewPage(object): + def __init__(self, pagename): + self.pagename = pagename + + def __acl__(self): + return [ + (Allow, 'role:editor', 'create'), + (Allow, 'role:basic', 'create'), + ] + +def page_factory(request): + pagename = request.matchdict['pagename'] + page = request.dbsession.query(Page).filter_by(name=pagename).first() + if page is None: + raise HTTPNotFound + return PageResource(page) + +class PageResource(object): + def __init__(self, page): + self.page = page + + def __acl__(self): + return [ + (Allow, Everyone, 'view'), + (Allow, 'role:editor', 'edit'), + (Allow, str(self.page.creator_id), 'edit'), + ] diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/scripts/initializedb.py b/docs/tutorials/wiki2/src/authorization/tutorial/scripts/initializedb.py index 4aac4a848..f3c0a6fef 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/scripts/initializedb.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/scripts/initializedb.py @@ -7,13 +7,15 @@ from pyramid.paster import ( setup_logging, ) -from ..models.meta import ( - Base, - get_session, +from pyramid.scripts.common import parse_vars + +from ..models.meta import Base +from ..models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) -from ..models.mymodel import Page +from ..models import Page, User def usage(argv): @@ -27,16 +29,29 @@ def main(argv=sys.argv): if len(argv) < 2: usage(argv) config_uri = argv[1] + options = parse_vars(argv[2:]) setup_logging(config_uri) - settings = get_appsettings(config_uri) + settings = get_appsettings(config_uri, options=options) engine = get_engine(settings) - dbmaker = get_dbmaker(engine) - - dbsession = get_session(transaction.manager, dbmaker) - Base.metadata.create_all(engine) + session_factory = get_session_factory(engine) + with transaction.manager: - model = Page(name='FrontPage', data='This is the front page') - dbsession.add(model) + dbsession = get_tm_session(session_factory, transaction.manager) + + editor = User(name='editor', role='editor') + editor.set_password('editor') + dbsession.add(editor) + + basic = User(name='basic', role='basic') + basic.set_password('basic') + dbsession.add(basic) + + page = Page( + name='FrontPage', + creator=editor, + data='This is the front page', + ) + dbsession.add(page) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/security.py b/docs/tutorials/wiki2/src/authorization/tutorial/security.py new file mode 100644 index 000000000..25cff7b05 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tutorial/security.py @@ -0,0 +1,40 @@ +from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.security import ( + Authenticated, + Everyone, +) + +from .models import User + + +class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): + def authenticated_userid(self, request): + user = request.user + if user is not None: + return user.id + + def effective_principals(self, request): + principals = [Everyone] + user = request.user + if user is not None: + principals.append(Authenticated) + principals.append(str(user.id)) + principals.append('role:' + user.role) + return principals + +def get_user(request): + user_id = request.unauthenticated_userid + if user_id is not None: + user = request.dbsession.query(User).get(user_id) + return user + +def includeme(config): + settings = config.get_settings() + authn_policy = MyAuthenticationPolicy( + settings['auth.secret'], + hashalg='sha512', + ) + config.set_authentication_policy(authn_policy) + config.set_authorization_policy(ACLAuthorizationPolicy()) + config.add_request_method(get_user, 'user', reify=True) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/security/default.py b/docs/tutorials/wiki2/src/authorization/tutorial/security/default.py deleted file mode 100644 index d88c9c71f..000000000 --- a/docs/tutorials/wiki2/src/authorization/tutorial/security/default.py +++ /dev/null @@ -1,7 +0,0 @@ -USERS = {'editor':'editor', - 'viewer':'viewer'} -GROUPS = {'editor':['group:editors']} - -def groupfinder(userid, request): - if userid in USERS: - return GROUPS.get(userid, []) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/404.jinja2 new file mode 100644 index 000000000..37b0a16b6 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/404.jinja2 @@ -0,0 +1,8 @@ +{% extends "layout.jinja2" %} + +{% block content %} +<div class="content"> + <h1><span class="font-semi-bold">Pyramid tutorial wiki</span> <span class="smaller">(based on TurboGears 20-Minute Wiki)</span></h1> + <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> +</div> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/edit.jinja2 index c4f3a2c93..7db25c674 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/edit.jinja2 @@ -1,73 +1,20 @@ -<!DOCTYPE html> -<html lang="{{request.locale_name}}"> - <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="pyramid web application"> - <meta name="author" content="Pylons Project"> - <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - - <title>Edit{% if page.name %} {{page.name}}{% endif %} - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> - - <!-- Bootstrap core CSS --> - <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> - - <!-- Custom styles for this scaffold --> - <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> - - <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> - <!--[if lt IE 9]> - <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> - <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> - <![endif]--> - </head> - - <body> - - <div class="starter-template"> - <div class="container"> - <div class="row"> - <div class="col-md-2"> - <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> - </div> - <div class="col-md-10"> - <div class="content"> - {% if logged_in %} - <p class="pull-right"> - <a href="{{ request.application_url }}/logout">Logout</a> - </p> - {% endif %} - <p> - Editing <strong>{% if page.name %}{{page.name}}{% else %}Page Name Goes Here{% endif %}</strong> - </p> - <p>You can return to the - <a href="{{request.application_url}}">FrontPage</a>. - </p> - <form action="{{ save_url }}" method="post"> - <div class="form-group"> - <textarea class="form-control" name="body" rows="10" cols="60">{{ page.data }}</textarea> - </div> - <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> - </div> - </form> - </div> - </div> - </div> - <div class="row"> - <div class="copyright"> - Copyright © Pylons Project - </div> - </div> - </div> - </div> - - - <!-- Bootstrap core JavaScript - ================================================== --> - <!-- Placed at the end of the document so the pages load faster --> - <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> - <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> - </body> -</html> +{% extends 'layout.jinja2' %} + +{% block subtitle %}Edit {{pagename}} - {% endblock subtitle %} + +{% block content %} +<p> +Editing <strong>{{pagename}}</strong> +</p> +<p>You can return to the +<a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. +</p> +<form action="{{ save_url }}" method="post"> +<div class="form-group"> + <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> +</div> +<div class="form-group"> + <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> +</div> +</form> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/layout.jinja2 index ff624c65b..44d14304e 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/layout.jinja2 @@ -8,7 +8,7 @@ <meta name="author" content="Pylons Project"> <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - <title>Alchemy Scaffold for The Pyramid Web Framework</title> + <title>{% block subtitle %}{% endblock %}Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> <!-- Bootstrap core CSS --> <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> @@ -32,20 +32,18 @@ <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> </div> <div class="col-md-10"> - {% block content %} - <p>No content</p> - {% endblock content %} - </div> - </div> - <div class="row"> - <div class="links"> - <ul> - <li class="current-version">Generated by v1.7.dev0</li> - <li><i class="glyphicon glyphicon-bookmark icon-muted"></i><a href="http://docs.pylonsproject.org/projects/pyramid/en/1.7-branch/">Docs</a></li> - <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> - <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="irc://irc.freenode.net#pyramid">IRC Channel</a></li> - <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="http://pylonsproject.org">Pylons Project</a></li> - </ul> + <div class="content"> + {% if request.user is none %} + <p class="pull-right"> + <a href="{{ request.route_url('login') }}">Login</a> + </p> + {% else %} + <p class="pull-right"> + {{request.user.name}} <a href="{{request.route_url('logout')}}">Logout</a> + </p> + {% endif %} + {% block content %}{% endblock %} + </div> </div> </div> <div class="row"> diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/login.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/login.jinja2 index a80a2a165..1806de0ff 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/login.jinja2 +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/login.jinja2 @@ -1,74 +1,26 @@ -<!DOCTYPE html> -<html lang="{{request.locale_name}}"> - <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="pyramid web application"> - <meta name="author" content="Pylons Project"> - <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - - <title>Login - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> - - <!-- Bootstrap core CSS --> - <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> - - <!-- Custom styles for this scaffold --> - <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> - - <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> - <!--[if lt IE 9]> - <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> - <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> - <![endif]--> - </head> - - <body> - - <div class="starter-template"> - <div class="container"> - <div class="row"> - <div class="col-md-2"> - <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> - </div> - <div class="col-md-10"> - <div class="content"> - <p> - <strong> - Login - </strong><br> - {{ message }} - </p> - <form action="{{ url }}" method="post"> - <input type="hidden" name="came_from" value="{{ came_from }}"> - <div class="form-group"> - <label for="login">Username</label> - <input type="text" name="login" value="{{ login }}"> - </div> - <div class="form-group"> - <label for="password">Password</label> - <input type="password" name="password" value="{{ password }}"> - </div> - <div class="form-group"> - <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> - </div> - </form> - </div> - </div> - </div> - <div class="row"> - <div class="copyright"> - Copyright © Pylons Project - </div> - </div> - </div> - </div> - - - <!-- Bootstrap core JavaScript - ================================================== --> - <!-- Placed at the end of the document so the pages load faster --> - <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> - <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> - </body> -</html> +{% extends 'layout.jinja2' %} + +{% block title %}Login - {% endblock title %} + +{% block content %} +<p> +<strong> + Login +</strong><br> +{{ message }} +</p> +<form action="{{ url }}" method="post"> +<input type="hidden" name="next" value="{{ next_url }}"> +<div class="form-group"> + <label for="login">Username</label> + <input type="text" name="login" value="{{ login }}"> +</div> +<div class="form-group"> + <label for="password">Password</label> + <input type="password" name="password"> +</div> +<div class="form-group"> + <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> +</div> +</form> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/mytemplate.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/mytemplate.jinja2 deleted file mode 100644 index bb622bf5a..000000000 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/mytemplate.jinja2 +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "layout.jinja2" %} - -{% block content %} -<div class="content"> - <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Alchemy scaffold</span></h1> - <p class="lead">Welcome to <span class="font-normal">{{project}}</span>, an application generated by<br>the <span class="font-normal">Pyramid Web Framework 1.7.dev0</span>.</p> -</div> -{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/view.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/view.jinja2 index a7afc66fc..94419e228 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/view.jinja2 +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/view.jinja2 @@ -1,71 +1,18 @@ -<!DOCTYPE html> -<html lang="{{request.locale_name}}"> - <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="pyramid web application"> - <meta name="author" content="Pylons Project"> - <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - - <title>{{page.name}} - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> - - <!-- Bootstrap core CSS --> - <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> - - <!-- Custom styles for this scaffold --> - <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> - - <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> - <!--[if lt IE 9]> - <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> - <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> - <![endif]--> - </head> - - <body> - - <div class="starter-template"> - <div class="container"> - <div class="row"> - <div class="col-md-2"> - <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> - </div> - <div class="col-md-10"> - <div class="content"> - {% if logged_in %} - <p class="pull-right"> - <a href="{{ request.application_url }}/logout">Logout</a> - </p> - {% endif %} - <p>{{ content|safe }}</p> - <p> - <a href="{{ edit_url }}"> - Edit this page - </a> - </p> - <p> - Viewing <strong>{% if page.name %}{{page.name}}{% else %}Page Name Goes Here{% endif %}</strong> - </p> - <p>You can return to the - <a href="{{request.application_url}}">FrontPage</a>. - </p> - </div> - </div> - </div> - <div class="row"> - <div class="copyright"> - Copyright © Pylons Project - </div> - </div> - </div> - </div> - - - <!-- Bootstrap core JavaScript - ================================================== --> - <!-- Placed at the end of the document so the pages load faster --> - <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> - <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> - </body> -</html> +{% extends 'layout.jinja2' %} + +{% block subtitle %}{{page.name}} - {% endblock subtitle %} + +{% block content %} +<p>{{ content|safe }}</p> +<p> +<a href="{{ edit_url }}"> + Edit this page +</a> +</p> +<p> + Viewing <strong>{{page.name}}</strong>, created by <strong>{{page.creator.name}}</strong>. +</p> +<p>You can return to the +<a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. +</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/tests.py b/docs/tutorials/wiki2/src/authorization/tutorial/tests.py index b947e3bb1..c54945c28 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/tests.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/tests.py @@ -13,22 +13,22 @@ class BaseTest(unittest.TestCase): self.config = testing.setUp(settings={ 'sqlalchemy.url': 'sqlite:///:memory:' }) - self.config.include('.models.meta') + self.config.include('.models') settings = self.config.get_settings() - from .models.meta import ( - get_session, + from .models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) self.engine = get_engine(settings) - dbmaker = get_dbmaker(self.engine) + session_factory = get_session_factory(self.engine) - self.session = get_session(transaction.manager, dbmaker) + self.session = get_tm_session(session_factory, transaction.manager) def init_database(self): - from .models.meta import Base + from .models import Base Base.metadata.create_all(self.engine) def tearDown(self): @@ -36,7 +36,7 @@ class BaseTest(unittest.TestCase): testing.tearDown() transaction.abort() - Base.metadata.create_all(self.engine) + Base.metadata.drop_all(self.engine) class TestMyViewSuccessCondition(BaseTest): @@ -45,7 +45,7 @@ class TestMyViewSuccessCondition(BaseTest): super(TestMyViewSuccessCondition, self).setUp() self.init_database() - from .models.mymodel import MyModel + from .models import MyModel model = MyModel(name='one', value=55) self.session.add(model) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/views/auth.py b/docs/tutorials/wiki2/src/authorization/tutorial/views/auth.py new file mode 100644 index 000000000..2b993b430 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tutorial/views/auth.py @@ -0,0 +1,46 @@ +from pyramid.httpexceptions import HTTPFound +from pyramid.security import ( + remember, + forget, + ) +from pyramid.view import ( + forbidden_view_config, + view_config, +) + +from ..models import User + + +@view_config(route_name='login', renderer='../templates/login.jinja2') +def login(request): + next_url = request.params.get('next', request.referrer) + if not next_url: + next_url = request.route_url('view_wiki') + message = '' + login = '' + if 'form.submitted' in request.params: + login = request.params['login'] + password = request.params['password'] + user = request.dbsession.query(User).filter_by(name=login).first() + if user is not None and user.check_password(password): + headers = remember(request, user.id) + return HTTPFound(location=next_url, headers=headers) + message = 'Failed login' + + return dict( + message=message, + url=request.route_url('login'), + next_url=next_url, + login=login, + ) + +@view_config(route_name='logout') +def logout(request): + headers = forget(request) + next_url = request.route_url('view_wiki') + return HTTPFound(location=next_url, headers=headers) + +@forbidden_view_config() +def forbidden_view(request): + next_url = request.route_url('login', _query={'next': request.url}) + return HTTPFound(location=next_url) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/views/default.py b/docs/tutorials/wiki2/src/authorization/tutorial/views/default.py index f35f041a4..9358993ea 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/views/default.py @@ -2,43 +2,25 @@ import cgi import re from docutils.core import publish_parts -from pyramid.httpexceptions import ( - HTTPFound, - HTTPNotFound, - ) +from pyramid.httpexceptions import HTTPFound +from pyramid.view import view_config -from pyramid.view import ( - view_config, - forbidden_view_config, - ) - -from pyramid.security import ( - remember, - forget, - ) - -from ..security.default import USERS - -from ..models.mymodel import Page +from ..models import Page # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") -@view_config(route_name='view_wiki', - permission='view') +@view_config(route_name='view_wiki') def view_wiki(request): - return HTTPFound(location=request.route_url('view_page', - pagename='FrontPage')) + next_url = request.route_url('view_page', pagename='FrontPage') + return HTTPFound(location=next_url) -@view_config(route_name='view_page', renderer='templates/view.jinja2', +@view_config(route_name='view_page', renderer='../templates/view.jinja2', permission='view') def view_page(request): - pagename = request.matchdict['pagename'] - page = request.dbsession.query(Page).filter_by(name=pagename).first() - if page is None: - return HTTPNotFound('No such page') + page = request.context.page - def check(match): + def add_link(match): word = match.group(1) exists = request.dbsession.query(Page).filter_by(name=word).all() if exists: @@ -49,72 +31,34 @@ def view_page(request): return '<a href="%s">%s</a>' % (add_url, cgi.escape(word)) content = publish_parts(page.data, writer_name='html')['html_body'] - content = wikiwords.sub(check, content) - edit_url = request.route_url('edit_page', pagename=pagename) - return dict(page=page, content=content, edit_url=edit_url, - logged_in=request.authenticated_userid) - -@view_config(route_name='add_page', renderer='templates/edit.jinja2', - permission='edit') -def add_page(request): - pagename = request.matchdict['pagename'] - if 'form.submitted' in request.params: - body = request.params['body'] - page = Page(name=pagename, data=body) - request.dbsession.add(page) - return HTTPFound(location = request.route_url('view_page', - pagename=pagename)) - save_url = request.route_url('add_page', pagename=pagename) - page = Page(name='', data='') - return dict(page=page, save_url=save_url, - logged_in=request.authenticated_userid) + content = wikiwords.sub(add_link, content) + edit_url = request.route_url('edit_page', pagename=page.name) + return dict(page=page, content=content, edit_url=edit_url) -@view_config(route_name='edit_page', renderer='templates/edit.jinja2', +@view_config(route_name='edit_page', renderer='../templates/edit.jinja2', permission='edit') def edit_page(request): - pagename = request.matchdict['pagename'] - page = request.dbsession.query(Page).filter_by(name=pagename).one() + page = request.context.page if 'form.submitted' in request.params: page.data = request.params['body'] - request.dbsession.add(page) - return HTTPFound(location = request.route_url('view_page', - pagename=pagename)) + next_url = request.route_url('view_page', pagename=page.name) + return HTTPFound(location=next_url) return dict( - page=page, - save_url = request.route_url('edit_page', pagename=pagename), - logged_in=request.authenticated_userid + pagename=page.name, + pagedata=page.data, + save_url=request.route_url('edit_page', pagename=page.name), ) -@view_config(route_name='login', renderer='templates/login.jinja2') -@forbidden_view_config(renderer='templates/login.jinja2') -def login(request): - login_url = request.route_url('login') - referrer = request.url - if referrer == login_url: - referrer = '/' # never use the login form itself as came_from - came_from = request.params.get('came_from', referrer) - message = '' - login = '' - password = '' +@view_config(route_name='add_page', renderer='../templates/edit.jinja2', + permission='create') +def add_page(request): + pagename = request.context.pagename if 'form.submitted' in request.params: - login = request.params['login'] - password = request.params['password'] - if USERS.get(login) == password: - headers = remember(request, login) - return HTTPFound(location = came_from, - headers = headers) - message = 'Failed login' - - return dict( - message = message, - url = request.application_url + '/login', - came_from = came_from, - login = login, - password = password, - ) - -@view_config(route_name='logout') -def logout(request): - headers = forget(request) - return HTTPFound(location = request.route_url('view_wiki'), - headers = headers) + body = request.params['body'] + page = Page(name=pagename, data=body) + page.creator = request.user + request.dbsession.add(page) + next_url = request.route_url('view_page', pagename=pagename) + return HTTPFound(location=next_url) + save_url = request.route_url('add_page', pagename=pagename) + return dict(pagename=pagename, pagedata='', save_url=save_url) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/views/notfound.py b/docs/tutorials/wiki2/src/authorization/tutorial/views/notfound.py new file mode 100644 index 000000000..69d6e2804 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tutorial/views/notfound.py @@ -0,0 +1,7 @@ +from pyramid.view import notfound_view_config + + +@notfound_view_config(renderer='../templates/404.jinja2') +def notfound_view(request): + request.response.status = 404 + return {} diff --git a/docs/tutorials/wiki2/src/basiclayout/MANIFEST.in b/docs/tutorials/wiki2/src/basiclayout/MANIFEST.in index 81beba1b1..42cd299b5 100644 --- a/docs/tutorials/wiki2/src/basiclayout/MANIFEST.in +++ b/docs/tutorials/wiki2/src/basiclayout/MANIFEST.in @@ -1,2 +1,2 @@ include *.txt *.ini *.cfg *.rst -recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml +recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.jinja2 *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/docs/tutorials/wiki2/src/basiclayout/production.ini b/docs/tutorials/wiki2/src/basiclayout/production.ini index 97acfbd7d..cb1db3211 100644 --- a/docs/tutorials/wiki2/src/basiclayout/production.ini +++ b/docs/tutorials/wiki2/src/basiclayout/production.ini @@ -11,8 +11,6 @@ pyramid.debug_authorization = false pyramid.debug_notfound = false pyramid.debug_routematch = false pyramid.default_locale_name = en -pyramid.includes = - pyramid_tm sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite diff --git a/docs/tutorials/wiki2/src/basiclayout/setup.py b/docs/tutorials/wiki2/src/basiclayout/setup.py index eb771010f..7bc697730 100644 --- a/docs/tutorials/wiki2/src/basiclayout/setup.py +++ b/docs/tutorials/wiki2/src/basiclayout/setup.py @@ -19,6 +19,10 @@ requires = [ 'waitress', ] +tests_require = [ + 'WebTest', +] + setup(name='tutorial', version='0.0', description='tutorial', @@ -37,6 +41,7 @@ setup(name='tutorial', include_package_data=True, zip_safe=False, test_suite='tutorial', + tests_require=tests_require, install_requires=requires, entry_points="""\ [paste.app_factory] diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py index 7994bbfa8..4dab44823 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py @@ -6,8 +6,7 @@ def main(global_config, **settings): """ config = Configurator(settings=settings) config.include('pyramid_jinja2') - config.include('.models.meta') - config.add_static_view('static', 'static', cache_max_age=3600) - config.add_route('home', '/') + config.include('.models') + config.include('.routes') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py index 6ffc10a78..48a957ecb 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py @@ -1,7 +1,73 @@ +from sqlalchemy import engine_from_config +from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import configure_mappers -# import all models classes here for sqlalchemy mappers -# to pick up +import zope.sqlalchemy + +# import or define all models here to ensure they are attached to the +# Base.metadata prior to any initialization routines from .mymodel import MyModel # flake8: noqa -# run configure mappers to ensure we avoid any race conditions +# run configure_mappers after defining all of the models to ensure +# all relationships can be setup configure_mappers() + + +def get_engine(settings, prefix='sqlalchemy.'): + return engine_from_config(settings, prefix) + + +def get_session_factory(engine): + factory = sessionmaker() + factory.configure(bind=engine) + return factory + + +def get_tm_session(session_factory, transaction_manager): + """ + Get a ``sqlalchemy.orm.Session`` instance backed by a transaction. + + This function will hook the session to the transaction manager which + will take care of committing any changes. + + - When using pyramid_tm it will automatically be committed or aborted + depending on whether an exception is raised. + + - When using scripts you should wrap the session in a manager yourself. + For example:: + + import transaction + + engine = get_engine(settings) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + """ + dbsession = session_factory() + zope.sqlalchemy.register( + dbsession, transaction_manager=transaction_manager) + return dbsession + + +def includeme(config): + """ + Initialize the model for a Pyramid app. + + Activate this setup using ``config.include('tutorial.models')``. + + """ + settings = config.get_settings() + + # use pyramid_tm to hook the transaction lifecycle to the request + config.include('pyramid_tm') + + session_factory = get_session_factory(get_engine(settings)) + config.registry['dbsession_factory'] = session_factory + + # make request.dbsession available for use in Pyramid + config.add_request_method( + # r.tm is the transaction manager used by pyramid_tm + lambda r: get_tm_session(session_factory, r.tm), + 'dbsession', + reify=True + ) diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/meta.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/meta.py index 80ececd8c..fc3e8f1dd 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/meta.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/meta.py @@ -1,8 +1,5 @@ -from sqlalchemy import engine_from_config from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker from sqlalchemy.schema import MetaData -import zope.sqlalchemy # Recommended naming convention used by Alembic, as various different database # providers will autogenerate vastly different names making migrations more @@ -17,33 +14,3 @@ NAMING_CONVENTION = { metadata = MetaData(naming_convention=NAMING_CONVENTION) Base = declarative_base(metadata=metadata) - - -def includeme(config): - settings = config.get_settings() - dbmaker = get_dbmaker(get_engine(settings)) - - config.add_request_method( - lambda r: get_session(r.tm, dbmaker), - 'dbsession', - reify=True - ) - - config.include('pyramid_tm') - - -def get_session(transaction_manager, dbmaker): - dbsession = dbmaker() - zope.sqlalchemy.register(dbsession, - transaction_manager=transaction_manager) - return dbsession - - -def get_engine(settings, prefix='sqlalchemy.'): - return engine_from_config(settings, prefix) - - -def get_dbmaker(engine): - dbmaker = sessionmaker() - dbmaker.configure(bind=engine) - return dbmaker diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/mymodel.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/mymodel.py index 5a2b5890c..d65a01a42 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/mymodel.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/mymodel.py @@ -1,4 +1,3 @@ -from .meta import Base from sqlalchemy import ( Column, Index, @@ -6,6 +5,8 @@ from sqlalchemy import ( Text, ) +from .meta import Base + class MyModel(Base): __tablename__ = 'models' diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/routes.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/routes.py new file mode 100644 index 000000000..25504ad4d --- /dev/null +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/routes.py @@ -0,0 +1,3 @@ +def includeme(config): + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('home', '/') diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/scripts/initializedb.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/scripts/initializedb.py index f0d09729e..7307ecc5c 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/scripts/initializedb.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/scripts/initializedb.py @@ -9,13 +9,13 @@ from pyramid.paster import ( from pyramid.scripts.common import parse_vars -from ..models.meta import ( - Base, - get_session, +from ..models.meta import Base +from ..models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) -from ..models.mymodel import MyModel +from ..models import MyModel def usage(argv): @@ -34,12 +34,12 @@ def main(argv=sys.argv): settings = get_appsettings(config_uri, options=options) engine = get_engine(settings) - dbmaker = get_dbmaker(engine) - - dbsession = get_session(transaction.manager, dbmaker) - Base.metadata.create_all(engine) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + model = MyModel(name='one', value=1) dbsession.add(model) diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/mytemplate.jinja2 b/docs/tutorials/wiki2/src/basiclayout/tutorial/templates/404.jinja2 index bb622bf5a..1917f83c7 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/templates/mytemplate.jinja2 +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/templates/404.jinja2 @@ -3,6 +3,6 @@ {% block content %} <div class="content"> <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Alchemy scaffold</span></h1> - <p class="lead">Welcome to <span class="font-normal">{{project}}</span>, an application generated by<br>the <span class="font-normal">Pyramid Web Framework 1.7.dev0</span>.</p> + <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> </div> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/tests.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/tests.py index b947e3bb1..c54945c28 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/tests.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/tests.py @@ -13,22 +13,22 @@ class BaseTest(unittest.TestCase): self.config = testing.setUp(settings={ 'sqlalchemy.url': 'sqlite:///:memory:' }) - self.config.include('.models.meta') + self.config.include('.models') settings = self.config.get_settings() - from .models.meta import ( - get_session, + from .models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) self.engine = get_engine(settings) - dbmaker = get_dbmaker(self.engine) + session_factory = get_session_factory(self.engine) - self.session = get_session(transaction.manager, dbmaker) + self.session = get_tm_session(session_factory, transaction.manager) def init_database(self): - from .models.meta import Base + from .models import Base Base.metadata.create_all(self.engine) def tearDown(self): @@ -36,7 +36,7 @@ class BaseTest(unittest.TestCase): testing.tearDown() transaction.abort() - Base.metadata.create_all(self.engine) + Base.metadata.drop_all(self.engine) class TestMyViewSuccessCondition(BaseTest): @@ -45,7 +45,7 @@ class TestMyViewSuccessCondition(BaseTest): super(TestMyViewSuccessCondition, self).setUp() self.init_database() - from .models.mymodel import MyModel + from .models import MyModel model = MyModel(name='one', value=55) self.session.add(model) diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py index 13ad8793c..ad0c728d7 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py @@ -3,7 +3,7 @@ from pyramid.view import view_config from sqlalchemy.exc import DBAPIError -from ..models.mymodel import MyModel +from ..models import MyModel @view_config(route_name='home', renderer='../templates/mytemplate.jinja2') @@ -12,7 +12,7 @@ def my_view(request): query = request.dbsession.query(MyModel) one = query.filter(MyModel.name == 'one').first() except DBAPIError: - return Response(db_err_msg, content_type='text/plain', status_int=500) + return Response(db_err_msg, content_type='text/plain', status=500) return {'one': one, 'project': 'tutorial'} diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/views/notfound.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/views/notfound.py new file mode 100644 index 000000000..69d6e2804 --- /dev/null +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/views/notfound.py @@ -0,0 +1,7 @@ +from pyramid.view import notfound_view_config + + +@notfound_view_config(renderer='../templates/404.jinja2') +def notfound_view(request): + request.response.status = 404 + return {} diff --git a/docs/tutorials/wiki2/src/models/MANIFEST.in b/docs/tutorials/wiki2/src/models/MANIFEST.in index 81beba1b1..42cd299b5 100644 --- a/docs/tutorials/wiki2/src/models/MANIFEST.in +++ b/docs/tutorials/wiki2/src/models/MANIFEST.in @@ -1,2 +1,2 @@ include *.txt *.ini *.cfg *.rst -recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml +recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.jinja2 *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/docs/tutorials/wiki2/src/models/production.ini b/docs/tutorials/wiki2/src/models/production.ini index 97acfbd7d..cb1db3211 100644 --- a/docs/tutorials/wiki2/src/models/production.ini +++ b/docs/tutorials/wiki2/src/models/production.ini @@ -11,8 +11,6 @@ pyramid.debug_authorization = false pyramid.debug_notfound = false pyramid.debug_routematch = false pyramid.default_locale_name = en -pyramid.includes = - pyramid_tm sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite diff --git a/docs/tutorials/wiki2/src/models/setup.py b/docs/tutorials/wiki2/src/models/setup.py index eb771010f..bdc9ceed7 100644 --- a/docs/tutorials/wiki2/src/models/setup.py +++ b/docs/tutorials/wiki2/src/models/setup.py @@ -9,6 +9,7 @@ with open(os.path.join(here, 'CHANGES.txt')) as f: CHANGES = f.read() requires = [ + 'bcrypt', 'pyramid', 'pyramid_jinja2', 'pyramid_debugtoolbar', @@ -19,6 +20,10 @@ requires = [ 'waitress', ] +tests_require = [ + 'WebTest', +] + setup(name='tutorial', version='0.0', description='tutorial', @@ -37,6 +42,7 @@ setup(name='tutorial', include_package_data=True, zip_safe=False, test_suite='tutorial', + tests_require=tests_require, install_requires=requires, entry_points="""\ [paste.app_factory] diff --git a/docs/tutorials/wiki2/src/models/tutorial/__init__.py b/docs/tutorials/wiki2/src/models/tutorial/__init__.py index 7994bbfa8..4dab44823 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/models/tutorial/__init__.py @@ -6,8 +6,7 @@ def main(global_config, **settings): """ config = Configurator(settings=settings) config.include('pyramid_jinja2') - config.include('.models.meta') - config.add_static_view('static', 'static', cache_max_age=3600) - config.add_route('home', '/') + config.include('.models') + config.include('.routes') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py index 7b1c62867..a8871f6f5 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py @@ -1,7 +1,74 @@ +from sqlalchemy import engine_from_config +from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import configure_mappers -# import all models classes here for sqlalchemy mappers -# to pick up -from .mymodel import Page # flake8: noqa +import zope.sqlalchemy -# run configure mappers to ensure we avoid any race conditions +# import or define all models here to ensure they are attached to the +# Base.metadata prior to any initialization routines +from .page import Page # flake8: noqa +from .user import User # flake8: noqa + +# run configure_mappers after defining all of the models to ensure +# all relationships can be setup configure_mappers() + + +def get_engine(settings, prefix='sqlalchemy.'): + return engine_from_config(settings, prefix) + + +def get_session_factory(engine): + factory = sessionmaker() + factory.configure(bind=engine) + return factory + + +def get_tm_session(session_factory, transaction_manager): + """ + Get a ``sqlalchemy.orm.Session`` instance backed by a transaction. + + This function will hook the session to the transaction manager which + will take care of committing any changes. + + - When using pyramid_tm it will automatically be committed or aborted + depending on whether an exception is raised. + + - When using scripts you should wrap the session in a manager yourself. + For example:: + + import transaction + + engine = get_engine(settings) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + """ + dbsession = session_factory() + zope.sqlalchemy.register( + dbsession, transaction_manager=transaction_manager) + return dbsession + + +def includeme(config): + """ + Initialize the model for a Pyramid app. + + Activate this setup using ``config.include('tutorial.models')``. + + """ + settings = config.get_settings() + + # use pyramid_tm to hook the transaction lifecycle to the request + config.include('pyramid_tm') + + session_factory = get_session_factory(get_engine(settings)) + config.registry['dbsession_factory'] = session_factory + + # make request.dbsession available for use in Pyramid + config.add_request_method( + # r.tm is the transaction manager used by pyramid_tm + lambda r: get_tm_session(session_factory, r.tm), + 'dbsession', + reify=True + ) diff --git a/docs/tutorials/wiki2/src/models/tutorial/models/meta.py b/docs/tutorials/wiki2/src/models/tutorial/models/meta.py index 80ececd8c..fc3e8f1dd 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/models/meta.py +++ b/docs/tutorials/wiki2/src/models/tutorial/models/meta.py @@ -1,8 +1,5 @@ -from sqlalchemy import engine_from_config from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker from sqlalchemy.schema import MetaData -import zope.sqlalchemy # Recommended naming convention used by Alembic, as various different database # providers will autogenerate vastly different names making migrations more @@ -17,33 +14,3 @@ NAMING_CONVENTION = { metadata = MetaData(naming_convention=NAMING_CONVENTION) Base = declarative_base(metadata=metadata) - - -def includeme(config): - settings = config.get_settings() - dbmaker = get_dbmaker(get_engine(settings)) - - config.add_request_method( - lambda r: get_session(r.tm, dbmaker), - 'dbsession', - reify=True - ) - - config.include('pyramid_tm') - - -def get_session(transaction_manager, dbmaker): - dbsession = dbmaker() - zope.sqlalchemy.register(dbsession, - transaction_manager=transaction_manager) - return dbsession - - -def get_engine(settings, prefix='sqlalchemy.'): - return engine_from_config(settings, prefix) - - -def get_dbmaker(engine): - dbmaker = sessionmaker() - dbmaker.configure(bind=engine) - return dbmaker diff --git a/docs/tutorials/wiki2/src/models/tutorial/models/mymodel.py b/docs/tutorials/wiki2/src/models/tutorial/models/mymodel.py deleted file mode 100644 index 45571d78e..000000000 --- a/docs/tutorials/wiki2/src/models/tutorial/models/mymodel.py +++ /dev/null @@ -1,14 +0,0 @@ -from .meta import Base -from sqlalchemy import ( - Column, - Integer, - Text, -) - - -class Page(Base): - """ The SQLAlchemy declarative model class for a Page object. """ - __tablename__ = 'pages' - id = Column(Integer, primary_key=True) - name = Column(Text, unique=True) - data = Column(Integer) diff --git a/docs/tutorials/wiki2/src/models/tutorial/models/page.py b/docs/tutorials/wiki2/src/models/tutorial/models/page.py new file mode 100644 index 000000000..4dd5b5721 --- /dev/null +++ b/docs/tutorials/wiki2/src/models/tutorial/models/page.py @@ -0,0 +1,20 @@ +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + Text, +) +from sqlalchemy.orm import relationship + +from .meta import Base + + +class Page(Base): + """ The SQLAlchemy declarative model class for a Page object. """ + __tablename__ = 'pages' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + data = Column(Integer, nullable=False) + + creator_id = Column(ForeignKey('users.id'), nullable=False) + creator = relationship('User', backref='created_pages') diff --git a/docs/tutorials/wiki2/src/models/tutorial/models/user.py b/docs/tutorials/wiki2/src/models/tutorial/models/user.py new file mode 100644 index 000000000..6fb32a1b2 --- /dev/null +++ b/docs/tutorials/wiki2/src/models/tutorial/models/user.py @@ -0,0 +1,29 @@ +import bcrypt +from sqlalchemy import ( + Column, + Integer, + Text, +) + +from .meta import Base + + +class User(Base): + """ The SQLAlchemy declarative model class for a User object. """ + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + role = Column(Text, nullable=False) + + password_hash = Column(Text) + + def set_password(self, pw): + pwhash = bcrypt.hashpw(pw.encode('utf8'), bcrypt.gensalt()) + self.password_hash = pwhash + + def check_password(self, pw): + if self.password_hash is not None: + expected_hash = self.password_hash.encode('utf8') + actual_hash = bcrypt.hashpw(pw.encode('utf8'), expected_hash) + return expected_hash == actual_hash + return False diff --git a/docs/tutorials/wiki2/src/models/tutorial/routes.py b/docs/tutorials/wiki2/src/models/tutorial/routes.py new file mode 100644 index 000000000..25504ad4d --- /dev/null +++ b/docs/tutorials/wiki2/src/models/tutorial/routes.py @@ -0,0 +1,3 @@ +def includeme(config): + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('home', '/') diff --git a/docs/tutorials/wiki2/src/models/tutorial/scripts/initializedb.py b/docs/tutorials/wiki2/src/models/tutorial/scripts/initializedb.py index 4aac4a848..f3c0a6fef 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/scripts/initializedb.py +++ b/docs/tutorials/wiki2/src/models/tutorial/scripts/initializedb.py @@ -7,13 +7,15 @@ from pyramid.paster import ( setup_logging, ) -from ..models.meta import ( - Base, - get_session, +from pyramid.scripts.common import parse_vars + +from ..models.meta import Base +from ..models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) -from ..models.mymodel import Page +from ..models import Page, User def usage(argv): @@ -27,16 +29,29 @@ def main(argv=sys.argv): if len(argv) < 2: usage(argv) config_uri = argv[1] + options = parse_vars(argv[2:]) setup_logging(config_uri) - settings = get_appsettings(config_uri) + settings = get_appsettings(config_uri, options=options) engine = get_engine(settings) - dbmaker = get_dbmaker(engine) - - dbsession = get_session(transaction.manager, dbmaker) - Base.metadata.create_all(engine) + session_factory = get_session_factory(engine) + with transaction.manager: - model = Page(name='FrontPage', data='This is the front page') - dbsession.add(model) + dbsession = get_tm_session(session_factory, transaction.manager) + + editor = User(name='editor', role='editor') + editor.set_password('editor') + dbsession.add(editor) + + basic = User(name='basic', role='basic') + basic.set_password('basic') + dbsession.add(basic) + + page = Page( + name='FrontPage', + creator=editor, + data='This is the front page', + ) + dbsession.add(page) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/mytemplate.jinja2 b/docs/tutorials/wiki2/src/models/tutorial/templates/404.jinja2 index bb622bf5a..1917f83c7 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/mytemplate.jinja2 +++ b/docs/tutorials/wiki2/src/models/tutorial/templates/404.jinja2 @@ -3,6 +3,6 @@ {% block content %} <div class="content"> <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Alchemy scaffold</span></h1> - <p class="lead">Welcome to <span class="font-normal">{{project}}</span>, an application generated by<br>the <span class="font-normal">Pyramid Web Framework 1.7.dev0</span>.</p> + <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> </div> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/models/tutorial/tests.py b/docs/tutorials/wiki2/src/models/tutorial/tests.py index b947e3bb1..c54945c28 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/tests.py +++ b/docs/tutorials/wiki2/src/models/tutorial/tests.py @@ -13,22 +13,22 @@ class BaseTest(unittest.TestCase): self.config = testing.setUp(settings={ 'sqlalchemy.url': 'sqlite:///:memory:' }) - self.config.include('.models.meta') + self.config.include('.models') settings = self.config.get_settings() - from .models.meta import ( - get_session, + from .models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) self.engine = get_engine(settings) - dbmaker = get_dbmaker(self.engine) + session_factory = get_session_factory(self.engine) - self.session = get_session(transaction.manager, dbmaker) + self.session = get_tm_session(session_factory, transaction.manager) def init_database(self): - from .models.meta import Base + from .models import Base Base.metadata.create_all(self.engine) def tearDown(self): @@ -36,7 +36,7 @@ class BaseTest(unittest.TestCase): testing.tearDown() transaction.abort() - Base.metadata.create_all(self.engine) + Base.metadata.drop_all(self.engine) class TestMyViewSuccessCondition(BaseTest): @@ -45,7 +45,7 @@ class TestMyViewSuccessCondition(BaseTest): super(TestMyViewSuccessCondition, self).setUp() self.init_database() - from .models.mymodel import MyModel + from .models import MyModel model = MyModel(name='one', value=55) self.session.add(model) diff --git a/docs/tutorials/wiki2/src/models/tutorial/views/default.py b/docs/tutorials/wiki2/src/models/tutorial/views/default.py index 13ad8793c..ad0c728d7 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/models/tutorial/views/default.py @@ -3,7 +3,7 @@ from pyramid.view import view_config from sqlalchemy.exc import DBAPIError -from ..models.mymodel import MyModel +from ..models import MyModel @view_config(route_name='home', renderer='../templates/mytemplate.jinja2') @@ -12,7 +12,7 @@ def my_view(request): query = request.dbsession.query(MyModel) one = query.filter(MyModel.name == 'one').first() except DBAPIError: - return Response(db_err_msg, content_type='text/plain', status_int=500) + return Response(db_err_msg, content_type='text/plain', status=500) return {'one': one, 'project': 'tutorial'} diff --git a/docs/tutorials/wiki2/src/models/tutorial/views/notfound.py b/docs/tutorials/wiki2/src/models/tutorial/views/notfound.py new file mode 100644 index 000000000..69d6e2804 --- /dev/null +++ b/docs/tutorials/wiki2/src/models/tutorial/views/notfound.py @@ -0,0 +1,7 @@ +from pyramid.view import notfound_view_config + + +@notfound_view_config(renderer='../templates/404.jinja2') +def notfound_view(request): + request.response.status = 404 + return {} diff --git a/docs/tutorials/wiki2/src/tests/MANIFEST.in b/docs/tutorials/wiki2/src/tests/MANIFEST.in index 81beba1b1..42cd299b5 100644 --- a/docs/tutorials/wiki2/src/tests/MANIFEST.in +++ b/docs/tutorials/wiki2/src/tests/MANIFEST.in @@ -1,2 +1,2 @@ include *.txt *.ini *.cfg *.rst -recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml +recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.jinja2 *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/docs/tutorials/wiki2/src/tests/development.ini b/docs/tutorials/wiki2/src/tests/development.ini index 99c4ff0fe..f3079727e 100644 --- a/docs/tutorials/wiki2/src/tests/development.ini +++ b/docs/tutorials/wiki2/src/tests/development.ini @@ -17,6 +17,8 @@ pyramid.includes = sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite +auth.secret = seekrit + # By default, the toolbar only appears for clients from IP addresses # '127.0.0.1' and '::1'. # debugtoolbar.hosts = 127.0.0.1 ::1 diff --git a/docs/tutorials/wiki2/src/tests/production.ini b/docs/tutorials/wiki2/src/tests/production.ini index 97acfbd7d..686dba48a 100644 --- a/docs/tutorials/wiki2/src/tests/production.ini +++ b/docs/tutorials/wiki2/src/tests/production.ini @@ -11,11 +11,11 @@ pyramid.debug_authorization = false pyramid.debug_notfound = false pyramid.debug_routematch = false pyramid.default_locale_name = en -pyramid.includes = - pyramid_tm sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite +auth.secret = real-seekrit + [server:main] use = egg:waitress#main host = 0.0.0.0 diff --git a/docs/tutorials/wiki2/src/tests/setup.py b/docs/tutorials/wiki2/src/tests/setup.py index f640b4399..57538f2d0 100644 --- a/docs/tutorials/wiki2/src/tests/setup.py +++ b/docs/tutorials/wiki2/src/tests/setup.py @@ -9,6 +9,8 @@ with open(os.path.join(here, 'CHANGES.txt')) as f: CHANGES = f.read() requires = [ + 'bcrypt', + 'docutils', 'pyramid', 'pyramid_jinja2', 'pyramid_debugtoolbar', @@ -17,10 +19,12 @@ requires = [ 'transaction', 'zope.sqlalchemy', 'waitress', - 'docutils', - 'WebTest', ] +tests_require = [ + 'WebTest', +] + setup(name='tutorial', version='0.0', description='tutorial', @@ -39,6 +43,7 @@ setup(name='tutorial', include_package_data=True, zip_safe=False, test_suite='tutorial', + tests_require=tests_require, install_requires=requires, entry_points="""\ [paste.app_factory] diff --git a/docs/tutorials/wiki2/src/tests/tutorial/__init__.py b/docs/tutorials/wiki2/src/tests/tutorial/__init__.py index 084fee19f..f5c033b8b 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/__init__.py @@ -1,27 +1,13 @@ from pyramid.config import Configurator -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy -from security.default import groupfinder def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - authn_policy = AuthTktAuthenticationPolicy( - 'sosecret', callback=groupfinder, hashalg='sha512') - authz_policy = ACLAuthorizationPolicy() - config = Configurator(settings=settings, - root_factory='tutorial.models.mymodel.RootFactory') - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(authz_policy) + config = Configurator(settings=settings) config.include('pyramid_jinja2') - config.include('.models.meta') - config.add_static_view('static', 'static', cache_max_age=3600) - config.add_route('view_wiki', '/') - config.add_route('login', '/login') - config.add_route('logout', '/logout') - config.add_route('view_page', '/{pagename}') - config.add_route('add_page', '/add_page/{pagename}') - config.add_route('edit_page', '/{pagename}/edit_page') + config.include('.models') + config.include('.routes') + config.include('.security') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py index 7b1c62867..a8871f6f5 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py @@ -1,7 +1,74 @@ +from sqlalchemy import engine_from_config +from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import configure_mappers -# import all models classes here for sqlalchemy mappers -# to pick up -from .mymodel import Page # flake8: noqa +import zope.sqlalchemy -# run configure mappers to ensure we avoid any race conditions +# import or define all models here to ensure they are attached to the +# Base.metadata prior to any initialization routines +from .page import Page # flake8: noqa +from .user import User # flake8: noqa + +# run configure_mappers after defining all of the models to ensure +# all relationships can be setup configure_mappers() + + +def get_engine(settings, prefix='sqlalchemy.'): + return engine_from_config(settings, prefix) + + +def get_session_factory(engine): + factory = sessionmaker() + factory.configure(bind=engine) + return factory + + +def get_tm_session(session_factory, transaction_manager): + """ + Get a ``sqlalchemy.orm.Session`` instance backed by a transaction. + + This function will hook the session to the transaction manager which + will take care of committing any changes. + + - When using pyramid_tm it will automatically be committed or aborted + depending on whether an exception is raised. + + - When using scripts you should wrap the session in a manager yourself. + For example:: + + import transaction + + engine = get_engine(settings) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + """ + dbsession = session_factory() + zope.sqlalchemy.register( + dbsession, transaction_manager=transaction_manager) + return dbsession + + +def includeme(config): + """ + Initialize the model for a Pyramid app. + + Activate this setup using ``config.include('tutorial.models')``. + + """ + settings = config.get_settings() + + # use pyramid_tm to hook the transaction lifecycle to the request + config.include('pyramid_tm') + + session_factory = get_session_factory(get_engine(settings)) + config.registry['dbsession_factory'] = session_factory + + # make request.dbsession available for use in Pyramid + config.add_request_method( + # r.tm is the transaction manager used by pyramid_tm + lambda r: get_tm_session(session_factory, r.tm), + 'dbsession', + reify=True + ) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/models/meta.py b/docs/tutorials/wiki2/src/tests/tutorial/models/meta.py index 80ececd8c..fc3e8f1dd 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/models/meta.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/models/meta.py @@ -1,8 +1,5 @@ -from sqlalchemy import engine_from_config from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker from sqlalchemy.schema import MetaData -import zope.sqlalchemy # Recommended naming convention used by Alembic, as various different database # providers will autogenerate vastly different names making migrations more @@ -17,33 +14,3 @@ NAMING_CONVENTION = { metadata = MetaData(naming_convention=NAMING_CONVENTION) Base = declarative_base(metadata=metadata) - - -def includeme(config): - settings = config.get_settings() - dbmaker = get_dbmaker(get_engine(settings)) - - config.add_request_method( - lambda r: get_session(r.tm, dbmaker), - 'dbsession', - reify=True - ) - - config.include('pyramid_tm') - - -def get_session(transaction_manager, dbmaker): - dbsession = dbmaker() - zope.sqlalchemy.register(dbsession, - transaction_manager=transaction_manager) - return dbsession - - -def get_engine(settings, prefix='sqlalchemy.'): - return engine_from_config(settings, prefix) - - -def get_dbmaker(engine): - dbmaker = sessionmaker() - dbmaker.configure(bind=engine) - return dbmaker diff --git a/docs/tutorials/wiki2/src/tests/tutorial/models/mymodel.py b/docs/tutorials/wiki2/src/tests/tutorial/models/mymodel.py deleted file mode 100644 index 03e2f90ca..000000000 --- a/docs/tutorials/wiki2/src/tests/tutorial/models/mymodel.py +++ /dev/null @@ -1,26 +0,0 @@ -from .meta import Base - -from pyramid.security import ( - Allow, - Everyone, - ) - -from sqlalchemy import ( - Column, - Integer, - Text, - ) - - -class Page(Base): - """ The SQLAlchemy declarative model class for a Page object. """ - __tablename__ = 'pages' - id = Column(Integer, primary_key=True) - name = Column(Text, unique=True) - data = Column(Integer) - -class RootFactory(object): - __acl__ = [ (Allow, Everyone, 'view'), - (Allow, 'group:editors', 'edit') ] - def __init__(self, request): - pass
\ No newline at end of file diff --git a/docs/tutorials/wiki2/src/tests/tutorial/models/page.py b/docs/tutorials/wiki2/src/tests/tutorial/models/page.py new file mode 100644 index 000000000..4dd5b5721 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/models/page.py @@ -0,0 +1,20 @@ +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + Text, +) +from sqlalchemy.orm import relationship + +from .meta import Base + + +class Page(Base): + """ The SQLAlchemy declarative model class for a Page object. """ + __tablename__ = 'pages' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + data = Column(Integer, nullable=False) + + creator_id = Column(ForeignKey('users.id'), nullable=False) + creator = relationship('User', backref='created_pages') diff --git a/docs/tutorials/wiki2/src/tests/tutorial/models/user.py b/docs/tutorials/wiki2/src/tests/tutorial/models/user.py new file mode 100644 index 000000000..6fb32a1b2 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/models/user.py @@ -0,0 +1,29 @@ +import bcrypt +from sqlalchemy import ( + Column, + Integer, + Text, +) + +from .meta import Base + + +class User(Base): + """ The SQLAlchemy declarative model class for a User object. """ + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + role = Column(Text, nullable=False) + + password_hash = Column(Text) + + def set_password(self, pw): + pwhash = bcrypt.hashpw(pw.encode('utf8'), bcrypt.gensalt()) + self.password_hash = pwhash + + def check_password(self, pw): + if self.password_hash is not None: + expected_hash = self.password_hash.encode('utf8') + actual_hash = bcrypt.hashpw(pw.encode('utf8'), expected_hash) + return expected_hash == actual_hash + return False diff --git a/docs/tutorials/wiki2/src/tests/tutorial/routes.py b/docs/tutorials/wiki2/src/tests/tutorial/routes.py new file mode 100644 index 000000000..c7c3a2120 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/routes.py @@ -0,0 +1,50 @@ +from pyramid.httpexceptions import HTTPNotFound +from pyramid.security import ( + Allow, + Everyone, +) + +from .models import Page + +def includeme(config): + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('view_wiki', '/') + config.add_route('login', '/login') + config.add_route('logout', '/logout') + config.add_route('view_page', '/{pagename}', factory=page_factory) + config.add_route('add_page', '/add_page/{pagename}', + factory=new_page_factory) + config.add_route('edit_page', '/{pagename}/edit_page', + factory=page_factory) + +def new_page_factory(request): + pagename = request.matchdict['pagename'] + return NewPage(pagename) + +class NewPage(object): + def __init__(self, pagename): + self.pagename = pagename + + def __acl__(self): + return [ + (Allow, 'role:editor', 'create'), + (Allow, 'role:basic', 'create'), + ] + +def page_factory(request): + pagename = request.matchdict['pagename'] + page = request.dbsession.query(Page).filter_by(name=pagename).first() + if page is None: + raise HTTPNotFound + return PageResource(page) + +class PageResource(object): + def __init__(self, page): + self.page = page + + def __acl__(self): + return [ + (Allow, Everyone, 'view'), + (Allow, 'role:editor', 'edit'), + (Allow, str(self.page.creator_id), 'edit'), + ] diff --git a/docs/tutorials/wiki2/src/tests/tutorial/scripts/initializedb.py b/docs/tutorials/wiki2/src/tests/tutorial/scripts/initializedb.py index 4aac4a848..f3c0a6fef 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/scripts/initializedb.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/scripts/initializedb.py @@ -7,13 +7,15 @@ from pyramid.paster import ( setup_logging, ) -from ..models.meta import ( - Base, - get_session, +from pyramid.scripts.common import parse_vars + +from ..models.meta import Base +from ..models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) -from ..models.mymodel import Page +from ..models import Page, User def usage(argv): @@ -27,16 +29,29 @@ def main(argv=sys.argv): if len(argv) < 2: usage(argv) config_uri = argv[1] + options = parse_vars(argv[2:]) setup_logging(config_uri) - settings = get_appsettings(config_uri) + settings = get_appsettings(config_uri, options=options) engine = get_engine(settings) - dbmaker = get_dbmaker(engine) - - dbsession = get_session(transaction.manager, dbmaker) - Base.metadata.create_all(engine) + session_factory = get_session_factory(engine) + with transaction.manager: - model = Page(name='FrontPage', data='This is the front page') - dbsession.add(model) + dbsession = get_tm_session(session_factory, transaction.manager) + + editor = User(name='editor', role='editor') + editor.set_password('editor') + dbsession.add(editor) + + basic = User(name='basic', role='basic') + basic.set_password('basic') + dbsession.add(basic) + + page = Page( + name='FrontPage', + creator=editor, + data='This is the front page', + ) + dbsession.add(page) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/security.py b/docs/tutorials/wiki2/src/tests/tutorial/security.py new file mode 100644 index 000000000..25cff7b05 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/security.py @@ -0,0 +1,40 @@ +from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.security import ( + Authenticated, + Everyone, +) + +from .models import User + + +class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): + def authenticated_userid(self, request): + user = request.user + if user is not None: + return user.id + + def effective_principals(self, request): + principals = [Everyone] + user = request.user + if user is not None: + principals.append(Authenticated) + principals.append(str(user.id)) + principals.append('role:' + user.role) + return principals + +def get_user(request): + user_id = request.unauthenticated_userid + if user_id is not None: + user = request.dbsession.query(User).get(user_id) + return user + +def includeme(config): + settings = config.get_settings() + authn_policy = MyAuthenticationPolicy( + settings['auth.secret'], + hashalg='sha512', + ) + config.set_authentication_policy(authn_policy) + config.set_authorization_policy(ACLAuthorizationPolicy()) + config.add_request_method(get_user, 'user', reify=True) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/security/__init__.py b/docs/tutorials/wiki2/src/tests/tutorial/security/__init__.py deleted file mode 100644 index 5bb534f79..000000000 --- a/docs/tutorials/wiki2/src/tests/tutorial/security/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# package diff --git a/docs/tutorials/wiki2/src/tests/tutorial/security/default.py b/docs/tutorials/wiki2/src/tests/tutorial/security/default.py deleted file mode 100644 index d88c9c71f..000000000 --- a/docs/tutorials/wiki2/src/tests/tutorial/security/default.py +++ /dev/null @@ -1,7 +0,0 @@ -USERS = {'editor':'editor', - 'viewer':'viewer'} -GROUPS = {'editor':['group:editors']} - -def groupfinder(userid, request): - if userid in USERS: - return GROUPS.get(userid, []) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 new file mode 100644 index 000000000..37b0a16b6 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 @@ -0,0 +1,8 @@ +{% extends "layout.jinja2" %} + +{% block content %} +<div class="content"> + <h1><span class="font-semi-bold">Pyramid tutorial wiki</span> <span class="smaller">(based on TurboGears 20-Minute Wiki)</span></h1> + <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> +</div> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 index c4f3a2c93..7db25c674 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 @@ -1,73 +1,20 @@ -<!DOCTYPE html> -<html lang="{{request.locale_name}}"> - <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="pyramid web application"> - <meta name="author" content="Pylons Project"> - <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - - <title>Edit{% if page.name %} {{page.name}}{% endif %} - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> - - <!-- Bootstrap core CSS --> - <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> - - <!-- Custom styles for this scaffold --> - <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> - - <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> - <!--[if lt IE 9]> - <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> - <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> - <![endif]--> - </head> - - <body> - - <div class="starter-template"> - <div class="container"> - <div class="row"> - <div class="col-md-2"> - <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> - </div> - <div class="col-md-10"> - <div class="content"> - {% if logged_in %} - <p class="pull-right"> - <a href="{{ request.application_url }}/logout">Logout</a> - </p> - {% endif %} - <p> - Editing <strong>{% if page.name %}{{page.name}}{% else %}Page Name Goes Here{% endif %}</strong> - </p> - <p>You can return to the - <a href="{{request.application_url}}">FrontPage</a>. - </p> - <form action="{{ save_url }}" method="post"> - <div class="form-group"> - <textarea class="form-control" name="body" rows="10" cols="60">{{ page.data }}</textarea> - </div> - <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> - </div> - </form> - </div> - </div> - </div> - <div class="row"> - <div class="copyright"> - Copyright © Pylons Project - </div> - </div> - </div> - </div> - - - <!-- Bootstrap core JavaScript - ================================================== --> - <!-- Placed at the end of the document so the pages load faster --> - <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> - <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> - </body> -</html> +{% extends 'layout.jinja2' %} + +{% block subtitle %}Edit {{pagename}} - {% endblock subtitle %} + +{% block content %} +<p> +Editing <strong>{{pagename}}</strong> +</p> +<p>You can return to the +<a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. +</p> +<form action="{{ save_url }}" method="post"> +<div class="form-group"> + <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> +</div> +<div class="form-group"> + <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> +</div> +</form> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 index ff624c65b..44d14304e 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 @@ -8,7 +8,7 @@ <meta name="author" content="Pylons Project"> <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - <title>Alchemy Scaffold for The Pyramid Web Framework</title> + <title>{% block subtitle %}{% endblock %}Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> <!-- Bootstrap core CSS --> <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> @@ -32,20 +32,18 @@ <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> </div> <div class="col-md-10"> - {% block content %} - <p>No content</p> - {% endblock content %} - </div> - </div> - <div class="row"> - <div class="links"> - <ul> - <li class="current-version">Generated by v1.7.dev0</li> - <li><i class="glyphicon glyphicon-bookmark icon-muted"></i><a href="http://docs.pylonsproject.org/projects/pyramid/en/1.7-branch/">Docs</a></li> - <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> - <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="irc://irc.freenode.net#pyramid">IRC Channel</a></li> - <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="http://pylonsproject.org">Pylons Project</a></li> - </ul> + <div class="content"> + {% if request.user is none %} + <p class="pull-right"> + <a href="{{ request.route_url('login') }}">Login</a> + </p> + {% else %} + <p class="pull-right"> + {{request.user.name}} <a href="{{request.route_url('logout')}}">Logout</a> + </p> + {% endif %} + {% block content %}{% endblock %} + </div> </div> </div> <div class="row"> diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 index a80a2a165..1806de0ff 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 @@ -1,74 +1,26 @@ -<!DOCTYPE html> -<html lang="{{request.locale_name}}"> - <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="pyramid web application"> - <meta name="author" content="Pylons Project"> - <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - - <title>Login - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> - - <!-- Bootstrap core CSS --> - <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> - - <!-- Custom styles for this scaffold --> - <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> - - <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> - <!--[if lt IE 9]> - <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> - <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> - <![endif]--> - </head> - - <body> - - <div class="starter-template"> - <div class="container"> - <div class="row"> - <div class="col-md-2"> - <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> - </div> - <div class="col-md-10"> - <div class="content"> - <p> - <strong> - Login - </strong><br> - {{ message }} - </p> - <form action="{{ url }}" method="post"> - <input type="hidden" name="came_from" value="{{ came_from }}"> - <div class="form-group"> - <label for="login">Username</label> - <input type="text" name="login" value="{{ login }}"> - </div> - <div class="form-group"> - <label for="password">Password</label> - <input type="password" name="password" value="{{ password }}"> - </div> - <div class="form-group"> - <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> - </div> - </form> - </div> - </div> - </div> - <div class="row"> - <div class="copyright"> - Copyright © Pylons Project - </div> - </div> - </div> - </div> - - - <!-- Bootstrap core JavaScript - ================================================== --> - <!-- Placed at the end of the document so the pages load faster --> - <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> - <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> - </body> -</html> +{% extends 'layout.jinja2' %} + +{% block title %}Login - {% endblock title %} + +{% block content %} +<p> +<strong> + Login +</strong><br> +{{ message }} +</p> +<form action="{{ url }}" method="post"> +<input type="hidden" name="next" value="{{ next_url }}"> +<div class="form-group"> + <label for="login">Username</label> + <input type="text" name="login" value="{{ login }}"> +</div> +<div class="form-group"> + <label for="password">Password</label> + <input type="password" name="password"> +</div> +<div class="form-group"> + <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> +</div> +</form> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/view.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/view.jinja2 index a7afc66fc..94419e228 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/view.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/view.jinja2 @@ -1,71 +1,18 @@ -<!DOCTYPE html> -<html lang="{{request.locale_name}}"> - <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="pyramid web application"> - <meta name="author" content="Pylons Project"> - <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - - <title>{{page.name}} - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> - - <!-- Bootstrap core CSS --> - <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> - - <!-- Custom styles for this scaffold --> - <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> - - <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> - <!--[if lt IE 9]> - <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> - <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> - <![endif]--> - </head> - - <body> - - <div class="starter-template"> - <div class="container"> - <div class="row"> - <div class="col-md-2"> - <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> - </div> - <div class="col-md-10"> - <div class="content"> - {% if logged_in %} - <p class="pull-right"> - <a href="{{ request.application_url }}/logout">Logout</a> - </p> - {% endif %} - <p>{{ content|safe }}</p> - <p> - <a href="{{ edit_url }}"> - Edit this page - </a> - </p> - <p> - Viewing <strong>{% if page.name %}{{page.name}}{% else %}Page Name Goes Here{% endif %}</strong> - </p> - <p>You can return to the - <a href="{{request.application_url}}">FrontPage</a>. - </p> - </div> - </div> - </div> - <div class="row"> - <div class="copyright"> - Copyright © Pylons Project - </div> - </div> - </div> - </div> - - - <!-- Bootstrap core JavaScript - ================================================== --> - <!-- Placed at the end of the document so the pages load faster --> - <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> - <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> - </body> -</html> +{% extends 'layout.jinja2' %} + +{% block subtitle %}{{page.name}} - {% endblock subtitle %} + +{% block content %} +<p>{{ content|safe }}</p> +<p> +<a href="{{ edit_url }}"> + Edit this page +</a> +</p> +<p> + Viewing <strong>{{page.name}}</strong>, created by <strong>{{page.creator.name}}</strong>. +</p> +<p>You can return to the +<a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. +</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/tests/__init__.py b/docs/tutorials/wiki2/src/tests/tutorial/tests/__init__.py index 8b1378917..e69de29bb 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/tests/__init__.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/tests/__init__.py @@ -1 +0,0 @@ - diff --git a/docs/tutorials/wiki2/src/tests/tutorial/tests/test_functional.py b/docs/tutorials/wiki2/src/tests/tutorial/tests/test_functional.py index 339c60bc2..b2c6e0975 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/tests/test_functional.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/tests/test_functional.py @@ -1,81 +1,57 @@ +import transaction import unittest - -from pyramid import testing - - -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -def _register_routes(config): - config.add_route('view_page', '{pagename}') - config.add_route('edit_page', '{pagename}/edit_page') - config.add_route('add_page', 'add_page/{pagename}') +from webtest import TestApp class FunctionalTests(unittest.TestCase): - viewer_login = '/login?login=viewer&password=viewer' \ - '&came_from=FrontPage&form.submitted=Login' - viewer_wrong_login = '/login?login=viewer&password=incorrect' \ - '&came_from=FrontPage&form.submitted=Login' - editor_login = '/login?login=editor&password=editor' \ - '&came_from=FrontPage&form.submitted=Login' - - engine = None - - @staticmethod - def setup_database(): - import transaction - from tutorial.models.mymodel import Page - from tutorial.models.meta import ( - Base, - ) - import tutorial.models.meta - - - def initialize_db(dbsession, engine): - Base.metadata.create_all(engine) - with transaction.manager: - model = Page(name='FrontPage', data='This is the front page') - dbsession.add(model) - - def wrap_get_session(transaction_manager, dbmaker): - dbsession = get_session(transaction_manager, dbmaker) - initialize_db(dbsession, engine) - tutorial.models.meta.get_session = get_session - tutorial.models.meta.get_engine = get_engine - return dbsession - - def wrap_get_engine(settings): - global engine - engine = get_engine(settings) - return engine - - get_session = tutorial.models.meta.get_session - tutorial.models.meta.get_session = wrap_get_session - - get_engine = tutorial.models.meta.get_engine - tutorial.models.meta.get_engine = wrap_get_engine + basic_login = ( + '/login?login=basic&password=basic' + '&next=FrontPage&form.submitted=Login') + basic_wrong_login = ( + '/login?login=basic&password=incorrect' + '&next=FrontPage&form.submitted=Login') + editor_login = ( + '/login?login=editor&password=editor' + '&next=FrontPage&form.submitted=Login') @classmethod def setUpClass(cls): - cls.setup_database() - - from webtest import TestApp + from tutorial.models.meta import Base + from tutorial.models import ( + User, + Page, + get_tm_session, + ) from tutorial import main - settings = {'sqlalchemy.url': 'sqlite://'} + + settings = { + 'sqlalchemy.url': 'sqlite://', + 'auth.secret': 'seekrit', + } app = main({}, **settings) cls.testapp = TestApp(app) + session_factory = app.registry['dbsession_factory'] + cls.engine = session_factory.kw['bind'] + Base.metadata.create_all(bind=cls.engine) + + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + editor = User(name='editor', role='editor') + editor.set_password('editor') + basic = User(name='basic', role='basic') + basic.set_password('basic') + page1 = Page(name='FrontPage', data='This is the front page') + page1.creator = editor + page2 = Page(name='BackPage', data='This is the back page') + page2.creator = basic + dbsession.add_all([basic, editor, page1, page2]) + @classmethod def tearDownClass(cls): from tutorial.models.meta import Base - Base.metadata.drop_all(engine) - - def tearDown(self): - import transaction - transaction.abort() + Base.metadata.drop_all(bind=cls.engine) def test_root(self): res = self.testapp.get('/', status=302) @@ -89,41 +65,46 @@ class FunctionalTests(unittest.TestCase): self.testapp.get('/SomePage', status=404) def test_successful_log_in(self): - res = self.testapp.get(self.viewer_login, status=302) + res = self.testapp.get(self.basic_login, status=302) self.assertEqual(res.location, 'http://localhost/FrontPage') def test_failed_log_in(self): - res = self.testapp.get(self.viewer_wrong_login, status=200) + res = self.testapp.get(self.basic_wrong_login, status=200) self.assertTrue(b'login' in res.body) def test_logout_link_present_when_logged_in(self): - self.testapp.get(self.viewer_login, status=302) + self.testapp.get(self.basic_login, status=302) res = self.testapp.get('/FrontPage', status=200) self.assertTrue(b'Logout' in res.body) def test_logout_link_not_present_after_logged_out(self): - self.testapp.get(self.viewer_login, status=302) + self.testapp.get(self.basic_login, status=302) self.testapp.get('/FrontPage', status=200) res = self.testapp.get('/logout', status=302) self.assertTrue(b'Logout' not in res.body) def test_anonymous_user_cannot_edit(self): - res = self.testapp.get('/FrontPage/edit_page', status=200) + res = self.testapp.get('/FrontPage/edit_page', status=302).follow() self.assertTrue(b'Login' in res.body) def test_anonymous_user_cannot_add(self): - res = self.testapp.get('/add_page/NewPage', status=200) + res = self.testapp.get('/add_page/NewPage', status=302).follow() self.assertTrue(b'Login' in res.body) - def test_viewer_user_cannot_edit(self): - self.testapp.get(self.viewer_login, status=302) - res = self.testapp.get('/FrontPage/edit_page', status=200) + def test_basic_user_cannot_edit_front(self): + self.testapp.get(self.basic_login, status=302) + res = self.testapp.get('/FrontPage/edit_page', status=302).follow() self.assertTrue(b'Login' in res.body) - def test_viewer_user_cannot_add(self): - self.testapp.get(self.viewer_login, status=302) + def test_basic_user_can_edit_back(self): + self.testapp.get(self.basic_login, status=302) + res = self.testapp.get('/BackPage/edit_page', status=200) + self.assertTrue(b'Editing' in res.body) + + def test_basic_user_can_add(self): + self.testapp.get(self.basic_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue(b'Editing' in res.body) def test_editors_member_user_can_edit(self): self.testapp.get(self.editor_login, status=302) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/tests/test_views.py b/docs/tutorials/wiki2/src/tests/tutorial/tests/test_views.py index d70311e38..5253183df 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/tests/test_views.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/tests/test_views.py @@ -8,48 +8,45 @@ def dummy_request(dbsession): return testing.DummyRequest(dbsession=dbsession) -def _register_routes(config): - config.add_route('view_page', '{pagename}') - config.add_route('edit_page', '{pagename}/edit_page') - config.add_route('add_page', 'add_page/{pagename}') - - class BaseTest(unittest.TestCase): def setUp(self): + from ..models import get_tm_session self.config = testing.setUp(settings={ 'sqlalchemy.url': 'sqlite:///:memory:' }) - self.config.include('..models.meta') - _register_routes(self.config) - settings = self.config.get_settings() - - from ..models.meta import ( - get_session, - get_engine, - get_dbmaker, - ) - - self.engine = get_engine(settings) - dbmaker = get_dbmaker(self.engine) + self.config.include('..models') + self.config.include('..routes') - self.session = get_session(transaction.manager, dbmaker) + session_factory = self.config.registry['dbsession_factory'] + self.session = get_tm_session(session_factory, transaction.manager) self.init_database() def init_database(self): from ..models.meta import Base - Base.metadata.create_all(self.engine) + session_factory = self.config.registry['dbsession_factory'] + engine = session_factory.kw['bind'] + Base.metadata.create_all(engine) def tearDown(self): testing.tearDown() transaction.abort() + def makeUser(self, name, role, password='dummy'): + from ..models import User + user = User(name=name, role=role) + user.set_password(password) + return user + + def makePage(self, name, data, creator): + from ..models import Page + return Page(name=name, data=data, creator=creator) -class ViewWikiTests(unittest.TestCase): +class ViewWikiTests(unittest.TestCase): def setUp(self): self.config = testing.setUp() - _register_routes(self.config) + self.config.include('..routes') def tearDown(self): testing.tearDown() @@ -65,26 +62,21 @@ class ViewWikiTests(unittest.TestCase): class ViewPageTests(BaseTest): - def setUp(self): - super(ViewPageTests, self).setUp() - - def tearDown(self): - transaction.abort() - testing.tearDown() - def _callFUT(self, request): from tutorial.views.default import view_page return view_page(request) def test_it(self): + from ..routes import PageResource + # add a page to the db - from ..models.mymodel import Page - page = Page(name='IDoExist', data='Hello CruelWorld IDoExist') - self.session.add(page) + user = self.makeUser('foo', 'editor') + page = self.makePage('IDoExist', 'Hello CruelWorld IDoExist', user) + self.session.add_all([page, user]) # create a request asking for the page we've created request = dummy_request(self.session) - request.matchdict['pagename'] = 'IDoExist' + request.context = PageResource(page) # call the view we're testing and check its behavior info = self._callFUT(request) @@ -102,67 +94,63 @@ class ViewPageTests(BaseTest): class AddPageTests(BaseTest): - def setUp(self): - super(AddPageTests, self).setUp() - - def tearDown(self): - transaction.abort() - testing.tearDown() - def _callFUT(self, request): from tutorial.views.default import add_page return add_page(request) def test_it_notsubmitted(self): + from ..routes import NewPage request = dummy_request(self.session) - request.matchdict = {'pagename': 'AnotherPage'} + request.user = self.makeUser('foo', 'editor') + request.context = NewPage('AnotherPage') info = self._callFUT(request) - self.assertEqual(info['page'].data, '') + self.assertEqual(info['pagedata'], '') self.assertEqual(info['save_url'], 'http://example.com/add_page/AnotherPage') def test_it_submitted(self): - from ..models.mymodel import Page + from ..models import Page + from ..routes import NewPage request = testing.DummyRequest({'form.submitted': True, 'body': 'Hello yo!'}, dbsession=self.session) - request.matchdict = {'pagename': 'AnotherPage'} + request.user = self.makeUser('foo', 'editor') + request.context = NewPage('AnotherPage') self._callFUT(request) page = self.session.query(Page).filter_by(name='AnotherPage').one() self.assertEqual(page.data, 'Hello yo!') class EditPageTests(BaseTest): - def setUp(self): - super(EditPageTests, self).setUp() - - def tearDown(self): - transaction.abort() - testing.tearDown() - def _callFUT(self, request): from tutorial.views.default import edit_page return edit_page(request) + def makeContext(self, page): + from ..routes import PageResource + return PageResource(page) + def test_it_notsubmitted(self): - from ..models.mymodel import Page + user = self.makeUser('foo', 'editor') + page = self.makePage('abc', 'hello', user) + self.session.add_all([page, user]) + request = dummy_request(self.session) - request.matchdict = {'pagename': 'abc'} - page = Page(name='abc', data='hello') - self.session.add(page) + request.context = self.makeContext(page) info = self._callFUT(request) - self.assertEqual(info['page'], page) + self.assertEqual(info['pagename'], 'abc') self.assertEqual(info['save_url'], 'http://example.com/abc/edit_page') def test_it_submitted(self): - from ..models.mymodel import Page + user = self.makeUser('foo', 'editor') + page = self.makePage('abc', 'hello', user) + self.session.add_all([page, user]) + request = testing.DummyRequest({'form.submitted': True, 'body': 'Hello yo!'}, dbsession=self.session) - request.matchdict = {'pagename': 'abc'} - page = Page(name='abc', data='hello') - self.session.add(page) + request.context = self.makeContext(page) response = self._callFUT(request) self.assertEqual(response.location, 'http://example.com/abc') self.assertEqual(page.data, 'Hello yo!') diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py b/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py new file mode 100644 index 000000000..2b993b430 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py @@ -0,0 +1,46 @@ +from pyramid.httpexceptions import HTTPFound +from pyramid.security import ( + remember, + forget, + ) +from pyramid.view import ( + forbidden_view_config, + view_config, +) + +from ..models import User + + +@view_config(route_name='login', renderer='../templates/login.jinja2') +def login(request): + next_url = request.params.get('next', request.referrer) + if not next_url: + next_url = request.route_url('view_wiki') + message = '' + login = '' + if 'form.submitted' in request.params: + login = request.params['login'] + password = request.params['password'] + user = request.dbsession.query(User).filter_by(name=login).first() + if user is not None and user.check_password(password): + headers = remember(request, user.id) + return HTTPFound(location=next_url, headers=headers) + message = 'Failed login' + + return dict( + message=message, + url=request.route_url('login'), + next_url=next_url, + login=login, + ) + +@view_config(route_name='logout') +def logout(request): + headers = forget(request) + next_url = request.route_url('view_wiki') + return HTTPFound(location=next_url, headers=headers) + +@forbidden_view_config() +def forbidden_view(request): + next_url = request.route_url('login', _query={'next': request.url}) + return HTTPFound(location=next_url) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views/default.py b/docs/tutorials/wiki2/src/tests/tutorial/views/default.py index f35f041a4..9358993ea 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/views/default.py @@ -2,43 +2,25 @@ import cgi import re from docutils.core import publish_parts -from pyramid.httpexceptions import ( - HTTPFound, - HTTPNotFound, - ) +from pyramid.httpexceptions import HTTPFound +from pyramid.view import view_config -from pyramid.view import ( - view_config, - forbidden_view_config, - ) - -from pyramid.security import ( - remember, - forget, - ) - -from ..security.default import USERS - -from ..models.mymodel import Page +from ..models import Page # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") -@view_config(route_name='view_wiki', - permission='view') +@view_config(route_name='view_wiki') def view_wiki(request): - return HTTPFound(location=request.route_url('view_page', - pagename='FrontPage')) + next_url = request.route_url('view_page', pagename='FrontPage') + return HTTPFound(location=next_url) -@view_config(route_name='view_page', renderer='templates/view.jinja2', +@view_config(route_name='view_page', renderer='../templates/view.jinja2', permission='view') def view_page(request): - pagename = request.matchdict['pagename'] - page = request.dbsession.query(Page).filter_by(name=pagename).first() - if page is None: - return HTTPNotFound('No such page') + page = request.context.page - def check(match): + def add_link(match): word = match.group(1) exists = request.dbsession.query(Page).filter_by(name=word).all() if exists: @@ -49,72 +31,34 @@ def view_page(request): return '<a href="%s">%s</a>' % (add_url, cgi.escape(word)) content = publish_parts(page.data, writer_name='html')['html_body'] - content = wikiwords.sub(check, content) - edit_url = request.route_url('edit_page', pagename=pagename) - return dict(page=page, content=content, edit_url=edit_url, - logged_in=request.authenticated_userid) - -@view_config(route_name='add_page', renderer='templates/edit.jinja2', - permission='edit') -def add_page(request): - pagename = request.matchdict['pagename'] - if 'form.submitted' in request.params: - body = request.params['body'] - page = Page(name=pagename, data=body) - request.dbsession.add(page) - return HTTPFound(location = request.route_url('view_page', - pagename=pagename)) - save_url = request.route_url('add_page', pagename=pagename) - page = Page(name='', data='') - return dict(page=page, save_url=save_url, - logged_in=request.authenticated_userid) + content = wikiwords.sub(add_link, content) + edit_url = request.route_url('edit_page', pagename=page.name) + return dict(page=page, content=content, edit_url=edit_url) -@view_config(route_name='edit_page', renderer='templates/edit.jinja2', +@view_config(route_name='edit_page', renderer='../templates/edit.jinja2', permission='edit') def edit_page(request): - pagename = request.matchdict['pagename'] - page = request.dbsession.query(Page).filter_by(name=pagename).one() + page = request.context.page if 'form.submitted' in request.params: page.data = request.params['body'] - request.dbsession.add(page) - return HTTPFound(location = request.route_url('view_page', - pagename=pagename)) + next_url = request.route_url('view_page', pagename=page.name) + return HTTPFound(location=next_url) return dict( - page=page, - save_url = request.route_url('edit_page', pagename=pagename), - logged_in=request.authenticated_userid + pagename=page.name, + pagedata=page.data, + save_url=request.route_url('edit_page', pagename=page.name), ) -@view_config(route_name='login', renderer='templates/login.jinja2') -@forbidden_view_config(renderer='templates/login.jinja2') -def login(request): - login_url = request.route_url('login') - referrer = request.url - if referrer == login_url: - referrer = '/' # never use the login form itself as came_from - came_from = request.params.get('came_from', referrer) - message = '' - login = '' - password = '' +@view_config(route_name='add_page', renderer='../templates/edit.jinja2', + permission='create') +def add_page(request): + pagename = request.context.pagename if 'form.submitted' in request.params: - login = request.params['login'] - password = request.params['password'] - if USERS.get(login) == password: - headers = remember(request, login) - return HTTPFound(location = came_from, - headers = headers) - message = 'Failed login' - - return dict( - message = message, - url = request.application_url + '/login', - came_from = came_from, - login = login, - password = password, - ) - -@view_config(route_name='logout') -def logout(request): - headers = forget(request) - return HTTPFound(location = request.route_url('view_wiki'), - headers = headers) + body = request.params['body'] + page = Page(name=pagename, data=body) + page.creator = request.user + request.dbsession.add(page) + next_url = request.route_url('view_page', pagename=pagename) + return HTTPFound(location=next_url) + save_url = request.route_url('add_page', pagename=pagename) + return dict(pagename=pagename, pagedata='', save_url=save_url) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views/notfound.py b/docs/tutorials/wiki2/src/tests/tutorial/views/notfound.py new file mode 100644 index 000000000..69d6e2804 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/views/notfound.py @@ -0,0 +1,7 @@ +from pyramid.view import notfound_view_config + + +@notfound_view_config(renderer='../templates/404.jinja2') +def notfound_view(request): + request.response.status = 404 + return {} diff --git a/docs/tutorials/wiki2/src/views/MANIFEST.in b/docs/tutorials/wiki2/src/views/MANIFEST.in index 81beba1b1..42cd299b5 100644 --- a/docs/tutorials/wiki2/src/views/MANIFEST.in +++ b/docs/tutorials/wiki2/src/views/MANIFEST.in @@ -1,2 +1,2 @@ include *.txt *.ini *.cfg *.rst -recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml +recursive-include tutorial *.ico *.png *.css *.gif *.jpg *.jinja2 *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/docs/tutorials/wiki2/src/views/production.ini b/docs/tutorials/wiki2/src/views/production.ini index 97acfbd7d..cb1db3211 100644 --- a/docs/tutorials/wiki2/src/views/production.ini +++ b/docs/tutorials/wiki2/src/views/production.ini @@ -11,8 +11,6 @@ pyramid.debug_authorization = false pyramid.debug_notfound = false pyramid.debug_routematch = false pyramid.default_locale_name = en -pyramid.includes = - pyramid_tm sqlalchemy.url = sqlite:///%(here)s/tutorial.sqlite diff --git a/docs/tutorials/wiki2/src/views/setup.py b/docs/tutorials/wiki2/src/views/setup.py index d4e5a4072..57538f2d0 100644 --- a/docs/tutorials/wiki2/src/views/setup.py +++ b/docs/tutorials/wiki2/src/views/setup.py @@ -9,6 +9,8 @@ with open(os.path.join(here, 'CHANGES.txt')) as f: CHANGES = f.read() requires = [ + 'bcrypt', + 'docutils', 'pyramid', 'pyramid_jinja2', 'pyramid_debugtoolbar', @@ -17,9 +19,12 @@ requires = [ 'transaction', 'zope.sqlalchemy', 'waitress', - 'docutils', ] +tests_require = [ + 'WebTest', +] + setup(name='tutorial', version='0.0', description='tutorial', @@ -38,6 +43,7 @@ setup(name='tutorial', include_package_data=True, zip_safe=False, test_suite='tutorial', + tests_require=tests_require, install_requires=requires, entry_points="""\ [paste.app_factory] diff --git a/docs/tutorials/wiki2/src/views/tutorial/__init__.py b/docs/tutorials/wiki2/src/views/tutorial/__init__.py index d28f09ca4..4dab44823 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/views/tutorial/__init__.py @@ -6,11 +6,7 @@ def main(global_config, **settings): """ config = Configurator(settings=settings) config.include('pyramid_jinja2') - config.include('.models.meta') - config.add_static_view('static', 'static', cache_max_age=3600) - config.add_route('view_wiki', '/') - config.add_route('view_page', '/{pagename}') - config.add_route('add_page', '/add_page/{pagename}') - config.add_route('edit_page', '/{pagename}/edit_page') + config.include('.models') + config.include('.routes') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py index 7b1c62867..a8871f6f5 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py @@ -1,7 +1,74 @@ +from sqlalchemy import engine_from_config +from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import configure_mappers -# import all models classes here for sqlalchemy mappers -# to pick up -from .mymodel import Page # flake8: noqa +import zope.sqlalchemy -# run configure mappers to ensure we avoid any race conditions +# import or define all models here to ensure they are attached to the +# Base.metadata prior to any initialization routines +from .page import Page # flake8: noqa +from .user import User # flake8: noqa + +# run configure_mappers after defining all of the models to ensure +# all relationships can be setup configure_mappers() + + +def get_engine(settings, prefix='sqlalchemy.'): + return engine_from_config(settings, prefix) + + +def get_session_factory(engine): + factory = sessionmaker() + factory.configure(bind=engine) + return factory + + +def get_tm_session(session_factory, transaction_manager): + """ + Get a ``sqlalchemy.orm.Session`` instance backed by a transaction. + + This function will hook the session to the transaction manager which + will take care of committing any changes. + + - When using pyramid_tm it will automatically be committed or aborted + depending on whether an exception is raised. + + - When using scripts you should wrap the session in a manager yourself. + For example:: + + import transaction + + engine = get_engine(settings) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + """ + dbsession = session_factory() + zope.sqlalchemy.register( + dbsession, transaction_manager=transaction_manager) + return dbsession + + +def includeme(config): + """ + Initialize the model for a Pyramid app. + + Activate this setup using ``config.include('tutorial.models')``. + + """ + settings = config.get_settings() + + # use pyramid_tm to hook the transaction lifecycle to the request + config.include('pyramid_tm') + + session_factory = get_session_factory(get_engine(settings)) + config.registry['dbsession_factory'] = session_factory + + # make request.dbsession available for use in Pyramid + config.add_request_method( + # r.tm is the transaction manager used by pyramid_tm + lambda r: get_tm_session(session_factory, r.tm), + 'dbsession', + reify=True + ) diff --git a/docs/tutorials/wiki2/src/views/tutorial/models/meta.py b/docs/tutorials/wiki2/src/views/tutorial/models/meta.py index 80ececd8c..fc3e8f1dd 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/models/meta.py +++ b/docs/tutorials/wiki2/src/views/tutorial/models/meta.py @@ -1,8 +1,5 @@ -from sqlalchemy import engine_from_config from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker from sqlalchemy.schema import MetaData -import zope.sqlalchemy # Recommended naming convention used by Alembic, as various different database # providers will autogenerate vastly different names making migrations more @@ -17,33 +14,3 @@ NAMING_CONVENTION = { metadata = MetaData(naming_convention=NAMING_CONVENTION) Base = declarative_base(metadata=metadata) - - -def includeme(config): - settings = config.get_settings() - dbmaker = get_dbmaker(get_engine(settings)) - - config.add_request_method( - lambda r: get_session(r.tm, dbmaker), - 'dbsession', - reify=True - ) - - config.include('pyramid_tm') - - -def get_session(transaction_manager, dbmaker): - dbsession = dbmaker() - zope.sqlalchemy.register(dbsession, - transaction_manager=transaction_manager) - return dbsession - - -def get_engine(settings, prefix='sqlalchemy.'): - return engine_from_config(settings, prefix) - - -def get_dbmaker(engine): - dbmaker = sessionmaker() - dbmaker.configure(bind=engine) - return dbmaker diff --git a/docs/tutorials/wiki2/src/views/tutorial/models/mymodel.py b/docs/tutorials/wiki2/src/views/tutorial/models/mymodel.py deleted file mode 100644 index 45571d78e..000000000 --- a/docs/tutorials/wiki2/src/views/tutorial/models/mymodel.py +++ /dev/null @@ -1,14 +0,0 @@ -from .meta import Base -from sqlalchemy import ( - Column, - Integer, - Text, -) - - -class Page(Base): - """ The SQLAlchemy declarative model class for a Page object. """ - __tablename__ = 'pages' - id = Column(Integer, primary_key=True) - name = Column(Text, unique=True) - data = Column(Integer) diff --git a/docs/tutorials/wiki2/src/views/tutorial/models/page.py b/docs/tutorials/wiki2/src/views/tutorial/models/page.py new file mode 100644 index 000000000..4dd5b5721 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tutorial/models/page.py @@ -0,0 +1,20 @@ +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + Text, +) +from sqlalchemy.orm import relationship + +from .meta import Base + + +class Page(Base): + """ The SQLAlchemy declarative model class for a Page object. """ + __tablename__ = 'pages' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + data = Column(Integer, nullable=False) + + creator_id = Column(ForeignKey('users.id'), nullable=False) + creator = relationship('User', backref='created_pages') diff --git a/docs/tutorials/wiki2/src/views/tutorial/models/user.py b/docs/tutorials/wiki2/src/views/tutorial/models/user.py new file mode 100644 index 000000000..6fb32a1b2 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tutorial/models/user.py @@ -0,0 +1,29 @@ +import bcrypt +from sqlalchemy import ( + Column, + Integer, + Text, +) + +from .meta import Base + + +class User(Base): + """ The SQLAlchemy declarative model class for a User object. """ + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=False, unique=True) + role = Column(Text, nullable=False) + + password_hash = Column(Text) + + def set_password(self, pw): + pwhash = bcrypt.hashpw(pw.encode('utf8'), bcrypt.gensalt()) + self.password_hash = pwhash + + def check_password(self, pw): + if self.password_hash is not None: + expected_hash = self.password_hash.encode('utf8') + actual_hash = bcrypt.hashpw(pw.encode('utf8'), expected_hash) + return expected_hash == actual_hash + return False diff --git a/docs/tutorials/wiki2/src/views/tutorial/routes.py b/docs/tutorials/wiki2/src/views/tutorial/routes.py new file mode 100644 index 000000000..72df58efe --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tutorial/routes.py @@ -0,0 +1,6 @@ +def includeme(config): + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('view_wiki', '/') + config.add_route('view_page', '/{pagename}') + config.add_route('add_page', '/add_page/{pagename}') + config.add_route('edit_page', '/{pagename}/edit_page') diff --git a/docs/tutorials/wiki2/src/views/tutorial/scripts/initializedb.py b/docs/tutorials/wiki2/src/views/tutorial/scripts/initializedb.py index 4aac4a848..f3c0a6fef 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/scripts/initializedb.py +++ b/docs/tutorials/wiki2/src/views/tutorial/scripts/initializedb.py @@ -7,13 +7,15 @@ from pyramid.paster import ( setup_logging, ) -from ..models.meta import ( - Base, - get_session, +from pyramid.scripts.common import parse_vars + +from ..models.meta import Base +from ..models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) -from ..models.mymodel import Page +from ..models import Page, User def usage(argv): @@ -27,16 +29,29 @@ def main(argv=sys.argv): if len(argv) < 2: usage(argv) config_uri = argv[1] + options = parse_vars(argv[2:]) setup_logging(config_uri) - settings = get_appsettings(config_uri) + settings = get_appsettings(config_uri, options=options) engine = get_engine(settings) - dbmaker = get_dbmaker(engine) - - dbsession = get_session(transaction.manager, dbmaker) - Base.metadata.create_all(engine) + session_factory = get_session_factory(engine) + with transaction.manager: - model = Page(name='FrontPage', data='This is the front page') - dbsession.add(model) + dbsession = get_tm_session(session_factory, transaction.manager) + + editor = User(name='editor', role='editor') + editor.set_password('editor') + dbsession.add(editor) + + basic = User(name='basic', role='basic') + basic.set_password('basic') + dbsession.add(basic) + + page = Page( + name='FrontPage', + creator=editor, + data='This is the front page', + ) + dbsession.add(page) diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2 new file mode 100644 index 000000000..37b0a16b6 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2 @@ -0,0 +1,8 @@ +{% extends "layout.jinja2" %} + +{% block content %} +<div class="content"> + <h1><span class="font-semi-bold">Pyramid tutorial wiki</span> <span class="smaller">(based on TurboGears 20-Minute Wiki)</span></h1> + <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> +</div> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/views/tutorial/templates/edit.jinja2 index b3aadfc2e..7db25c674 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/views/tutorial/templates/edit.jinja2 @@ -1,68 +1,20 @@ -<!DOCTYPE html> -<html lang="{{request.locale_name}}"> - <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="pyramid web application"> - <meta name="author" content="Pylons Project"> - <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - - <title>Edit{% if page.name %} {{page.name}}{% endif %} - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> - - <!-- Bootstrap core CSS --> - <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> - - <!-- Custom styles for this scaffold --> - <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> - - <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> - <!--[if lt IE 9]> - <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> - <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> - <![endif]--> - </head> - - <body> - - <div class="starter-template"> - <div class="container"> - <div class="row"> - <div class="col-md-2"> - <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> - </div> - <div class="col-md-10"> - <div class="content"> - <p> - Editing <strong>{% if page.name %}{{page.name}}{% else %}Page Name Goes Here{% endif %}</strong> - </p> - <p>You can return to the - <a href="{{request.application_url}}">FrontPage</a>. - </p> - <form action="{{ save_url }}" method="post"> - <div class="form-group"> - <textarea class="form-control" name="body" rows="10" cols="60">{{ page.data }}</textarea> - </div> - <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> - </div> - </form> - </div> - </div> - </div> - <div class="row"> - <div class="copyright"> - Copyright © Pylons Project - </div> - </div> - </div> - </div> - - - <!-- Bootstrap core JavaScript - ================================================== --> - <!-- Placed at the end of the document so the pages load faster --> - <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> - <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> - </body> -</html> +{% extends 'layout.jinja2' %} + +{% block subtitle %}Edit {{pagename}} - {% endblock subtitle %} + +{% block content %} +<p> +Editing <strong>{{pagename}}</strong> +</p> +<p>You can return to the +<a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. +</p> +<form action="{{ save_url }}" method="post"> +<div class="form-group"> + <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> +</div> +<div class="form-group"> + <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> +</div> +</form> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/views/tutorial/templates/layout.jinja2 index ff624c65b..71785157f 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/views/tutorial/templates/layout.jinja2 @@ -8,7 +8,7 @@ <meta name="author" content="Pylons Project"> <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - <title>Alchemy Scaffold for The Pyramid Web Framework</title> + <title>{% block subtitle %}{% endblock %}Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> <!-- Bootstrap core CSS --> <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> @@ -32,20 +32,9 @@ <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> </div> <div class="col-md-10"> - {% block content %} - <p>No content</p> - {% endblock content %} - </div> - </div> - <div class="row"> - <div class="links"> - <ul> - <li class="current-version">Generated by v1.7.dev0</li> - <li><i class="glyphicon glyphicon-bookmark icon-muted"></i><a href="http://docs.pylonsproject.org/projects/pyramid/en/1.7-branch/">Docs</a></li> - <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> - <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="irc://irc.freenode.net#pyramid">IRC Channel</a></li> - <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="http://pylonsproject.org">Pylons Project</a></li> - </ul> + <div class="content"> + {% block content %}{% endblock %} + </div> </div> </div> <div class="row"> diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/view.jinja2 b/docs/tutorials/wiki2/src/views/tutorial/templates/view.jinja2 index 36bb96870..94419e228 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/templates/view.jinja2 +++ b/docs/tutorials/wiki2/src/views/tutorial/templates/view.jinja2 @@ -1,66 +1,18 @@ -<!DOCTYPE html> -<html lang="{{request.locale_name}}"> - <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="pyramid web application"> - <meta name="author" content="Pylons Project"> - <link rel="shortcut icon" href="{{request.static_url('tutorial:static/pyramid-16x16.png')}}"> - - <title>{{page.name}} - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> - - <!-- Bootstrap core CSS --> - <link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> - - <!-- Custom styles for this scaffold --> - <link href="{{request.static_url('tutorial:static/theme.css')}}" rel="stylesheet"> - - <!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries --> - <!--[if lt IE 9]> - <script src="//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script> - <script src="//oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script> - <![endif]--> - </head> - - <body> - - <div class="starter-template"> - <div class="container"> - <div class="row"> - <div class="col-md-2"> - <img class="logo img-responsive" src="{{request.static_url('tutorial:static/pyramid.png')}}" alt="pyramid web framework"> - </div> - <div class="col-md-10"> - <div class="content"> - <p>{{ content|safe }}</p> - <p> - <a href="{{ edit_url }}"> - Edit this page - </a> - </p> - <p> - Viewing <strong>{% if page.name %}{{page.name}}{% else %}Page Name Goes Here{% endif %}</strong> - </p> - <p>You can return to the - <a href="{{request.application_url}}">FrontPage</a>. - </p> - </div> - </div> - </div> - <div class="row"> - <div class="copyright"> - Copyright © Pylons Project - </div> - </div> - </div> - </div> - - - <!-- Bootstrap core JavaScript - ================================================== --> - <!-- Placed at the end of the document so the pages load faster --> - <script src="//oss.maxcdn.com/libs/jquery/1.10.2/jquery.min.js"></script> - <script src="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/js/bootstrap.min.js"></script> - </body> -</html> +{% extends 'layout.jinja2' %} + +{% block subtitle %}{{page.name}} - {% endblock subtitle %} + +{% block content %} +<p>{{ content|safe }}</p> +<p> +<a href="{{ edit_url }}"> + Edit this page +</a> +</p> +<p> + Viewing <strong>{{page.name}}</strong>, created by <strong>{{page.creator.name}}</strong>. +</p> +<p>You can return to the +<a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. +</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/views/tutorial/tests.py b/docs/tutorials/wiki2/src/views/tutorial/tests.py index b947e3bb1..c54945c28 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/tests.py +++ b/docs/tutorials/wiki2/src/views/tutorial/tests.py @@ -13,22 +13,22 @@ class BaseTest(unittest.TestCase): self.config = testing.setUp(settings={ 'sqlalchemy.url': 'sqlite:///:memory:' }) - self.config.include('.models.meta') + self.config.include('.models') settings = self.config.get_settings() - from .models.meta import ( - get_session, + from .models import ( get_engine, - get_dbmaker, + get_session_factory, + get_tm_session, ) self.engine = get_engine(settings) - dbmaker = get_dbmaker(self.engine) + session_factory = get_session_factory(self.engine) - self.session = get_session(transaction.manager, dbmaker) + self.session = get_tm_session(session_factory, transaction.manager) def init_database(self): - from .models.meta import Base + from .models import Base Base.metadata.create_all(self.engine) def tearDown(self): @@ -36,7 +36,7 @@ class BaseTest(unittest.TestCase): testing.tearDown() transaction.abort() - Base.metadata.create_all(self.engine) + Base.metadata.drop_all(self.engine) class TestMyViewSuccessCondition(BaseTest): @@ -45,7 +45,7 @@ class TestMyViewSuccessCondition(BaseTest): super(TestMyViewSuccessCondition, self).setUp() self.init_database() - from .models.mymodel import MyModel + from .models import MyModel model = MyModel(name='one', value=55) self.session.add(model) diff --git a/docs/tutorials/wiki2/src/views/tutorial/views/default.py b/docs/tutorials/wiki2/src/views/tutorial/views/default.py index 3e5c61a72..c1d402f6a 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/views/tutorial/views/default.py @@ -9,24 +9,24 @@ from pyramid.httpexceptions import ( from pyramid.view import view_config -from ..models.mymodel import Page +from ..models import Page, User # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @view_config(route_name='view_wiki') def view_wiki(request): - return HTTPFound(location=request.route_url('view_page', - pagename='FrontPage')) + next_url = request.route_url('view_page', pagename='FrontPage') + return HTTPFound(location=next_url) -@view_config(route_name='view_page', renderer='templates/view.jinja2') +@view_config(route_name='view_page', renderer='../templates/view.jinja2') def view_page(request): pagename = request.matchdict['pagename'] page = request.dbsession.query(Page).filter_by(name=pagename).first() if page is None: - return HTTPNotFound('No such page') + raise HTTPNotFound('No such page') - def check(match): + def add_link(match): word = match.group(1) exists = request.dbsession.query(Page).filter_by(name=word).all() if exists: @@ -37,33 +37,34 @@ def view_page(request): return '<a href="%s">%s</a>' % (add_url, cgi.escape(word)) content = publish_parts(page.data, writer_name='html')['html_body'] - content = wikiwords.sub(check, content) - edit_url = request.route_url('edit_page', pagename=pagename) + content = wikiwords.sub(add_link, content) + edit_url = request.route_url('edit_page', pagename=page.name) return dict(page=page, content=content, edit_url=edit_url) -@view_config(route_name='add_page', renderer='templates/edit.jinja2') -def add_page(request): - pagename = request.matchdict['pagename'] - if 'form.submitted' in request.params: - body = request.params['body'] - page = Page(name=pagename, data=body) - request.dbsession.add(page) - return HTTPFound(location = request.route_url('view_page', - pagename=pagename)) - save_url = request.route_url('add_page', pagename=pagename) - page = Page(name='', data='') - return dict(page=page, save_url=save_url) - -@view_config(route_name='edit_page', renderer='templates/edit.jinja2') +@view_config(route_name='edit_page', renderer='../templates/edit.jinja2') def edit_page(request): pagename = request.matchdict['pagename'] page = request.dbsession.query(Page).filter_by(name=pagename).one() if 'form.submitted' in request.params: page.data = request.params['body'] - request.dbsession.add(page) - return HTTPFound(location = request.route_url('view_page', - pagename=pagename)) + next_url = request.route_url('view_page', pagename=page.name) + return HTTPFound(location=next_url) return dict( - page=page, - save_url = request.route_url('edit_page', pagename=pagename), + pagename=page.name, + pagedata=page.data, + save_url=request.route_url('edit_page', pagename=page.name), ) + +@view_config(route_name='add_page', renderer='../templates/edit.jinja2') +def add_page(request): + pagename = request.matchdict['pagename'] + if 'form.submitted' in request.params: + body = request.params['body'] + page = Page(name=pagename, data=body) + page.creator = ( + request.dbsession.query(User).filter_by(name='editor').one()) + request.dbsession.add(page) + next_url = request.route_url('view_page', pagename=pagename) + return HTTPFound(location=next_url) + save_url = request.route_url('add_page', pagename=pagename) + return dict(pagename=pagename, pagedata='', save_url=save_url) diff --git a/docs/tutorials/wiki2/src/views/tutorial/views/notfound.py b/docs/tutorials/wiki2/src/views/tutorial/views/notfound.py new file mode 100644 index 000000000..69d6e2804 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tutorial/views/notfound.py @@ -0,0 +1,7 @@ +from pyramid.view import notfound_view_config + + +@notfound_view_config(renderer='../templates/404.jinja2') +def notfound_view(request): + request.response.status = 404 + return {} diff --git a/docs/tutorials/wiki2/tests.rst b/docs/tutorials/wiki2/tests.rst index fe3fdaf2c..667550467 100644 --- a/docs/tutorials/wiki2/tests.rst +++ b/docs/tutorials/wiki2/tests.rst @@ -18,6 +18,14 @@ subpackage, and add several new tests. Start by creating a new directory and a new empty file ``tests/__init__.py``. +.. warning:: + + It is very important when refactoring a Python module into a package to + be sure to delete the cache files (``.pyc`` files or ``__pycache__`` + folders) sitting around! Python will prioritize the cache files before + traversing into folders and so it will use the old code and you will wonder + why none of your changes are working! + Test the views ============== @@ -35,7 +43,7 @@ Functional tests We'll test the whole application, covering security aspects that are not tested in the unit tests, like logging in, logging out, checking that -the ``viewer`` user cannot add or edit pages, but the ``editor`` user +the ``basic`` user cannot edit pages it didn't create, but the ``editor`` user can, and so on. @@ -57,39 +65,20 @@ follows: :language: python -Running the tests -================= +.. note:: -We can run these tests by using ``setup.py test`` in the same way we did in -:ref:`running_tests`. However, first we must edit our ``setup.py`` to include -a dependency on `WebTest -<http://docs.pylonsproject.org/projects/webtest/en/latest/>`_, which we've used -in our ``tests.py``. Change the ``requires`` list in ``setup.py`` to include -``WebTest``. + We're utilizing the excellent WebTest_ package to do functional testing + of the application. This is defined in the ``tests_require`` section of + our ``setup.py``. Any other dependencies needed only for testing purposes + can be added there and will be installed automatically when running + ``setup.py test``. -.. literalinclude:: src/tests/setup.py - :linenos: - :language: python - :lines: 11-22 - :emphasize-lines: 11 - -After we've added a dependency on WebTest in ``setup.py``, we need to run -``setup.py develop`` to get WebTest installed into our virtualenv. Assuming -our shell's current working directory is the "tutorial" distribution directory: -On UNIX: - -.. code-block:: bash - - $ $VENV/bin/python setup.py develop - -On Windows: - -.. code-block:: text - - c:\pyramidtut\tutorial> %VENV%\Scripts\python setup.py develop +Running the tests +================= -Once that command has completed successfully, we can run the tests themselves: +We can run these tests by using ``setup.py test`` in the same way we did in +:ref:`running_tests`: On UNIX: @@ -114,3 +103,5 @@ The expected result should look like the following: OK Process finished with exit code 0 + +.. _webtest: http://docs.pylonsproject.org/projects/webtest/en/latest/ |
