Tutorial Step 6 - User roles and buying the items
The goal of this tutorial step is to provide the user account creation and the possibility of buying the items from the shop.
Contents
Creating user model
At the beginning we must describe the user. Let it be an entity that has the following fields:
- login - that should identify the unique user (username)
- password - the password that should be connected to the user
- real name
- address
The following fields should be mapped on the Erlang Web record definition (shop/include/user.hrl):
1 -record(user, {
2 login = "",
3 password = "",
4 real_name = "",
5 address = ""
6 }).
7
8 -record(user_types, {
9 login = {string, [primary_key,
10 {min_length, 3},
11 {max_length, 20},
12 {regexp, "^[A-Za-z][0-9A-Za-z._]+$"},
13 {description, "Login"}]},
14 password = {password, [{min_length, 6},
15 {max_length, 256},
16 {description, "Password"}]},
17 real_name = {string, [{min_length, 4},
18 {regexp, "[A-Za-z]+ [A-Za-z]+"},
19 {description, "Real name"}]},
20 address = {text, [{description, "Address to ship"}]}
21 }).
As you can see, we put a constraint on the login and the real_name fields - both fields must match the given regular expressions. Let's generate a model now:
$ ./bin/generate.erl model --app shop --name user --hrl lib/shop-0.1/include/user.hrl File (...)/lib/shop-0.1/src/wtype_user.erl created successfully!
Creating user controller
When we have a model, we should also create a simple controller for managing the users. It should be very similar to the existing one - the one for the items:
$ ./bin/generate.erl controller --app shop --name shop_user Exported functions (separated with ,): add, edit, do_edit, remove File (...)/lib/shop-0.1/src/shop_user.erl created successfully!
Good - now is the time for configuring the dispatcher. We should separate the user management URLs from the rest of the addresses - put them in the config/dispatcher/user.conf file and add a delegate entry in the main dispatcher file:
1 {dynamic, delegate, "^/user/", "config/dispatcher/user.conf"}.
and config/dispatcher/user.conf file:
1 {static, "add$", "user/add.html"}.
2 {dynamic, "do_add$", {shop_user, add}}.
3 {dynamic, "edit$", {shop_user, edit}}.
4 {dynamic, "do_edit$", {shop_user, do_edit}}.
5 {dynamic, "remove$", {shop_user, remove}}.
The adding functionality should be available only for not logged users. Let's write the proper annotation (the opposite of the authorize one - shop_utils.erl):
1 ?BEFORE.
2 not_logged_in(_AnnArg, _Mod, _Fun, Args) ->
3 case e_auth:status() of
4 true ->
5 {skip, {redirect, "/item/all"}};
6 false ->
7 {proceed, Args}
8 end.
Then we should edit the user.erl file:
1 ?NOT_LOGGED_IN(not_used).
2 ?VALIDATE({user, create}).
3 add(User) ->
4 case wtype_user:read(User#user.login) of
5 not_found ->
6 wtype_user:create(User#user{password = ""}),
7 e_auth:add_user(User#user.login, User#user.password),
8 {redirect, "/login"};
9 _ ->
10 {template, "user/login_taken.html"}
11 end.
Before the add/1 function we are checking if we are not logged in, and if so - the validity of the passed data. Then we will check if someone with the given username does not exist. We did not create a separate annotation for that since that check will be used only once - in the user:add/1 function.
Next function is edit:
1 ?AUTHORIZE(not_used).
2 edit(_Args) ->
3 User = wtype_user:read(e_auth:username()),
4
5 wpart:fset("__edit", wpart_db:build_record_structure(user, User)),
6 wpart:fset("__primary_key", User#user.login),
7
8 {template, "user/edit.html"}.
Here is a place where we check if we are logged in (authorize annotation). If so, we read the data about us (the username is retrieved from e_auth:username/0) and set both: edit and primary_key request dictionary variables.
Third function implementation is do_edit:
1 ?AUTHORIZE(not_used).
2 ?VALIDATE({user, update}).
3 do_edit(User) ->
4 case wtype_user:read(User#user.login) of
5
6 not_found ->
7 wtype_user:create(User#user{password = ""}),
8 wtype_user:delete(e_auth:username()),
9
10 e_auth:rename_user(e_auth:username(), User#user.login),
11 e_auth:change_password(User#user.login, User#user.password),
12 e_auth:logout(),
13 e_auth:login(User#user.login, User#user.password),
14
15 {template, "user/update_successful.html"};
16 OldUser ->
17 CurrentUser = e_auth:username(),
18 if
19 OldUser#user.login =/= CurrentUser ->
20 {template, "user/login_taken.html"};
21 true ->
22 wtype_user:update(User#user{password = ""}),
23
24 e_auth:change_password(User#user.login, User#user.password),
25
26 {template, "user/update_successful.html"}
27 end
28 end.
Once again we check if the user is logged in, then if he passed the correct data. The generic validator does not handle the case-specific checks - like here when we want to check, if the desired username is not already taken. Because the check will be used only in one place it is not worth to convert it to the separate annotation.
Note that we are also using a pair: delete/create instead of update when the login has changed - this is done because we are using login as a primary key and the updates of the mnesia tables (which are actually sets in our case) overwrite the old content. It will not happen if we change our login - the old one will remain as it was so we must remember to delete it manually.
The last controller function is the remove one:
1 ?AUTHORIZE(not_used).
2 remove(_Args) ->
3 wtype_user:delete(wtype_user:read(e_auth:username())),
4 login:logout(not_used).
Moreover, we should not forget about the validate error callback function:
1 validate_error(add) ->
2 wpart:fset("__edit", wtype_user:prepare_validated()),
3 {template, "user/add.html"};
4 validate_error(do_edit) ->
5 wpart:fset("__edit", wtype_user:prepare_validated()),
6 {template, "user/edit.html"}.
Since user templates are not very sophisticated and are almost the same as the ones used for items there is no need to paste them here. All of them will be included in the tarball with code.
Creating new table
Before the new functionality starts working we must create a new table in our database (this step can be skipped if we are using CouchDB as our DBMS). We should slightly modify the contents of the db_init.erl file:
1 -define(TABS, [item, user]).
2
3 -include("lib/shop-0.1/include/user.hrl").
Moreover, it is a good idea to export the install/1 function if we do not want to delete our schema.
Compile the module, run db_init:install(user) and enjoy the new users functionality!
Some improvements
As we could notice it is a little bit annoying that after each login process we are redirected straight into the login/successful.html page. The good idea is to be redirected back to the page we were going to visit. It could be easy done by editing the authorize annotation and login function.
At first, let's remember what page we wanted to see before the login process. We should save that information in our session (shop_utils.erl):
1 ?BEFORE.
2 authorize(_AnnArg, _Mod, _Fun, Args) ->
3 case e_auth:status() of
4 true ->
5 {proceed, Args};
6 false ->
7 wpart:fset("session:about2see", wpart:fget("__path")),
8 {skip, {redirect, "/login"}}
9 end.
The path variable holds the URL we are entered in the browser.
Now, after login, we should check if we wanted to go somewhere (login.erl):
1 login(_Args) ->
2 Username = wpart:fget("post:username"),
3 Password = wpart:fget("post:password"),
4
5 case e_auth:login(Username, Password) of
6 ok ->
7 case wpart:fget("session:about2see") of
8 undefined ->
9 {template, "login/successful.html"};
10 URL ->
11 wpart:finsert("session:about2see", undefined),
12 {redirect, [$/ | URL]}
13 end;
14 {error, Reason} ->
15 wpart:fset("error", Reason),
16 {template, "login/unsuccessful.html"}
17 end.
And here we are reading the previously set about2see variable. If someone went straight into the login form and his about2see is blank, he will see the beautiful successful login page.
Adding user roles
Ok - so far we managed to add users to our shop. Do you remember the item_admin controller? We were using authorize annotation intensively there. But because now we will have two different types of authorized entities we have to distinct the standard buyer from the seller (admin). In order to achieve that we must change the authorize annotation to accept the target entity role (shop_utils.erl):
1 ?BEFORE.
2 authorize(Role, _Mod, _Fun, Args) ->
3 case e_auth:status() of
4 true ->
5 case e_auth:authorize([Role]) of
6 ok ->
7 {proceed, Args};
8 {error, _Reason} ->
9 {skip, {template, "not_allowed.html"}}
10 end;
11 false ->
12 wpart:fset("session:about2see", wpart:fget("__path")),
13 {skip, {redirect, "/login"}}
14 end.
e_auth:authorize/1 accepts as an argument a list of the groups. If the user belongs at least to the one of the passed groups he is authorized. Otherwise, he will be see a pag informing him about the error.
Having the annotation definition ready we should modify all of its occurrences in our code. At first we should change the item_admin ?AUTHORIZE(not_used). to ?AUTHORIZE("admin"). Then - edit the shop_user and change ?AUTHORIZE(not_used) to ?AUTHORIZE("user") (e_auth operates on strings as a group names).
The last step is to add the new user to the user group (shop_user.erl):
1 ?NOT_LOGGED_IN(not_used).
2 ?VALIDATE({user, create}).
3 add(User) ->
4 case wtype_user:read(User#user.login) of
5 not_found ->
6 wtype_user:create(User#user{password = ""}),
7
8 e_auth:add_user(User#user.login, User#user.password),
9 e_auth:add_to_group(User#user.login, "user"),
10
11 {redirect, "/login"};
12 _ ->
13 {template, "user/login_taken.html"}
14 end.
We are almost done. Right now we should create two new groups: "user" and "admin" and add admin to the "admin" group manually (consider adding it to the db_init module):
1 e_auth:add_group("user").
2 e_auth:add_group("admin").
3 e_auth:add_to_group("admin", "admin").
From now user can:
- edit his profile
- remove himself from the system
Admin can:
- add/edit/remove the items
Buying the items
And here comes the time when we should focus on the most important aspect of the shop - buying things. As a starting point we should add new function to the browser controller:
1 ?AUTHORIZE("user").
2 ?CHECK_EXISTENCE({id, item}).
3 buy_item(Item) ->
4 if
5 Item#item.available == "true" ->
6 wtype_item:update(Item#item{available = "false"}),
7
8 wpart:fset("item", wtype_item:format(Item)),
9 wpart:fset("tid", e_db:get_next_id(tid)),
10
11 {template, "item/sold.html"};
12 true ->
13 wpart:fset("id", Item#item.id),
14 {template, "item/not_available.html"}
15 end.
At first we check if the user is properly logged in. Then we should check if the item he is trying to buy is still available. If so, we are checking its availability and if everything goes right - we mark the item as sold, generate a transaction ID and render the page with the account number and contact details. Otherwise we are informing user about the error.
The last part is the dispatcher and templates updates. Let's start from the configuration (dispatch.conf):
1 {dynamic, "^/item/buy/(?<id>[0-9]+)$", {browser, buy_item}}.
Then comes the item description item/show.html:
<html>
<head>
<title>Erlang Web Shop - <wpart:lookup key="item:name" /></title>
</head>
<body>
<h1><wpart:lookup key="item:name" /></h1>
<wpart:choose>
<wpart:when test="not {item:available}">
<h2>Item is not available!</h2>
</wpart:when>
<wpart:otherwise>
<p><a wpart:href="/item/buy/{[integer]item:id}">Buy this item</a></p>
</wpart:otherwise>
</wpart:choose>
<p>Price: <wpart:lookup key="item:price" format="float(2)" /></p>
<p><wpart:lookup key="item:description" /></p>
</body>
</html>and the last important template, item/sold.html:
<html> <head> <title>Erlang Web Shop - <wpart:lookup key="item:name" /> sold</title> </head> <body> <p>You have bought the following item: <p><wpart:lookup key="item:name" /></p> <p>for <wpart:lookup key="item:price" format="float(2)" /> pounds</p> <p>You transaction id is <wpart:lookup key="tid" format="integer" /> use it as a comment when you will be transfering the funds on our account.</p> <p>Our account data: ...</p> </body> </html>
Download
This tutorial step is over, the package containing the sources of the application can be downloaded from here.
