The web session problem

User sessions

Objects created during a request to the web browser only exist for the duration of that request. If you want them to live longer, they need to be persisted in a database.

Sometimes persisted objects are only needed for use in the current sequence of interactions between a browser and server, after which they can be safely deleted.

You can persist objects on the server for the duration of a UserSession by making them “session scoped”. Session scoped persisted objects are retained for as long as the UserSession exists.

In the example here we build a simplified version of the login functionality already provided by Reahl to show how session scoped objects can be used.

The example consists of User object and a LoginSession object:

../_images/sessions.png

A model for letting Users log in.

The application itself has two pages—a home page on which the name of the User who is currently logged in is displayed, and a page on which you can log in using your email address and a password.

Declaring the LoginSession

Create a LoginSession persisted class and decorate it using the session_scoped() decorator.

@session_scoped
class LoginSession(Base):
    __tablename__ = 'sessscope_loginsession'

    id              = Column(Integer, primary_key=True)
    current_user_id = Column(Integer, ForeignKey(User.id))
    current_user    = relationship(User)

    fields = ExposedNames()
    fields.email_address = lambda i: EmailField(label='Email', required=True)
    fields.password = lambda i: PasswordField(label='Password', required=True)

    events = ExposedNames()
    events.log_in = lambda i: Event(label='Log in', action=Action(i.log_in))

    def log_in(self):
        matching_users = Session.query(User).filter_by(email_address=self.email_address)
        if matching_users.count() != 1:
            raise InvalidPassword()

        user = matching_users.one()
        if user.matches_password(self.password):
            self.current_user = user
#            print('Successfully logged in as %s ' % self.current_user.name)
        else:
            raise InvalidPassword()

The LoginSession keeps track of who is currently logged in through its .current_user relationship to a User.

The email_address and password are Fields on the LoginSession – they are to be linked to Inputs on the user interface. (The same goes for the log_in Event.)

To complete the model itself, here is the implementation of User:

class User(Base):
    __tablename__ = 'sesscope_user'
    
    id            = Column(Integer, primary_key=True)
    email_address = Column(UnicodeText, nullable=False) 
    name          = Column(UnicodeText, nullable=False)
    password_hash = Column(Unicode(1024), nullable=False)

    def set_password(self, password):
        self.password_hash = pbkdf2_sha256.hash(password)
        
    def matches_password(self, password):
        return pbkdf2_sha256.verify(password, self.password_hash)

Using a session scoped object

In this example, we obtain the current LoginSession at the beginning of .assemble() of the UserInterface, and send it around as argument to the login page.

LoginSession.for_current_session() will check whether there is a LoginSession for the current UserSession and create one if necessary:

class SessionScopeUI(UserInterface):
    def assemble(self):
        login_session = LoginSession.for_current_session()
        if login_session.current_user:
            user_name = login_session.current_user.name
        else:
            user_name = 'Guest'

        home = self.define_view('/', title='Home')
        home.set_slot('main', P.factory(text='Welcome %s' % user_name))

       
        login_page = self.define_view('/login', title='Log in')
        login_page.set_slot('main', LoginForm.factory(login_session))
        
        bookmarks = [i.as_bookmark(self) for i in [home, login_page]]
        self.define_page(MenuPage, bookmarks)

Dealing with DomainExceptions

A DomainException is an exception that can be raised to communicate a domain-specific error condition to the user.

In this example it may happen that a user supplies an incorrect password. That’s why an InvalidPassword exception is raised inside the .log_in() method of LoginSession.

When the InvalidPassword (a DomainException) is raised, the Reahl framework ignores all Transitions defined, and re-displays the current UrlBoundView. The exception will then contain the DomainException that is keeping the user from progressing. Display this exception using an Alert to inform the user of the issue:

class LoginForm(Form):
    def __init__(self, view, login_session):
        super().__init__(view, 'login')
        self.use_layout(FormLayout())
        if self.exception:
            self.layout.add_alert_for_domain_exception(self.exception)

        self.layout.add_input(TextInput(self, login_session.fields.email_address))
        self.layout.add_input(PasswordInput(self, login_session.fields.password))

        self.define_event_handler(login_session.events.log_in)
        self.add_child(Button(self, login_session.events.log_in, style='primary'))

The complete example




from passlib.hash import pbkdf2_sha256

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

from reahl.sqlalchemysupport import Session, Base, session_scoped

from reahl.component.exceptions import DomainException
from reahl.web.fw import UserInterface
from reahl.web.layout import PageLayout
from reahl.web.bootstrap.page import HTML5Page
from reahl.web.bootstrap.ui import P
from reahl.web.bootstrap.forms import Form, TextInput, Button, PasswordInput, FormLayout
from reahl.web.bootstrap.navs import Nav, TabLayout
from reahl.web.bootstrap.grid import ColumnLayout, ColumnOptions, ResponsiveSize, Container
from reahl.component.modelinterface import Action, EmailField, Event, PasswordField, ExposedNames


class User(Base):
    __tablename__ = 'sesscope_user'
    
    id            = Column(Integer, primary_key=True)
    email_address = Column(UnicodeText, nullable=False) 
    name          = Column(UnicodeText, nullable=False)
    password_hash = Column(Unicode(1024), nullable=False)

    def set_password(self, password):
        self.password_hash = pbkdf2_sha256.hash(password)
        
    def matches_password(self, password):
        return pbkdf2_sha256.verify(password, self.password_hash)


@session_scoped
class LoginSession(Base):
    __tablename__ = 'sessscope_loginsession'

    id              = Column(Integer, primary_key=True)
    current_user_id = Column(Integer, ForeignKey(User.id))
    current_user    = relationship(User)

    fields = ExposedNames()
    fields.email_address = lambda i: EmailField(label='Email', required=True)
    fields.password = lambda i: PasswordField(label='Password', required=True)

    events = ExposedNames()
    events.log_in = lambda i: Event(label='Log in', action=Action(i.log_in))

    def log_in(self):
        matching_users = Session.query(User).filter_by(email_address=self.email_address)
        if matching_users.count() != 1:
            raise InvalidPassword()

        user = matching_users.one()
        if user.matches_password(self.password):
            self.current_user = user
#            print('Successfully logged in as %s ' % self.current_user.name)
        else:
            raise InvalidPassword()


class MenuPage(HTML5Page):
    def __init__(self, view, main_bookmarks):
        super().__init__(view)
        self.use_layout(PageLayout(document_layout=Container()))
        contents_layout = ColumnLayout(ColumnOptions('main', size=ResponsiveSize())).with_slots()
        self.layout.contents.use_layout(contents_layout)
        self.layout.header.add_child(Nav(view).use_layout(TabLayout()).with_bookmarks(main_bookmarks))


class InvalidPassword(DomainException):
    def as_user_message(self):
        return 'The email/password given do not match.'
    


class LoginForm(Form):
    def __init__(self, view, login_session):
        super().__init__(view, 'login')
        self.use_layout(FormLayout())
        if self.exception:
            self.layout.add_alert_for_domain_exception(self.exception)

        self.layout.add_input(TextInput(self, login_session.fields.email_address))
        self.layout.add_input(PasswordInput(self, login_session.fields.password))

        self.define_event_handler(login_session.events.log_in)
        self.add_child(Button(self, login_session.events.log_in, style='primary'))

        # login_session.current_user and Session.refresh(login_session.current_user)


class SessionScopeUI(UserInterface):
    def assemble(self):
        login_session = LoginSession.for_current_session()
        if login_session.current_user:
            user_name = login_session.current_user.name
        else:
            user_name = 'Guest'

        home = self.define_view('/', title='Home')
        home.set_slot('main', P.factory(text='Welcome %s' % user_name))

       
        login_page = self.define_view('/login', title='Log in')
        login_page.set_slot('main', LoginForm.factory(login_session))
        
        bookmarks = [i.as_bookmark(self) for i in [home, login_page]]
        self.define_page(MenuPage, bookmarks)