An image blog
Here, as another example, we wish to create a web application that allows the administrator to post images and give them a name, and allows the visitors of the web site to view the named images and submit comments (posts).
As before, from the site page in admin, create a new application called images
, and navigate to the edit page:
We start by creating a model, a representation of the persistent data in the application (the images to upload, their names, and the comments). First, you need to create/edit a model file which, for lack of imagination, we call “db.py”. We assume the code below will replace any existing code in “db.py”. Models and controllers must have a .py
extension since they are Python code. If the extension is not provided, it is appended by web2py. Views instead have a .html
extension since they mainly contain HTML code.
Remove the model “menu.py”.
Edit the “db.py” file by clicking the corresponding “edit” button:
and enter the following:
|
|
Let’s analyze this line by line.
Line 1 defines a global variable called db
that represents the database connection. In this case it is a connection to a SQLite database stored in the file “applications/images/databases/storage.sqlite”. When using SQLite, if the database file does not exist, it is created. You can change the name of the file, as well as the name of the global variable db
, but it is convenient to give them the same name, to make it easy to remember.
Lines 3-6 define a table “image”. define_table
is a method of the db
object. The first argument, “image”, is the name of the table we are defining. The other arguments are the fields belonging to that table. This table has a field called “title”, a field called “file”, and a field called “id” that serves as the table primary key (“id” is not explicitly declared because all tables have an id field by default). The field “title” is a string, and the field “file” is of type “upload”. “upload” is a special type of field used by the web2py Data Abstraction Layer (DAL) to store the names of uploaded files. web2py knows how to upload files (via streaming if they are large), rename them safely, and store them.
When a table is defined, web2py takes one of several possible actions:
- if the table does not exist, the table is created;
- if the table exists and does not correspond to the definition, the table is altered accordingly, and if a field has a different type, web2py tries to convert its contents;
- if the table exists and corresponds to the definition, web2py does nothing.
This behavior is called “migration”. In web2py migrations are automatic, but can be disabled for each table by passing migrate=False
as the last argument of define_table
.
Line 6 defines a format string for the table. It determines how a record should be represented as a string. Notice that the format
argument can also be a function that takes a record and returns a string. For example:
format=lambda row: row.title
Lines 8-12 define another table called “post”. A post has an “author”, an “email” (we intend to store the email address of the author of the post), a “body” of type “text” (we intend to use it to store the actual comment posted by the author), and an “image_id” field of type reference that points to db.image
via the “id” field.
In line 14, db.image.title
represents the field “title” of table “image”. The attribute requires
allows you to set requirements/constraints that will be enforced by web2py forms. Here we require that the “title” is unique:
IS_NOT_IN_DB(db, db.image.title)
Notice this is optional because it is set automatically given that Field('title', unique=True)
.
The objects representing these constraints are called validators. Multiple validators can be grouped in a list. Validators are executed in the order they appear. IS_NOT_IN_DB(a, b)
is a special validator that checks that the value of a field b
for a new record is not already in a
.
Line 15 requires that the field “image_id” of table “post” is in db.image.id
. As far as the database is concerned, we had already declared this when we defined the table “post”. Now we are explicitly telling the model that this condition should be enforced by web2py, too, at the form processing level when a new comment is posted, so that invalid values do not propagate from input forms to the database. We also require that the “image_id” be represented by the “title”, '%(title)s'
, of the corresponding record.
Line 20 indicates that the field “image_id” of table “post” should not be shown in forms, writable=False
and not even in read-only forms, readable=False
.
The meaning of the validators in lines 17-18 should be obvious.
Notice that the validator
db.post.image_id.requires = IS_IN_DB(db, db.image.id, '%(title)s')
can be omitted (and would be automatic) if we specify a format for referenced table:
db.define_table('image', ..., format='%(title)s')
where the format can be a string or a function that takes a record and returns a string.
Once a model is defined, if there are no errors, web2py creates an application administration interface to manage the database. You access it via the “database administration” link in the edit page or directly:
http://127.0.0.1:8000/images/appadmin
Here is a screenshot of the appadmin interface:
This interface is coded in the controller called “appadmin.py” and the corresponding view “appadmin.html”. From now on, we will refer to this interface simply as appadmin. It allows the administrator to insert new database records, edit and delete existing records, browse tables, and perform database joins.
The first time appadmin is accessed, the model is executed and the tables are created. The web2py DAL translates Python code into SQL statements that are specific to the selected database back-end (SQLite in this example). You can see the generated SQL from the edit page by clicking on the “sql.log” link under “models”. Notice that the link is not present until the tables have been created.
If you were to edit the model and access appadmin again, web2py would generate SQL to alter the existing tables. The generated SQL is logged into “sql.log”.
Now go back to appadmin and try to insert a new image record:
web2py has translated the db.image.file
“upload” field into an upload form for the file. When the form is submitted and an image file is uploaded, the file is renamed in a secure way that preserves the extension, it is saved with the new name under the application “uploads” folder, and the new name is stored in the db.image.file
field. This process is designed to prevent directory traversal attacks.
Notice that each field type is rendered by a widget. Default widgets can be overridden.
When you click on a table name in appadmin, web2py performs a select of all records on the current table, identified by the DAL query
db.image.id > 0
and renders the result.
You can select a different set of records by editing the DAL query and pressing [Submit].
To edit or delete a single record, click on the record id number.
Because of the IS_IN_DB
validator, the reference field “image_id” is rendered by a drop-down menu. The items in the drop-down are stored as keys (db.image.id
), but are represented by their db.image.title
, as specified by the validator.
Validators are powerful objects that know how to represent fields, filter field values, generate errors, and format values extracted from the field.
The following figure shows what happens when you submit a form that does not pass validation:
The same forms that are automatically generated by appadmin can also be generated programmatically via the SQLFORM
helper and embedded in user applications. These forms are CSS-friendly, and can be customized.
Every application has its own appadmin; therefore, appadmin itself can be modified without affecting other applications.
So far, the application knows how to store data, and we have seen how to access the database via appadmin. Access to appadmin is restricted to the administrator, and it is not intended as a production web interface for the application; hence the next part of this walk-through. Specifically we want to create:
- An “index” page that lists all available images sorted by title and links to detail pages for the images.
- A “show/[id]“ page that shows the visitor the requested image and allows the visitor to view and post comments.
- A “download/[name]“ action to download uploaded images.
This is represented schematically here:
Go back to the edit page and edit the “default.py” controller, replacing its contents with the following:
def index():
images = db().select(db.image.ALL, orderby=db.image.title)
return dict(images=images)
This action returns a dictionary. The keys of the items in the dictionary are interpreted as variables passed to the view associated to the action. When developing, if there is no view, the action is rendered by the “generic.html” view that is provided with every web2py application.
The index action performs a select of all fields (db.image.ALL
) from table image, ordered by db.image.title
. The result of the select is a Rows
object containing the records. Assign it to a local variable called images
returned by the action to the view. images
is iterable and its elements are the selected rows. For each row the columns can be accessed as dictionaries: images[0]['title']
or equivalently as images[0].title
.
If you do not write a view, the dictionary is rendered by “views/generic.html” and a call to the index action would look like this:
You have not created a view for this action yet, so web2py renders the set of records in plain tabular form.
Proceed to create a view for the index action. Return to admin, edit “default/index.html” and replace its content with the following:
|
|
The first thing to notice is that a view is pure HTML with special {{…}} tags. The code embedded in {{…}} is pure Python code with one caveat: indentation is irrelevant. Blocks of code start with lines ending in colon (:) and end in lines beginning with the keyword pass
. In some cases the end of a block is obvious from context and the use of pass
is not required.
Lines 5-7 loop over the image rows and for each row image display:
LI(A(image.title, _href=URL('show', args=image.id))
This is a <li>...</li>
tag that contains an <a href="...">...</a>
tag which contains the image.title
. The value of the hypertext reference (href attribute) is:
URL('show', args=image.id)
i.e., the URL within the same application and controller as the current request that calls the function called “show”, passing a single argument to the function, args=image.id
. LI
, A
, etc. are web2py helpers that map to the corresponding HTML tags. Their unnamed arguments are interpreted as objects to be serialized and inserted in the tag’s innerHTML. Named arguments starting with an underscore (for example _href
) are interpreted as tag attributes but without the underscore. For example _href
is the href
attribute, _class
is the class
attribute, etc.
As an example, the following statement:
{{=LI(A('something', _href=URL('show', args=123))}}
is rendered as:
<li><a href="/images/default/show/123">something</a></li>
A handful of helpers (INPUT
, TEXTAREA
, OPTION
and SELECT
) also support some special named attributes not starting with underscore (value
, and requires
). They are important for building custom forms and will be discussed later.
Go back to the edit page. It now indicates that “default.py exposes index”. By clicking on “index”, you can visit the newly created page:
http://127.0.0.1:8000/images/default/index
which looks like:
If you click on the image name link, you are directed to:
http://127.0.0.1:8000/images/default/show/1
and this results in an error, since you have not yet created an action called “show” in controller “default.py”.
Let’s edit the “default.py” controller and replace its content with:
def index():
images = db().select(db.image.ALL, orderby=db.image.title)
return dict(images=images)
def show():
image = db.image(request.args(0, cast=int)) or redirect(URL('index'))
db.post.image_id.default = image.id
form = SQLFORM(db.post)
if form.process().accepted:
response.flash = 'your comment is posted'
comments = db(db.post.image_id == image.id).select(orderby=db.post.id)
return dict(image=image, comments=comments, form=form)
def download():
return response.download(request, db)
The controller contains two new actions: “show” and “download”. The “show” action selects the image with the id
parsed from the request args and all comments related to the image. “show” then passes everything to the view “default/show.html”.
The image id referenced by:
URL('show', args=image.id)
in “default/index.html”, can be accessed as:
request.args(0, cast=int)
from the “show” action. The cast=int
argument is optional but very important. It attempts to cast the string value passed in the PATH_INFO into an int. On failure it raises a proper exception instead of causing a ticket. One can also specify a redirect in case of failure to cast:
request.args(0, cast=int, otherwise=URL('error'))
Moreover db.image(...)
is a shortcut for
db(db.image.id == ...).select().first()
The “download” action expects a filename in request.args(0)
, builds a path to the location where that file is supposed to be, and sends it back to the client. If the file is too large, it streams the file without incurring any memory overhead.
Notice the following statements:
- Line 7 sets the value for the reference field, which is not part of the input form because of the
db.post
table model. - Line 8 creates an insert form SQLFORM for the
db.post
table. - Line 9 processes the submitted form (the submitted form variables are in
request.vars
) within the current session (the session is used to prevent double submissions, and to enforce navigation). If the submitted form variables are validated, the new comment is inserted in thedb.post
table; otherwise the form is modified to include error messages (for example, if the author’s email address is invalid). This is all done in line 9!. - Line 10 is only executed if the form is accepted, after the record is inserted into the database table.
response.flash
is a web2py variable that is displayed in the views and used to notify the visitor that something happened. - Line 11 selects all comments that reference the current image, the
.select(orderby=db.post.id)
keeps comments history sorted.
The “download” action is already defined in the “default.py” controller of the scaffolding application.
The “download” action does not return a dictionary, so it does not need a view. The “show” action, though, should have a view, so return to admin and create a new view called “default/show.html”.
Edit this new file and replace its content with the following:
{{extend 'layout.html'}}
<h1>Image: {{=image.title}}</h1>
<div style="text-align:center">
<img width="200px"
src="{{=URL('download', args=image.file)}}" />
</div>
{{if len(comments):}}
<h2>Comments</h2><br /><p>
{{for post in comments:}}
<p>{{=post.author}} says <i>{{=post.body}}</i></p>
{{pass}}</p>
{{else:}}
<h2>No comments posted yet</h2>
{{pass}}
<h2>Post a comment</h2>
{{=form}}
This view displays the image.file by calling the “download” action inside an <img ... />
tag. If there are comments, it loops over them and displays each one.
Here is how everything will appear to a visitor.
When a visitor submits a comment via this page, the comment is stored in the database and appended at the bottom of the page.
Adding authentication
The web2py API for Role-Based Access Control is quite sophisticated, but for now we will limit ourselves to restricting access to the show action to authenticated users, deferring a more detailed discussion to Chapter 9.
To limit access to authenticated users, we need to complete three steps. In a model, for example “db.py”, we need to add:
from gluon.tools import Auth
auth = Auth(db)
auth.define_tables(username=True)
In our controller, we need to add one action:
def user():
return dict(form=auth())
This is sufficient to enable login, register, logout, etc. pages. The default layout will also show options to the corresponding pages in the top right corner.
We can now decorate the functions that we want to restrict, for example:
@auth.requires_login()
def show():
...
Any attempt to access
http://127.0.0.1:8000/images/default/show/[image_id]
will require login. If the user is not logged it, the user will be redirected to
http://127.0.0.1:8000/images/default/user/login
The user
function also exposes, among others, the following actions:
http://127.0.0.1:8000/images/default/user/logout
http://127.0.0.1:8000/images/default/user/register
http://127.0.0.1:8000/images/default/user/profile
http://127.0.0.1:8000/images/default/user/change_password
http://127.0.0.1:8000/images/default/user/request_reset_password
http://127.0.0.1:8000/images/default/user/retrieve_username
http://127.0.0.1:8000/images/default/user/retrieve_password
http://127.0.0.1:8000/images/default/user/verify_email
http://127.0.0.1:8000/images/default/user/impersonate
http://127.0.0.1:8000/images/default/user/not_authorized
Now, a first-time user needs to register in order to be able to log in and read or post comments.
Both the
auth
object and theuser
function are already defined in the scaffolding application. Theauth
object is highly customizable and can deal with email verification, registration approvals, CAPTCHA, and alternate login methods via plugins.
Adding grids
We can improve this further using the SQLFORM.grid
and SQLFORM.smartgrid
gadgets to create a management interface for our application:
@auth.requires_membership('manager')
def manage():
grid = SQLFORM.smartgrid(db.image, linked_tables=['post'])
return dict(grid=grid)
with associated “views/default/manage.html”
{{extend 'layout.html'}}
<h2>Management Interface</h2>
{{=grid}}
Using appadmin create a group “manager” and make some users members of the group. They will be able to access
http://127.0.0.1:8000/images/default/manage
and browse, search:
create, update and delete images and their comments:
Configuring the layout
You can configure the default layout by editing “views/layout.html” but you can also configure it without editing the HTML. In fact, the “static/css/web2py.css” stylesheet is well documented and described in Chapter 5. You can change color, columns, size, borders and background without editing the HTML. If you want to edit the menu, the title or the subtitle, you can do so in any model file. The scaffolding app, sets default values of these parameters in the file “models/menu.py”:
response.title = request.application
response.subtitle = 'customize me!'
response.meta.author = 'you'
response.meta.description = 'describe your app'
response.meta.keywords = 'bla bla bla'
response.menu = [ [ 'Index', False, URL('index') ] ]