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 exercise 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 reahl.doc.examples.tutorial.access2.access2_dev.access2tests
#
# To set up a demo database for playing with, do:
# nosetests -F reahl.webdev.fixtures:BrowserSetup --with-setup-fixture=reahl.doc.examples.tutorial.access2.access2_dev.access2tests:DemoFixture -s --nologcapture



from __future__ import print_function, unicode_literals, absolute_import, division
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.domain.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 = 'topsecret'

    def new_account(self, email='[email protected]'):
        account = EmailAndPasswordSystemAccount(email=email)
        Session.add(account)
        account.set_new_password(account.email, self.password)
        account.activate()
        return account

    def new_address_book(self, owner=None):
        owner = owner or self.account
        address_book = AddressBook(owner=owner)
        Session.add(address_book)
        return address_book

    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()
        Session.commit()




@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 print_function, unicode_literals, absolute_import, division

from sqlalchemy import Column, ForeignKey, Integer, UnicodeText, Boolean
from sqlalchemy.orm import relationship

from reahl.sqlalchemysupport import Session, Base
from reahl.domain.systemaccountmodel import AccountManagementInterface, EmailAndPasswordSystemAccount, LoginSession
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 HTML5Page, Form, TextInput, LabelledBlockInput, Button, Div, A, P, H, FieldSet, Menu, HorizontalLayout,\
                         PasswordInput, ErrorFeedbackMessage, Slot, Widget, SelectInput, CheckboxInput
from reahl.web.layout import PageLayout
from reahl.web.pure import ColumnLayout, UnitSize


class Address(Base):
    __tablename__ = 'access2_address'

    id              = Column(Integer, primary_key=True)    
    address_book_id = Column(Integer, ForeignKey('access2_address_book.id'))
    address_book    = relationship('reahl.doc.examples.tutorial.access2.access2.AddressBook')
    email_address   = Column(UnicodeText)
    name            = Column(UnicodeText)

    @classmethod
    def by_id(cls, address_id, exception_to_raise):
        addresses = Session.query(cls).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(Base):
    __tablename__ = 'access2_address_book'

    id              = Column(Integer, primary_key=True)

    owner_id   = Column(Integer, ForeignKey(EmailAndPasswordSystemAccount.id), nullable=False)
    owner      = relationship(EmailAndPasswordSystemAccount)
    collaborators = relationship('reahl.doc.examples.tutorial.access2.access2.Collaborator', lazy='dynamic',
                                 backref='address_book')

    @classmethod
    def by_id(cls, address_book_id, exception_to_raise):
        address_books = Session.query(cls).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 Session.query(cls).filter_by(owner=account)
        
    @classmethod
    def address_books_visible_to(cls, account):
        visible_books = Session.query(cls).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 Session.query(EmailAndPasswordSystemAccount).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 = Session.query(EmailAndPasswordSystemAccount).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)

    @property
    def addresses(self):
        return Session.query(Address).filter_by(address_book=self).all()

    @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):
        Session.query(Collaborator).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(Base):
    __tablename__ = 'access2_collaborator'
    id      = Column(Integer, primary_key=True)

    address_book_id = Column(Integer, ForeignKey(AddressBook.id))

    account_id = Column(Integer, ForeignKey(EmailAndPasswordSystemAccount.id), nullable=False)
    account = relationship(EmailAndPasswordSystemAccount)
    
    can_add_addresses = Column(Boolean, default=False)
    can_edit_addresses = Column(Boolean, default=False)


class AddressAppPage(HTML5Page):
    def __init__(self, view, home_bookmark):
        super(AddressAppPage, self).__init__(view, style='basic')

        self.use_layout(PageLayout())
        contents_layout = ColumnLayout(('main', UnitSize('1/2'))).with_slots()
        self.layout.contents.use_layout(contents_layout)

        login_session = LoginSession.for_current_session()
        if login_session.is_logged_in():
            logged_in_as = login_session.account.email
        else:
            logged_in_as = 'Not logged in'

        self.layout.header.add_child(P(view, text=logged_in_as))
        self.layout.header.add_child(Menu(view).use_layout(HorizontalLayout()).with_bookmarks([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()
        login_session = LoginSession.for_current_session()
        if login_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(Div):
    def __init__(self, view, address_book_ui):
        super(AddressBookList, self).__init__(view)

        current_account = LoginSession.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(Div):
    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(Menu(view).use_layout(HorizontalLayout()).with_bookmarks(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(FieldSet(view, legend_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(FieldSet(view, legend_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(FieldSet(view, legend_text='Add a collaborator'))
        grouped_inputs.add_child(LabelledBlockInput(SelectInput(self, address_book.fields.chosen_collaborator)))

        rights_inputs = grouped_inputs.add_child(FieldSet(view, legend_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)