Series: Building a Social Network with Flask & Stream – Part 4

Spencer P.
Spencer P.
Published February 1, 2020 Updated February 6, 2020

This is the fourth installment of a tutorial series focusing on how to create a full-stack application using Flask and Stream. This is the second part of the User and Permissions set up. This article is going to cover setting up the views and forms to allow users to register, login, and logout, as well as functions, to allow them to change their email or password. Check out the Github repo here to help you follow along!

Getting Started

Last week we got our database ready to go complete with user models as well as permissions and anonymous users. We also went over initializing Flask-Migrate to handle changes to the database.

Init to Win It

Now that we have our database set up and ready, it would be pretty nice to have a way for people to sign up and log in. This next section is going to deal specifically with that.

We are going to modularize the authentication portion of the site by creating a specific auth folder. After creating the auth directory, create an init.py file.

app/auth/init.py

from flask import Blueprint

auth = Blueprint(‘auth’, __name__)

from . import views

Before we move on to views and forms, let’s initialize the blueprint in our main init.py file:

app/init.py

def create_app(config_name):
	#...
	
	from .auth import auth as auth_blueprint
	app.register_blueprint(auth_blueprint, url_prefix=’/auth’)
	
	return app

Next Steps

Our Authentication folder is now integrated with the rest of our app, so we can start creating our endpoints.

Auth To The Races

Our first step is to allow users to register, so we will have to make a form and endpoint for it. First of which will be the form:

app/auth/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo
from wtforms import ValidationError
from ..models import User

class RegistrationForm(FlaskForm):
	  email =  StringField(‘Email’, validators=[DataRequired(), Length(1, 64), Email()])
    username = StringField('Username', validators=[DataRequired(), Length(1, 64),
   	Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0,
          		'Usernames must have only letters, numbers, dots or '
          		'underscores')])
    password = PasswordField(label='Password', 
                             validators=[DataRequired(), 
                                         EqualTo('password2', message='Passwords must match.')])
    password2 = PasswordField(label='Confirm password', validators=[DataRequired()])
    submit = SubmitField('Register')

def validate_email(self, field):
   	if User.query.filter_by(email=field.data.lower()).first():
      		 raise ValidationError('Email already registered.')

def validate_username(self, field):
  	 if User.query.filter_by(username=field.data).first():
       		raise ValidationError('Username already in use.')

This form has fields for username, email, and two for confirming a password. Additionally, there are custom validators to ensure that the email and username aren’t already in use when a user signs up.

After that, we can create the endpoint that will provide and return the information on the form in auth/views.py.

app/auth/views.py

From flask import render_template, url_for, redirect
from . import auth
from .. import db
from ..models import User
from .forms import RegistrationForm

@auth.route(‘/register’, methods=[‘GET’, ‘POST’])
def register():
	# The form we just created
	form = RegistrationForm()
	# Check and see if all the validation checks pass
	if form.validate_on_submit():
      # Create a new user
      User = User(email=form.email.data.lower(),
                username=form.username.data,
                password=form.password.data)
      db.session.add(user)
      db.session.commit()
      return redirect(url_for(‘main.index’)
	return render_template(‘auth/register.html’, form=form)

As you can no doubt tell, there are already quite a few issues here. First and foremost, we haven’t created the HTML document that we are going to return with this page. After that, how can we be sure that the email the user entered belongs to them. Finally, short of making a user physically type in the address for registration, there isn’t any practical way to navigate our site. We are going to tackle each of these problems next.

Early Registration

First, the HTML file – since we will have a few different pages for authentication (register, login, logout, etc.) we will create our folder for it in templates. Once that’s been created, we’ll create the register.html file.

app/templates/auth/register.html

{% import “bootstrap/wtf.html” as wtf %}

{% block title %}Offbrand - Register{% endblock %}

{% block page_content %}
<div class=”page-header”>
	<h1>Register</h1>
</div>
<div class=”col-md-4”>
	{{ wtf.quick_form(form) }}
</div>
{% endblock %}

In this file, we import a function (quick_form) from the bootstrap/wtf package. This handy bit of code allows you to quickly render all the fields of the form class that you passed through the render_template function.

Cannot Confirm or Deny

Although now we can return a web page for registration, we still can’t be sure that the email used for signup belongs to the user. While there are a few ways around this (like third party authentication, but that’s a story for another day), confirmation emails are the standard. Implementing an email system in Flask is pretty easy with Flask-Mail as well.

If you remember from the previous article, we have already installed, initialized, and configured the Flask-Mail package, the only thing left to do is set the email account that we will be sending from. If this is your first time using this kind of functionality, I’m going to point you again to Miguel Grinberg’s tutorial, as he explains the process of allowing a Gmail account to act as a proxy for your app. In the repo, you will see that I have set my Offbrand Mail Sender configuration as admin@offbrand.co; however, you should change to whichever email you are using. Additionally, you will need to set your environmental variables (using “set” in the command line) for your email username and password.

Once that is set up, we have to create an email.py folder to hold the email function. Our email function will operate asynchronously using the threading package, so sending the email doesn’t block the rest of the application.

app/email.py

from threading import Thread
from flask import current_app, render_template
from flask_mail import Message
from . import mail

def send_async_email(app, msg):
	  with app.app_context():
		    mail.send(msg)

def send_email(to, subject, template, **kwargs):
	  app = current_app._get_current_object()
    msg = Message(app.config['OFFBRAND_MAIL_SUBJECT_PREFIX'] + ' ' + subject,
             sender=app.config['OFFBRAND_MAIL_SENDER'], recipients=[to])
    msg.body = render_template(template + '.txt', **kwargs)
    msg.html = render_template(template + '.html', **kwargs)
    thr = Thread(target=send_async_email, args=[app, msg])
    thr.start()
    return thr

Next, we are going to create our new user email templates in our auth template folder to keep them neat.

app/templates/auth/mail/confirm_user.html

<p>Dear {{ user.username }},</p>
<p>Welcome to <b>Offbrand</b>!</p>
<p>To confirm your account please <a href="{{ url_for('auth.confirm', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.confirm', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Offbrand Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>

We will also create a text based version as well:

app/templates/auth/email/confirm_user.txt

Dear {{ user.username }},

Welcome to Offbrand!

To confirm your account please click on the following link:

{{ url_for('auth.confirm', token=token, _external=True) }}

Sincerely,

The Offbrand Team

Note: replies to this email address are not monitored.

After this, we will have to integrate this function when a user registers, as well as a confirmation endpoint that we included in the email (that fancy little url_for() function) to validate the confirmation token. To top it all off, we will add a resend confirmation route as well, just in case it gets lost in the mail. If you recall, we set up a confirmation token as part of the models in the previous article, as well as the function to validate them, so this should be a snap.

app/auth/views.py

from flask import render_template, redirect, url_for, flash
from flask_login import login_required, current_user
from ..email import send_email
#...

@auth.route(‘/register’, methods=[‘GET’, ‘POST’])
def register():
    # …
    db.session.commit()
    token = user.generate_confirmation_token()
    send_email(user.email, ‘Confirm Your Account’,
          ‘auth/email/confirm_user’, user=user, token=token)
    flash(‘A confirmation email has been sent to you by email.’)
    return redirect(url_for(‘main.index’))
            #...

@auth.route(‘/confirm/<token>’)
@login_required
def confirm(token):
    if current_user.confirmed:
       return redirect(url_for(‘main.index’))
    if current_user.confirm(token):
        db.session.commit()
        flash(‘You have confirmed your account. Thanks!’)
    else:
        flash(‘The confirmation link is invalid or has expired.’)
    return redirect(url_for(‘main.index’))

@auth.route(‘/confirm’)
@login_required
def resend_confirmation():
   	token = current_user.generate_confirmation_token()
   	send_email(current_user.email, 'Confirm Your Account',
              	'auth/email/confirm', user=current_user, token=token)
  	flash('A new confirmation email has been sent to you by email.')
   	  return redirect(url_for('main.index'))

Known Unknowns

Before we can get to our confirmations, though, we run into another issue: login_required. To validate the user, the user has to be logged in, but we don’t have a login screen yet. This next step is going to go over a lot of the same material we just covered in setting up the registration page.

First, let’s create our login form.

app/auth/forms.py

#...
from wtforms import StringField, PasswordField, BooleanField, SubmitField

#...
class LoginForm(FlaskForm):
	email = StringField(‘email’, validators=[DataRequired(), Length(1, 64),
						Email()])
	password = PasswordField(‘Password’, validators=[DataRequired()])
	remember_me = BooleanField(‘Keep me logged in’, id=”checkbox”)
	submit = SubmitField(‘Login’)

Next we’ll construct the endpoint and pass the form to it.

app/auth/views.py

#...
from flask import request, redirect, render_template, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from .forms import LoginForm, RegistrationForm

@auth.before_app_request
def before_request():
    if current_user.is_authenticated:
        current_user.ping()
        if not current_user.confirmed \
                and request.endpoint \
                and request.blueprint != 'auth' \
                and request.endpoint != 'static':
            return redirect(url_for('auth.unconfirmed'))

@auth.route('/unconfirmed')
def unconfirmed():
    if current_user.is_anonymous or current_user.confirmed:
        return redirect(url_for('main.index'))
    return render_template('auth/unconfirmed.html')

@auth.route(‘/login’, methods=[‘GET’, ‘POST’])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data.lower())
    if user is not None and user.verify_password(form.password.data):
         login_user(user, form.remember_me.data)
         next = request.args.get('next')
         if next is None or not next.startswith('/'):
             next = url_for('main.index')
         return redirect(next)
    flash('Invalid email or password.')
    return render_template('auth/login.html', form=form)

@auth.route(‘/logout’)
@login_required
def logout():
	logout_user()
	flash(‘You have been logged out.’)
	return redirect(url_for(‘main.index’)
#...

The next argument in this route will come in handy later as we start to build more pages for the site, allowing users who had previously not authenticated and had detoured to do so the ability to continue after authentication instead of being forced back to the index page.

I also took the opportunity here to put together a logout route, an unconfirmed route quickly, and a before_request check to see if the user is unconfirmed to direct them there.

Last but not least, we need the pages to return. As you can see from render_template(), we are going to name this file login.html.

app/templates/auth/login.html

{% import “bootstrap/wtf.html” as wtf %}

{% block title %}Offbrand - Login {% endblock %}

{% block page_content %}
<div class="page-header">
  	<h1>Login</h1>
</div>
<div class="col-md-4">
   	{{ wtf.quick_form(form) }}
   <br>
   <p>New user? <a href="{{ url_for('auth.register') }}">Click here to register</a>.</p>
</div>
{% endblock %}
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

Just one more page for those who have yet to confirm their account, and we should be just about finished.

app/templates/auth/unconfirmed.html

{% block title %}Offbrand - Confirm your account{% endblock %}

{% block page_content %}
<div class="page-header">
   <h1>
       Hello, {{ current_user.username }}!
   </h1>
   <h3>You have not confirmed your account yet.</h3>
   <p>
       Before you can access this site you need to confirm your account.
       Check your inbox, you should have received an email with a confirmation link.
   </p>
   <p>
       Need another confirmation email?
       <a href="{{ url_for('auth.resend_confirmation') }}">Click here</a>
   </p>
</div>
{% endblock %}

Last Hurdles

Now that we have wrapped up registration, confirmation, login, and logout, there are a few more things to get into to make sure that we have all of the functionality you would expect – such as the ability to reset a forgotten password, change your password, or change your email. Each of these will require some functions to be added to the User class in models.py.

app/models.py

#...
class User(db.Model, UserMixin):
    #...
    
    def verify_password(self, password):
      #...
      
    def generate_reset_token(self, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps({'reset': self.id}).decode('utf-8')

    @staticmethod
    def reset_password(token, new_password):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
               data = s.loads(token.encode('utf-8'))
         except:
              return False
        user = User.query.get(data.get('reset'))
        if user is None:
              return False
         user.password = new_password
         db.session.add(user)
        return True

    def generate_email_change_token(self, new_email, expiration=3600):
        s = Serializer(current_app.config['SECRET_KEY'], expiration)
        return s.dumps(
               {'change_email': self.id, 'new_email': new_email}).decode('utf-8')

    def change_email(self, token):
        s = Serializer(current_app.config['SECRET_KEY'])
        try:
               data = s.loads(token.encode('utf-8'))
        except:
              return False
        if data.get('change_email') != self.id:
              return False
         new_email = data.get('new_email')
         if new_email is None:
              return False
         if self.query.filter_by(email=new_email).first() is not None:
               return False
        self.email = new_email
        self.avatar_hash = self.gravatar_hash()
        db.session.add(self)
        return True
     
    def can(self, perm):
       #...

We will also need the forms for those requests, as well as the routes for them:

app/auth/forms.py

#...

class ChangePasswordForm(FlaskForm):
    old_password = PasswordField('Old password', validators=[DataRequired()])
    password = PasswordField('New password', validators=[
        DataRequired(), EqualTo('password2', message='Passwords must match.')])
    password2 = PasswordField('Confirm new password',
                              validators=[DataRequired()])
    submit = SubmitField('Update Password')


class PasswordResetRequestForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Length(1, 64),
                                             Email()])
    submit = SubmitField('Reset Password')


class PasswordResetForm(FlaskForm):
    password = PasswordField('New Password', validators=[
        DataRequired(), EqualTo('password2', message='Passwords must match')])
    password2 = PasswordField('Confirm password', validators=[DataRequired()])
    submit = SubmitField('Reset Password')


class ChangeEmailForm(FlaskForm):
    email = StringField('New Email', validators=[DataRequired(), Length(1, 64),
                                                 Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    submit = SubmitField('Update Email Address')

    def validate_email(self, field):
        if User.query.filter_by(email=field.data.lower()).first():
            raise ValidationError('Email already registered.')

app/auth/views.py

#...
from .forms import LoginForm, RegistrationForm, ChangePasswordForm,\
    PasswordResetRequestForm, PasswordResetForm, ChangeEmailForm
#...

@auth.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
    form = ChangePasswordForm()
    if form.validate_on_submit():
        if current_user.verify_password(form.old_password.data):
            current_user.password = form.password.data
            db.session.add(current_user)
            db.session.commit()
            flash('Your password has been updated.')
            return redirect(url_for('main.index'))
        else:
            flash('Invalid password.')
    return render_template("auth/change_password.html", form=form)


@auth.route('/reset', methods=['GET', 'POST'])
def password_reset_request():
    if not current_user.is_anonymous:
        return redirect(url_for('main.index'))
    form = PasswordResetRequestForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data.lower()).first()
        if user:
            token = user.generate_reset_token()
            send_email(user.email, 'Reset Your Password',
                       'auth/email/reset_password',
                       user=user, token=token)
        flash('An email with instructions to reset your password has been '
              'sent to you.')
        return redirect(url_for('auth.login'))
    return render_template('auth/reset_password.html', form=form)


@auth.route('/reset/<token>', methods=['GET', 'POST'])
def password_reset(token):
    if not current_user.is_anonymous:
        return redirect(url_for('main.index'))
    form = PasswordResetForm()
    if form.validate_on_submit():
        if User.reset_password(token, form.password.data):
            db.session.commit()
            flash('Your password has been updated.')
            return redirect(url_for('auth.login'))
        else:
            return redirect(url_for('main.index'))
    return render_template('auth/reset_password.html', form=form)


@auth.route('/change_email', methods=['GET', 'POST'])
@login_required
def change_email_request():
    form = ChangeEmailForm()
    if form.validate_on_submit():
        if current_user.verify_password(form.password.data):
            new_email = form.email.data.lower()
            token = current_user.generate_email_change_token(new_email)
            send_email(new_email, 'Confirm your email address',
                       'auth/email/change_email',
                       user=current_user, token=token)
            flash('An email with instructions to confirm your new email '
                  'address has been sent to you.')
            return redirect(url_for('main.index'))
        else:
            flash('Invalid email or password.')
    return render_template("auth/change_email.html", form=form)


@auth.route('/change_email/<token>')
@login_required
def change_email(token):
    if current_user.change_email(token):
        db.session.commit()
        flash('Your email address has been updated.')
    else:
        flash('Invalid request.')
    return redirect(url_for('main.index'))

The changing email and password functions will stay buried for now, but the forgotten password can easily be put into our login form, along with a quick template for the reset password page and email.

app/template/auth/login.html

#...
	<br>
	<p>Forgot your password? <a href="{{ url_for('auth.password_reset_request') }}">Click here to reset it</a>.</p>
  <p>New user? <a href="{{ url_for('auth.register') }}">Click here to register</a>.</p>
  #...

app/template/auth/reset_password.html

{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Offbrand - Password Reset{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Reset Your Password</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

app/template/auth/email/reset_password.html

<p>Dear {{ user.username }},</p>
<p>To reset your password <a href="{{ url_for('auth.password_reset', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.password_reset', token=token, _external=True) }}</p>
<p>If you have not requested a password reset simply ignore this message.</p>
<p>Sincerely,</p>
<p>The Offbrand Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>

app/template/auth/email/reset_password.txt

Dear {{ user.username }},

To reset your password click on the following link:

{{ url_for('auth.password_reset', token=token, _external=True) }}

If you have not requested a password reset simply ignore this message.

Sincerely,

The Offbrand Team

Note: replies to this email address are not monitored.

Base Jumping

Now that we have all of these pages, we need a way to navigate between them. As we will need this navigation on every page, and I don’t particularly like writing navigation bars on every page, we will once again modularize it and import it using Jinja. We can also use it as a way to centralize any of our other components that will be used on every page, such as flashed messages and Moment.js

app/template/base.html

{% extends "bootstrap/base.html" %}

{% block title %}Offbrand{% endblock %}

{% block head %}
{{ super() }}
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
{% endblock %}

{% block navbar %}
<div class="navbar navbar-inverse" role="navigation">
    <div class="container">
        <div class="navbar-header">
            <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="{{ url_for('main.index') }}">Offbrand</a>
        </div>
        <div class="navbar-collapse collapse">
            <ul class="nav navbar-nav">
                <li><a href="{{ url_for('main.index') }}">Home</a></li>
            </ul>
            <ul class="nav navbar-nav navbar-right">
                {% if current_user.is_authenticated %}
                <li class="dropdown">
                    <a href="#" class="dropdown-toggle" data-toggle="dropdown">
                        <img src="{{ current_user.gravatar(size=18) }}">
                        Account <b class="caret"></b>
                    </a>
                    <ul class="dropdown-menu">
                        <li><a href="{{ url_for('auth.change_password') }}">Change Password</a></li>
                        <li><a href="{{ url_for('auth.change_email_request') }}">Change Email</a></li>
                        <li><a href="{{ url_for('auth.logout') }}">Log Out</a></li>
                    </ul>
                </li>
                {% else %}
                <li><a href="{{ url_for('auth.login') }}">Log In</a></li>
                {% endif %}
            </ul>
        </div>
    </div>
</div>
{% endblock %}

{% block content %}
<div class="container">
    {% for message in get_flashed_messages() %}
    <div class="alert alert-warning">
        <button type="button" class="close" data-dismiss="alert">&times;</button>
        {{ message }}
    </div>
    {% endfor %}

    {% block page_content %}{% endblock %}
</div>
{% endblock %}

{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{% endblock %}

Going back to the HTML documents that we’ve made before (login, register, reset_password, unconfirmed, index, etc.), we can simply import it at the top like so:

app/template/login.html

{% extends "base.html" %}
#...

Loose Change

Since we now have a way to access our change email and change password functions, we can build those now too!

The only thing left is to build the HTML templates to return, as well as the emails.

app/templates/auth/change_email.html

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Offbrand - Change Email Address{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Change Your Email Address</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

app/templates/auth/email/change_email.html

<p>Dear {{ user.username }},</p>
<p>To confirm your new email address <a href="{{ url_for('auth.change_email', token=token, _external=True) }}">click here</a>.</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{ url_for('auth.change_email', token=token, _external=True) }}</p>
<p>Sincerely,</p>
<p>The Offbrand Team</p>
<p><small>Note: replies to this email address are not monitored.</small></p>

app/templates/auth/email/change_email.txt

Dear {{ user.username }},

To confirm your new email address click on the following link:

{{ url_for('auth.change_email', token=token, _external=True) }}

Sincerely,

The Offbrand Team

Note: replies to this email address are not monitored.

app/templates/auth/change_password.html

{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}

{% block title %}Offbrand - Change Password{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Change Your Password</h1>
</div>
<div class="col-md-4">
    {{ wtf.quick_form(form) }}
</div>
{% endblock %}

Erring On The Side Of Caution

The last thing we will have to deal with for now is errors. Since we’ve constructed the capability for permission routes, we will also have to gracefully deal with users trying to access things that they aren’t authorized for. Your most common errors are going to be 403’s (Forbidden), 404’s (Page Not Found), and 500’s (Internal Server Errors), so we will make those now:

app/templates/403.html

{% extends "base.html" %}

{% block title %}Offbrand - Forbidden{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Forbidden</h1>
</div>
{% endblock %}

app/templates/404.html

{% extends "base.html" %}

{% block title %}Offbrand - Page Not Found{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Not Found</h1>
</div>
{% endblock %}

app/templates/500.html

{% extends "base.html" %}

{% block title %}Offbrand - Internal Server Error{% endblock %}

{% block page_content %}
<div class="page-header">
    <h1>Internal Server Error</h1>
</div>
{% endblock %}

Sanity Check

Booting up our Flask server again and navigating to localhost, we can see our page is starting to come together.

Final Thoughts

Phew! That was a lot.

Now though, we have all of the pieces in place to start creating our app. Users have a landing page, can register, sign-in and sign-out, as well as change their password or email. You also now can use permission routes, return custom error pages, and have a nice little navbar for your users to navigate the site.

Next week we are going to start getting into the meat of the project, creating user profile pages with their information and allowing them to edit their profiles! We will also start diving into developing testing strategies to ensure that everything is working correctly.

Thank you for following along and happy coding!

Note: The next post in this series can be found here.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->