The user interface – without access control¶
There is quite a bit of user interface in this application besides its access control requirements. Implementing the application without access control is not trivial, so it is a good idea to get it out of the way.
Tests¶
Let us add three user stories to the tests that would excercise the functionality of the user interface, but sidestep the finer grained access control. That will force us to build the entire application, but allow us to ignore any access control related code for the moment. The following three scenarios would do that:
- How a user arrives at the site and has to log in before seeing any address books.
- How a user adds and edits addresses in her own address book (this is will always be allowed, hence no access control yet).
- How the owner of an address book adds collaborators to his address book (ditto).
Because it is valuable to play with a running application too, a DemoFixture is also added to the tests that sets up a few SystemAccounts and adds some Addresses to their AddressBooks:
# To run this test do:
# nosetests -F reahl.webdev.fixtures:BrowserSetup -s --nologcapture reahl/doc_dev/tutorialtests/accesstests2.py
#
# To set up a demo database for playing with, do:
# nosetests -F reahl.webdev.fixtures:BrowserSetup --with-setup-fixture=reahl.doc_dev.tutorialtests.accesstests2:DemoFixture -s --nologcapture
from __future__ import unicode_literals
from __future__ import print_function
from reahl.tofu import test, set_up
from reahl.web_dev.fixtures import WebFixture
from reahl.webdev.tools import Browser, XPath
from reahl.sqlalchemysupport import Session
from reahl.systemaccountmodel import EmailAndPasswordSystemAccount
from reahl.doc.examples.tutorial.access2.access2 import AddressBookUI, AddressBook, Address
class AccessFixture(WebFixture):
def new_browser(self):
return Browser(self.new_wsgi_app(site_root=AddressBookUI))
password = 'bobbejaan'
def new_account(self, email='[email protected]'):
account = EmailAndPasswordSystemAccount(email=email)
account.set_new_password(account.email, self.password)
account.activate()
return account
def new_address_book(self, owner=None):
owner = owner or self.account
return AddressBook(owner=owner)
def new_other_account(self):
return self.new_account(email='[email protected]')
def new_other_address_book(self):
return self.new_address_book(owner=self.other_account)
def is_on_address_book_page_of(self, email):
return self.browser.title == 'Address book of %s' % email
class DemoFixture(AccessFixture):
commit=True
@set_up
def do_demo_setup(self):
self.address_book
john = self.account
jane = self.new_account(email='[email protected]')
jane_book = self.new_address_book(owner=jane)
someone = self.new_account(email='[email protected]')
someone_book = self.new_address_book(owner=someone)
someone_else = self.new_account(email='[email protected]')
someone_else_book = self.new_address_book(owner=someone_else)
jane_book.allow(john, can_add_addresses=True, can_edit_addresses=True)
someone_book.allow(john, can_add_addresses=False, can_edit_addresses=True)
someone_else_book.allow(john, can_add_addresses=False, can_edit_addresses=False)
Address(address_book=jane_book, email_address='[email protected]', name='Friend1').save()
Address(address_book=jane_book, email_address='[email protected]', name='Friend2').save()
Address(address_book=jane_book, email_address='[email protected]', name='Friend3').save()
Address(address_book=jane_book, email_address='[email protected]', name='Friend4').save()
Address(address_book=someone_book, email_address='[email protected]', name='Friend11').save()
Address(address_book=someone_book, email_address='[email protected]', name='Friend12').save()
Address(address_book=someone_book, email_address='[email protected]', name='Friend13').save()
Address(address_book=someone_book, email_address='[email protected]', name='Friend14').save()
Address(address_book=someone_else_book, email_address='[email protected]', name='Friend21').save()
Address(address_book=someone_else_book, email_address='[email protected]', name='Friend22').save()
Address(address_book=someone_else_book, email_address='[email protected]', name='Friend23').save()
Address(address_book=someone_else_book, email_address='[email protected]', name='Friend24').save()
Session.flush()
@test(AccessFixture)
def separate_address_books(fixture):
"""An Address is created in a particular AddressBook, which is owned by a SystemAccount."""
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
# AddressBooks are owned
address_book.owner is account
other_address_book.owner is fixture.other_account
# Addresses live in specific AddressBooks
assert address_book.addresses == []
assert other_address_book.addresses == []
address1 = Address(address_book=address_book, email_address='[email protected]', name='Friend1')
address2 = Address(address_book=address_book, email_address='[email protected]', name='Friend2')
address3 = Address(address_book=other_address_book, email_address='[email protected]', name='Friend3')
for address in [address1, address2, address3]:
address.save()
assert address_book.addresses == [address1, address2]
assert other_address_book.addresses == [address3]
@test(AccessFixture)
def collaborators(fixture):
"""A particular SystemAccount can see its own AddressBooks as well as all the AddressBooks
it is explicitly allowed to see, but no other AddressBooks."""
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
unrelated_account = fixture.new_account(email='[email protected]')
unrelated_address_book = fixture.new_address_book(owner=unrelated_account)
other_address_book.allow(account)
# Checks to see whether an AddressBook is visible
assert address_book.is_visible_to(account)
assert other_address_book.is_visible_to(account)
assert not unrelated_address_book.is_visible_to(account)
# Getting a list of visible AddressBooks (for populating the screen)
books = AddressBook.address_books_visible_to(account)
assert set(books) == {address_book, other_address_book}
@test(AccessFixture)
def collaborator_rights(fixture):
"""When allowing an account to see another's AddressBook, the rights it has to the AddressBook are specified."""
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
# Case: defaults
other_address_book.allow(account)
assert not other_address_book.can_be_edited_by(account)
assert not other_address_book.can_be_added_to_by(account)
# Case: rights specified
other_address_book.allow(account, can_edit_addresses=True, can_add_addresses=True)
assert other_address_book.can_be_edited_by(account)
assert other_address_book.can_be_added_to_by(account)
@test(AccessFixture)
def adding_collaborators(fixture):
"""The owner of an AddressBook may add collaborators to it."""
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
assert address_book.collaborators_can_be_added_by(account)
assert not other_address_book.collaborators_can_be_added_by(account)
@test(AccessFixture)
def logging_in(fixture):
"""A user first sees only a login screen on the home page; after logging in,
all the address books visible to the user appear."""
browser = fixture.browser
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
other_address_book.allow(account)
browser.open('/')
browser.type(XPath.input_labelled('Email'), '[email protected]')
browser.type(XPath.input_labelled('Password'), fixture.password)
browser.click(XPath.button_labelled('Log in'))
assert browser.is_element_present(XPath.link_with_text('Address book of [email protected]'))
assert browser.is_element_present(XPath.link_with_text('Address book of [email protected]'))
@test(AccessFixture)
def edit_and_add_own(fixture):
"""The owner of an AddressBook can add and edit Addresses to the owned AddressBook."""
browser = fixture.browser
account = fixture.account
address_book = fixture.address_book
fixture.log_in(browser=browser, system_account=account)
browser.open('/')
browser.click(XPath.link_with_text('Address book of [email protected]'))
# add
browser.click(XPath.link_with_text('Add address'))
browser.type(XPath.input_labelled('Name'), 'Someone')
browser.type(XPath.input_labelled('Email'), '[email protected]')
browser.click(XPath.button_labelled('Save'))
assert browser.is_element_present(XPath.paragraph_containing('Someone: [email protected]'))
# edit
browser.click(XPath.button_labelled('Edit'))
browser.type(XPath.input_labelled('Name'), 'Else')
browser.type(XPath.input_labelled('Email'), '[email protected]')
browser.click(XPath.button_labelled('Update'))
assert browser.is_element_present(XPath.paragraph_containing('Else: [email protected]'))
@test(AccessFixture)
def add_collaborator(fixture):
"""A user may add other users as collaborators to his address book, specifying the privileges in the process."""
browser = fixture.browser
account = fixture.account
address_book = fixture.address_book
other_address_book = fixture.other_address_book
other_account = fixture.other_account
fixture.log_in(browser=browser, system_account=account)
browser.open('/')
assert address_book not in AddressBook.address_books_visible_to(other_account)
assert not address_book.can_be_edited_by(other_account)
assert not address_book.can_be_added_to_by(other_account)
browser.click(XPath.link_with_text('Address book of [email protected]'))
browser.click(XPath.link_with_text('Add collaborator'))
browser.select(XPath.select_labelled('Choose collaborator'), '[email protected]')
browser.click(XPath.input_labelled('May add new addresses'))
browser.click(XPath.input_labelled('May edit existing addresses'))
browser.click(XPath.button_labelled('Share'))
assert fixture.is_on_address_book_page_of('[email protected]')
assert address_book in AddressBook.address_books_visible_to(other_account)
assert address_book.can_be_edited_by(other_account)
assert address_book.can_be_added_to_by(other_account)
Code¶
The implementation that makes those tests pass is given in the code below. There are several things you’d have to understand in this solution:
The application uses an AddressAppPage for a page. This is just to make the application a little easier to drive: it displays who is logged in currently at the top of the page, and includes a Menu which just gives the user a way to always return to the home page.
When not logged in, the home page gives the user a way to log in using an email address and password. When logged in, a list of AddressBooks is shown. The changing home page is achieved by constructing the HomePageWidget with different children depending on whether the user is logged in or not.
Most importantly, the user interface includes a number of Views , many of them parameterised, and with transitions amongst these parameterised Views . It is easy to get lost in such code, or to build it in a messy way. Hence, it warrants your undivided attention before we complicate the application further.
The Reahl way to deal with this situation is to first sketch it out on a diagram and then translate the Views and Transitions into an .assemble() method. In this method (right at the bottom of the code below), one can see which Views are parameterised, and thus which Transitions are to parameterised Views . Use this information to check that the correct arguments are specified to .with_arguments() where Buttons are placed for each Event that may lead to a parameterised View.
We have decided to use .define_event_handler() in LoginForm and LogoutForm for allowing login_event and logout_event to be linked to Buttons in these Widgets. All other Events are dealt with using Transitions defined in AddressBookUI.assemble(). When it comes to being able to clearly visualise all the pathways through the application, some Events are just more important to actually show on a visual diagram than others. This code mirrors our schematic diagram.
Please take your time to study the code below:
from __future__ import unicode_literals
from __future__ import print_function
import elixir
from reahl.sqlalchemysupport import Session, metadata
from reahl.systemaccountmodel import AccountManagementInterface, EmailAndPasswordSystemAccount, UserSession
from reahl.component.modelinterface import exposed, IntegerField, BooleanField, Field, EmailField, Event, Action, Choice, ChoiceField
from reahl.web.fw import UserInterface, UrlBoundView, CannotCreate
from reahl.web.ui import TwoColumnPage, Form, TextInput, LabelledBlockInput, Button, Panel, A, P, H, InputGroup, HMenu,\
PasswordInput, ErrorFeedbackMessage, Slot, Widget, SelectInput, CheckboxInput
class Address(elixir.Entity):
elixir.using_options(session=Session, metadata=metadata)
elixir.using_mapper_options(save_on_init=False)
address_book = elixir.ManyToOne('reahl.doc.examples.tutorial.access2.access2.AddressBook')
email_address = elixir.Field(elixir.UnicodeText)
name = elixir.Field(elixir.UnicodeText)
@classmethod
def by_id(cls, address_id, exception_to_raise):
addresses = Address.query.filter_by(id=address_id)
if addresses.count() != 1:
raise exception_to_raise
return addresses.one()
@exposed
def fields(self, fields):
fields.name = Field(label='Name')
fields.email_address = EmailField(label='Email', required=True)
@exposed('save', 'update', 'edit')
def events(self, events):
events.save = Event(label='Save', action=Action(self.save))
events.update = Event(label='Update')
events.edit = Event(label='Edit')
def save(self):
Session.add(self)
class AddressBook(elixir.Entity):
elixir.using_options(session=Session, metadata=metadata)
owner = elixir.ManyToOne(EmailAndPasswordSystemAccount, required=True)
@classmethod
def by_id(cls, address_book_id, exception_to_raise):
address_books = AddressBook.query.filter_by(id=address_book_id)
if address_books.count() != 1:
raise exception_to_raise
return address_books.one()
@classmethod
def owned_by(cls, account):
return cls.query.filter_by(owner=account)
@classmethod
def address_books_visible_to(cls, account):
visible_books = cls.query.join(Collaborator).filter(Collaborator.account == account).all()
visible_books.extend(cls.owned_by(account))
return visible_books
@exposed
def fields(self, fields):
collaborators = [Choice(i.id, IntegerField(label=i.email)) for i in EmailAndPasswordSystemAccount.query.all()]
fields.chosen_collaborator = ChoiceField(collaborators, label='Choose collaborator')
fields.may_edit_address = BooleanField(label='May edit existing addresses')
fields.may_add_address = BooleanField(label='May add new addresses')
@exposed('add_collaborator')
def events(self, events):
events.add_collaborator = Event(label='Share', action=Action(self.add_collaborator))
def add_collaborator(self):
chosen_account = EmailAndPasswordSystemAccount.query.filter_by(id=self.chosen_collaborator).one()
self.allow(chosen_account, can_add_addresses=self.may_add_address, can_edit_addresses=self.may_edit_address)
# See https://groups.google.com/forum/?fromgroups=#!topic/sqlelixir/ZlR9Kvcor6Q
# addresses = elixir.OneToMany(Address)
@property
def addresses(self):
return Address.query.filter_by(address_book=self).all()
collaborators = elixir.OneToMany('reahl.doc.examples.tutorial.access2.access2.Collaborator', lazy='dynamic')
@property
def display_name(self):
return 'Address book of %s' % self.owner.email
def allow(self, account, can_add_addresses=False, can_edit_addresses=False):
Collaborator.query.filter_by(address_book=self, account=account).delete()
Collaborator(address_book=self, account=account,
can_add_addresses=can_add_addresses,
can_edit_addresses=can_edit_addresses)
def can_be_edited_by(self, account):
if account is self.owner:
return True
collaborator = self.get_collaborator(account)
return (collaborator and collaborator.can_edit_addresses) or self.can_be_added_to_by(account)
def can_be_added_to_by(self, account):
if account is self.owner:
return True
collaborator = self.get_collaborator(account)
return collaborator and collaborator.can_add_addresses
def collaborators_can_be_added_by(self, account):
return self.owner is account
def is_visible_to(self, account):
return self in self.address_books_visible_to(account)
def get_collaborator(self, account):
collaborators = self.collaborators.filter_by(account=account)
count = collaborators.count()
if count == 1:
return collaborators.one()
if count > 1:
raise ProgrammerError('There can be only one Collaborator per account. Here is more than one.')
return None
class Collaborator(elixir.Entity):
elixir.using_options(session=Session, metadata=metadata)
account = elixir.ManyToOne(EmailAndPasswordSystemAccount)
can_add_addresses = elixir.Field(elixir.Boolean, default=False)
can_edit_addresses = elixir.Field(elixir.Boolean, default=False)
address_book = elixir.ManyToOne(AddressBook)
class AddressAppPage(TwoColumnPage):
def __init__(self, view, home_bookmark):
super(AddressAppPage, self).__init__(view, style='basic')
user_session = UserSession.for_current_session()
if user_session.is_logged_in():
logged_in_as = user_session.account.email
else:
logged_in_as = 'Not logged in'
self.header.add_child(P(view, text=logged_in_as))
self.header.add_child(HMenu.from_bookmarks(view, [home_bookmark]))
class LoginForm(Form):
def __init__(self, view, accounts):
super(LoginForm, self).__init__(view, 'login')
if self.exception:
self.add_child(ErrorFeedbackMessage(view, self.exception.as_user_message()))
self.add_child(LabelledBlockInput(TextInput(self, accounts.fields.email)))
self.add_child(LabelledBlockInput(PasswordInput(self, accounts.fields.password)))
self.define_event_handler(accounts.events.login_event)
self.add_child(Button(self, accounts.events.login_event))
class LogoutForm(Form):
def __init__(self, view, accounts):
super(LogoutForm, self).__init__(view, 'logout')
self.define_event_handler(accounts.events.log_out_event)
self.add_child(Button(self, accounts.events.log_out_event))
class HomePageWidget(Widget):
def __init__(self, view, address_book_ui):
super(HomePageWidget, self).__init__(view)
accounts = AccountManagementInterface.for_current_session()
user_session = UserSession.for_current_session()
if user_session.is_logged_in():
self.add_child(AddressBookList(view, address_book_ui))
self.add_child(LogoutForm(view, accounts))
else:
self.add_child(LoginForm(view, accounts))
class AddressBookList(Panel):
def __init__(self, view, address_book_ui):
super(AddressBookList, self).__init__(view)
current_account = UserSession.for_current_session().account
address_books = [book for book in AddressBook.address_books_visible_to(current_account)]
bookmarks = [address_book_ui.get_address_book_bookmark(address_book, description=address_book.display_name)
for address_book in address_books]
for bookmark in bookmarks:
p = self.add_child(P(view))
p.add_child(A.from_bookmark(view, bookmark))
class AddressBookPanel(Panel):
def __init__(self, view, address_book, address_book_ui):
self.address_book = address_book
super(AddressBookPanel, self).__init__(view)
self.add_child(H(view, 1, text='Addresses in %s' % address_book.display_name))
self.add_child(HMenu.from_bookmarks(view, self.menu_bookmarks(address_book_ui)))
self.add_children([AddressBox(view, address) for address in address_book.addresses])
def menu_bookmarks(self, address_book_ui):
return [address_book_ui.get_add_address_bookmark(self.address_book),
address_book_ui.get_add_collaborator_bookmark(self.address_book)]
class EditAddressForm(Form):
def __init__(self, view, address):
super(EditAddressForm, self).__init__(view, 'edit_form')
grouped_inputs = self.add_child(InputGroup(view, label_text='Edit address'))
grouped_inputs.add_child(LabelledBlockInput(TextInput(self, address.fields.name)))
grouped_inputs.add_child(LabelledBlockInput(TextInput(self, address.fields.email_address)))
grouped_inputs.add_child(Button(self, address.events.update.with_arguments(address_book_id=address.address_book.id)))
class AddAddressForm(Form):
def __init__(self, view, address_book):
super(AddAddressForm, self).__init__(view, 'add_form')
new_address = Address(address_book=address_book)
grouped_inputs = self.add_child(InputGroup(view, label_text='Add an address'))
grouped_inputs.add_child(LabelledBlockInput(TextInput(self, new_address.fields.name)))
grouped_inputs.add_child(LabelledBlockInput(TextInput(self, new_address.fields.email_address)))
grouped_inputs.add_child(Button(self, new_address.events.save.with_arguments(address_book_id=address_book.id)))
class AddressBox(Form):
def __init__(self, view, address):
form_name = 'address_%s' % address.id
super(AddressBox, self).__init__(view, form_name)
par = self.add_child(P(view, text='%s: %s ' % (address.name, address.email_address)))
par.add_child(Button(self, address.events.edit.with_arguments(address_id=address.id)))
class AddressBookView(UrlBoundView):
def assemble(self, address_book_id=None, address_book_ui=None):
address_book = AddressBook.by_id(address_book_id, CannotCreate())
self.title = address_book.display_name
self.set_slot('main', AddressBookPanel.factory(address_book, address_book_ui))
class AddAddressView(UrlBoundView):
def assemble(self, address_book_id=None):
address_book = AddressBook.by_id(address_book_id, CannotCreate())
self.title = 'Add to %s' % address_book.display_name
self.set_slot('main', AddAddressForm.factory(address_book))
class AddCollaboratorForm(Form):
def __init__(self, view, address_book):
super(AddCollaboratorForm, self).__init__(view, 'add_collaborator_form')
grouped_inputs = self.add_child(InputGroup(view, label_text='Add a collaborator'))
grouped_inputs.add_child(LabelledBlockInput(SelectInput(self, address_book.fields.chosen_collaborator)))
rights_inputs = grouped_inputs.add_child(InputGroup(view, label_text='Rights'))
rights_inputs.add_child(LabelledBlockInput(CheckboxInput(self, address_book.fields.may_edit_address)))
rights_inputs.add_child(LabelledBlockInput(CheckboxInput(self, address_book.fields.may_add_address)))
grouped_inputs.add_child(Button(self, address_book.events.add_collaborator.with_arguments(address_book_id=address_book.id)))
class AddCollaboratorView(UrlBoundView):
def assemble(self, address_book_id=None):
address_book = AddressBook.by_id(address_book_id, CannotCreate())
self.title = 'Add collaborator to %s' % address_book.display_name
self.set_slot('main', AddCollaboratorForm.factory(address_book))
class EditAddressView(UrlBoundView):
def assemble(self, address_id=None):
address = Address.by_id(address_id, CannotCreate())
self.title = 'Edit Address for %s' % address.name
self.set_slot('main', EditAddressForm.factory(address))
class AddressBookUI(UserInterface):
def assemble(self):
home = self.define_view('/', title='Address books')
home.set_slot('main', HomePageWidget.factory(self))
self.address_book_page = self.define_view('/address_book', view_class=AddressBookView,
address_book_id=IntegerField(required=True),
address_book_ui=self)
self.add_address_page = self.define_view('/add_address', view_class=AddAddressView,
address_book_id=IntegerField(required=True))
edit_address_page = self.define_view('/edit_address', view_class=EditAddressView,
address_id=IntegerField(required=True))
self.add_collaborator_page = self.define_view('/add_collaborator', view_class=AddCollaboratorView,
address_book_id=IntegerField(required=True))
self.define_transition(Address.events.save, self.add_address_page, self.address_book_page)
self.define_transition(Address.events.edit, self.address_book_page, edit_address_page)
self.define_transition(Address.events.update, edit_address_page, self.address_book_page)
self.define_transition(AddressBook.events.add_collaborator, self.add_collaborator_page, self.address_book_page)
self.define_page(AddressAppPage, home.as_bookmark(self))
def get_address_book_bookmark(self, address_book, description=None):
return self.address_book_page.as_bookmark(self, description=description, address_book_id=address_book.id)
def get_add_address_bookmark(self, address_book, description='Add address'):
return self.add_address_page.as_bookmark(self, description=description, address_book_id=address_book.id)
def get_add_collaborator_bookmark(self, address_book, description='Add collaborator'):
return self.add_collaborator_page.as_bookmark(self, description=description, address_book_id=address_book.id)