Build a team technical challenge website with replit.web

Build a team technical challenge website with replit.web

·

22 min read

Code competitions and hackathons are a fun way to expand your programming skills, get exposed to new ideas, and work together to solve difficult problems. The time-limited, competitive nature of these competitions provides an additional challenge.

In this tutorial, we'll use the replit.web framework to build a leaderboard website for an online technical challenge in the vein of Advent of Code or Hackasat. We'll focus on the generic aspects of the site, such as teams, challenges and scores, so once we're done, you can use the site for your own competition.

Challenge site functionality

By the end of this tutorial, you'll be able to:

  • Use Replit's Flask-based web framework to rapidly develop authenticated web applications with persistent storage.
  • Use WTForms to create sophisticated web forms.
  • Use custom function decorators to handle multiple user roles.

Getting started

To get started, sign into Replit or create an account if you haven't already. Once logged in, create a Python repl.

Creating a new repl

Our competition website will have the following functionality:

  • Users can sign in with their Replit accounts and either create a team or join an existing team. To join an existing team, a team password will be required.
  • Once they're in a team, users will be able to view challenges and submit challenge solutions. To keep things simple, we will validate challenge solutions by requiring users to submit a unique code per challenge.
  • A designated group of admin users will have the ability to add and remove challenges, start and end the competition, and clear the database for a new competition.

Let's start off our competition application with the following module imports in main.py:

from flask import Flask, render_template, flash, redirect, url_for, request
from replit import db, web

Here we're importing a number of Flask features we'll need. We could just use import flask to import everything, but we'll be using most of these functions often enough that having to prepend them with flask. would quickly become tiresome. We're also importing Replit's db and web modules, which will give us data persistence and user authentication.

Now let's create our app and initialize its database. Add the following code just below the import statements in main.py:

app = Flask(__name__)

# Secret key
app.config['SECRET_KEY'] = "YOUR-SECRET-KEY-HERE"

# Database setup
db_init()
users = web.UserStore()

ADMINS = ["YOUR-REPLIT-USERNAME-HERE"]

Here we initialize our application, our general and user databases, and our list of admins. Make sure to replace the string in ADMINS with your Replit username before proceeding. Also replace the secret key with a long, random string. You can generate one in your repl's Python console with the following two lines of code:

import random, string
''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(20))

Generating a random string

You'll notice that db_init() is undefined. As this is going to be a fairly large codebase, we're going to put it in a separate file. Create the file db_init.py in your repl's files tab:

Database init file

Add the following code to this file:

from replit import db

def db_init():
    if "teams" not in db.keys():
        db["teams"] = {}

    if "challenges" not in db.keys():
        db["challenges"] = {}

    if "competition_started" not in db.keys():
        db["competition_started"] = False

Replit's Database can be thought of and used as one big Python dictionary that we can access with db. Any values we store in db will persist between repl restarts.

To import this file in main.py, we can use an import statement in much the same way as we would for a module. Add this line in main.py, below your other imports:

from db_init import db_init

We've also defined a secondary database users in main.py. While db only contains what we put into it, users is a UserStore that will automatically have the names of users who sign into our application added as keys, so we can easily store and retrieve information about them.

Now let's create some test content and run our app. Add the following code, and then run your repl.

# Routes
@app.route("/")
@web.authenticated
def index():
    return f"Hello {web.auth.name}"

web.run(app)

Because we've added the @web.authenticated function decorator to our index page, it will only be available to logged in users. You should see this now, as your app will show a login button. Click on that button, and authorize your application to use Replit authentication in the window that pops up.

Login Button

Having done that, you should now see the greeting we implemented above. If you send your repl to a friend, they will also be able to log in, and see their own Replit username on the greeting message.

Creating user roles

Function decorators like @web.authenticated, which prevent a function from executing unless certain conditions are met, are very useful for web applications like this one, in which we want to restrict certain pages based on who's attempting to view them. @web.authenticated restricts users based on authentication -- who a user is. We can now create our own decorators to restrict users based on authorization -- what a user is allowed to do.

For this site, we're concerned about three things:

  • Is the user in a team? Users who aren't need to be able to create or join a team, and users who are need to be able to submit challenge solutions.
  • Is the user an admin? Users who are need to be able to create challenges, and perform other administrative tasks. For the sake of fairness, they should not be allowed to join teams themselves.
  • Is the competition running? If not, we don't want non-admin users to be able to view challenge pages or attempt to submit solutions.

First, we'll create two helper functions to answer these questions. Add the following code to main.py, just below your ADMINS list:

# Helper functions
def is_admin(username):
    return username in ADMINS

def in_team(username):
    if "team" in users[username].keys():
        return users[username]["team"]

The is_admin() function will return True if the provided user is an admin, or False otherwise. The function in_team() will return the name of the team the user is in, or None if they aren't in a team.

Now we can create our authorization function decorators. Add the following import function to the top of main.py:

from functools import wraps

Then add this code below our helper functions:

# Authorization decorators
def admin_only(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):

        if not is_admin(web.auth.name):
            flash("Permission denied.", "warning")
            return redirect(url_for("index"))

        return f(*args, **kwargs)

    return decorated_function

This code may look a bit strange if you haven't written your own decorators before. Here's how it works: admin_only is the name of our decorator. You can think of decorators as functions which take other functions as arguments. (The code coming up is example code for the purpose of illustration, and not part of our program.) Therefore, if we write the following:

@admin_only
def admin_function():
    return f"Hello admin"

admin_function()

it will be roughly equivalent to:

def admin_function():
    return f"Hello admin"

admin_only(admin_function)

So whenever admin_function gets called, the code we've defined in decorated_function will execute before anything we define in admin_function. This means we don't have to include an if not is_admin check in every piece of admin functionality. As per the code, if a non-admin attempts to access restricted functionality, our app will flash a warning message and redirect them to the home page.

We also need to define a decorator for the opposite case, where we need to ensure that the current user is not an admin. Add the following code just below the # Authorization decorators code you added above:

def not_admin_only(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):

        if is_admin(web.auth.name):
            flash("Admins can't do that.", "warning")
            return redirect(url_for("index"))

        return f(*args, **kwargs)

    return decorated_function

We will do much the same thing for team_only and not_team_only:

def team_only(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):

        if not in_team(web.auth.name):
            flash("Join a team first!", "warning")
            return redirect(url_for("index"))

        return f(*args, **kwargs)

    return decorated_function

def not_team_only(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):

        if in_team(web.auth.name):
            flash("You've already joined a team!", "warning")
            return redirect(url_for("index"))

        return f(*args, **kwargs)

    return decorated_function

Finally, we need to add a decorator to check whether our competition is running. This is mainly for challenge description pages, so we'll add an exception for non-admin users:

def competition_running(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):

        if not (is_admin(web.auth.name) or db["competition_started"]):
            flash("The competition has not started yet.")
            return redirect(url_for("index"))

        return f(*args, **kwargs)

    return decorated_function

Now that we've added our authorization controls, it's time to give them something to authorize. In the next sections, we'll define all of our app's functionality and build its front-end.

Building forms

The bulk of interactivity in our application will be enabled through forms. Users will be able to create and join teams, as well as submit challenge solutions. When we work with web forms, there's a lot to consider, including:

  • Which users should be able to submit which forms (authorization)?
  • What validation do we want on different fields? For example, length requirements, or ensuring a given value is an integer rather than a string.
  • How do we give feedback on data that doesn't pass our validations?
  • Security concerns around user input, such as SQL injection, cross-site scripting and cross-site request forgery. While the first one won't be relevant to our app, the second two are.

We could build all of this ourselves using Flask's request.form as a basis, but fortunately someone else has already done the hard work and built the WTForms library, as well as Flask WTF, which integrates WTForms with Flask. We'll be using both of these to construct our application's various forms.

To keep our codebase navigable, we'll put all our form code in a separate file, like we did with our database initialization code. Create forms.py in your repl's files tab now:

Building forms

We'll start this file off with some imports:

from replit import db
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField, PasswordField, SelectField, IntegerField, ValidationError
from wtforms.validators import InputRequired, NumberRange, Length

Here we import our Replit database, which we'll need for uniqueness validations, as well as everything we'll be using from WTForms and Flask WTF.

Before we get started with our forms, it's worth thinking about how we're going to lay out the data structures they'll be used to create and modify. In db_init.py, we've defined two dictionaries -- "challenges" and "teams". Each of these will contain a dictionary for each challenge or team, keyed by an ID. Our data structure will look something like this:

{
    "challenges": {
        "ID": {
            "name": "NAME",
            "description": "DESCRIPTION",
            "points": 10,
            "code": "CHALLENGE SOLUTION CODE"
        }
    },
    "teams": {
        "ID": {
            "name": "NAME",
            "team_leader": "LEADER NAME",
            "team_members": ["LEADER NAME", "ADDITIONAL MEMBER"],
            "score": 0,
            "password": "TEAM PASSWORD",
            "challenges_solved": ["CHALLENGE ID", "ANOTHER CHALLENGE ID"]
        }
    }
}

The ID value for both our challenges and teams will be the challenge or team name, all-lowercase, with spaces replaced by hyphens, so we can use it in our app's URLs. Let's create a function that turns names into IDs, in forms.py, just below our imports:

def name_to_id(name):
    return name.lower().replace(" ", "-")

Now we can start creating our forms. With Flask WTF, we model each form as a class inheriting from FlaskForm. These classes take in the value of Flask's request.form and apply validations to the fields therein. We'll create our TeamCreateField first, with the following code:

class TeamCreateForm(FlaskForm):
    name = StringField(
        "Team name",
        validators=[
            InputRequired(),
            Length(3)
            ]
    )

    password = PasswordField(
        "Team password",
        validators=[
            InputRequired(),
            Length(8)
        ]
    )

    submit = SubmitField("Create team")

    def validate_name(form, field):
        if name_to_id(field.data) in db["teams"].keys():
            raise ValidationError("Team name already taken.")

When users create teams, they'll specify a team name and team password. In our WTForms field specifications above, we've defined minimum lengths for both of these fields, ensured that the team password is entered in a password field, and written a custom validator to reject new teams with IDs that match existing teams. Because we're validating on ID rather than name, users won't be able to create teams with the same name but different capitalization (e.g. "Codeslingers" and "codeslingers").

Every field in all of our forms includes the InputRequired validator, which will ensure that users do not submit blank values. This validator can be left out for optional fields.

Our ChallengeCreateForm is similar to TeamCreateForm, and can be added below it:

class ChallengeCreateForm(FlaskForm):
    name = StringField(
        "Challenge name",
        validators=[
            InputRequired(),
            Length(3)
        ]
    )

    description = TextAreaField(
        "Challenge description",
        validators=[InputRequired()]
    )

    points = IntegerField(
        "Challenge points",
        validators=[
            InputRequired(),
            NumberRange(1)
        ]
    )

    code = StringField("Challenge code",
        validators=[
            InputRequired(),
            Length(8)
        ]
    )

    submit = SubmitField("Create challenge")

    def validate_name(form, field):
        if name_to_id(field.data) in db["challenges"].keys():
            raise ValidationError("Challenge name already used.")

Here we've used the TextAreaField to give a bit more space for our users to write challenge descriptions, and IntegerField to specify the number of points a challenge is worth. We're also requiring that challenges be worth at least 1 point, using the NumberRange validator.

Next up is our TeamJoinForm:

class TeamJoinForm(FlaskForm):
    name = SelectField(
        "Team to join",
        choices= [
            (team_id, team["name"]) for team_id, team in db["teams"].items()
        ],
        validators=[InputRequired()]
    )

    password = PasswordField(
        "Team password",
        validators=[InputRequired()]
    )

    submit = SubmitField("Join team")

In this form, we're creating a drop-down box with the names of existing teams. The list comprehension in choices constructs a tuple for each team, consisting of the team's ID and name. This way, we can use the ID to identify teams on the backend while displaying the name to the user.

Our last form is ChallengeSolveForm, which users will use to submit challenge solutions. Add it to the bottom of forms.py:

class ChallengeSolveForm(FlaskForm):
    code = StringField("Challenge code",
        validators=[
            InputRequired(),
        ]
    )

    submit = SubmitField("Submit solution code")

As we'll be including this form on the individual challenge pages, we don't need to ask the user to specify which challenge they're solving.

Finally, we'll need to import our forms and helper function into main.py so we can use them in the rest of our app. Add the following line to the import statements in main.py:

from forms import TeamCreateForm, TeamJoinForm, ChallengeCreateForm, ChallengeSolveForm, name_to_id

Now that we have our form logic, we need to integrate them into both the front-end and back-end of the application. We'll deal with the back-end first.

Building back-end functionality

Back-end functionality is the heart of our application. Below, we'll define our application's routes and build the logic for creating and joining teams, as well as creating and solving challenges.

Team functionality

Let's start with teams. We'll define the following routes and functions in main.py, below our index() function:

# Teams
@app.route("/team-create", methods=["GET", "POST"])
@web.authenticated
@not_admin_only
@not_team_only
def team_create():
    pass

@app.route("/team-join", methods=['GET', 'POST'])
@web.authenticated
@not_admin_only
@not_team_only
def team_join():
    pass

@app.route("/team/<team_id>")
def team(team_id):
    pass

The /team-create and /team-join routes will use their respective forms. Users already in teams and admins will not be permitted to create or join teams. The /team/<team_id> page will be an informational page, showing the team's name, score, and which challenges they've solved. We're using part of the URL as a parameter here, so, for example, /team/codeslingers will take us to the team page for that team. We won't require authentication for this page.

Because we'll be dealing with passwords, we're going to store them as one-way encrypted hashes. This will prevent anyone with access to our repl's database from easily seeing all team passwords. We'll use Flask's Bcrypt extension for this, which you can install by searching for "flask-bcrypt" in the Packages tab on the Replit IDE sidebar.

Flask bcrypt package

While Replit usually automatically installs packages based on our import statements, this one must be manually installed, as its package name is slightly different on Pypi and on disk. Once it's installed, we import it with the following additional line at the top of main.py:

from flask_bcrypt import Bcrypt

Then we initialize a Bcrypt object for our app by adding the following line just below app = Flask(__name__):

bcrypt = Bcrypt(app)

Now let's add some code to our team_create function:

@app.route("/team-create", methods=["GET", "POST"])
@web.authenticated
@not_admin_only
@not_team_only
def team_create():

    form = TeamCreateForm(request.form)

    if request.method == "POST" and form.validate():
        team_name = form.name.data
        team_id = name_to_id(team_name)

        hashed_password = bcrypt.generate_password_hash(form.password.data).decode("utf-8")
        team_leader = web.auth.name

        # Construct team dictionary
        db["teams"][team_id] = {
            "name": team_name,
            "password": hashed_password,
            "leader": team_leader,
            "members": [team_leader],
            "score": 0,
            "challenges_solved": []
        }

        # Set user team
        users.current["team"] = team_id

        flash("Team created!")
        return redirect(url_for('team', team_id=team_id))

    return render_template("team-create.html",
        form = form,
        **context())

First, we create an instance of TeamCreateForm using the values in request.form. We then check whether the current request is an HTTP POST, and we call validate() on the form. Behind the scenes, this method will run all of our field validators, and return error messages to the user for fields that fail validation. It will only return True once all fields validate.

Once we know we've got valid form input, we can save its data to our database. We construct our team's ID using the helper function from forms.py, hash our team password, and then define our team's dictionary.

After that, we set the current user's team in our user database and redirect the user to their new team's page. We use users.current as an alias for users[web.auth.name].

At the bottom of the function, we render our team-create page and tell it which form to use. This will happen regardless of whether the initiating request was a GET or a POST. We'll create the template and define the context function when we build the front-end.

Now we can add the code for joining a team, in the team_join function:

@app.route("/team-join", methods=['GET', 'POST'])
@web.authenticated
@not_admin_only
@not_team_only
def team_join():

    form = TeamJoinForm(request.form)

    if request.method == "POST" and form.validate():
        team_id = form.name.data
        team_name = db["teams"][team_id]["name"]

        if bcrypt.check_password_hash(
                db["teams"][team_id]["password"],
                form.password.data
            ):
            db["teams"][team_id]["members"].append(web.auth.name)
            users.current["team"] = team_id

            flash(f"You joined {team_name}!")
            return redirect(url_for('team', team_id=team_id))
        else:
            flash(f"Wrong password for {team_name}!")
            return redirect(url_for("index"))

    return render_template("team-join.html",
        form = form,
        **context())

If our form validates, we check the provided team password, and if it's correct, we add the current user to the team and send them to the team page. If it's incorrect, we redirect them to the home page.

Finally, we can define our /team/<team_id> route, by adding this code to the team() function:

@app.route("/team/<team_id>")
def team(team_id):
    return render_template("team.html",
        team_id = team_id,
        **context())

Admin functionality

We're going to let admin users add challenges to the front-end so that we can keep our code generic and re-use it for multiple competitions, if we wish. We'll add the other admin functionality we need at the same time.

We'll start with the challenge creation route. Add this code below your team routes:

# Admin functions
@app.route("/admin/challenge-create", methods=["GET", "POST"])
@web.authenticated
@admin_only
def admin_challenge_create():

    form = ChallengeCreateForm(request.form)

    if request.method == "POST" and form.validate():
        challenge_name = form.name.data
        challenge_id = name_to_id(challenge_name)
        hashed_code = bcrypt.generate_password_hash(form.code.data).decode("utf-8")

        # Construct challenge dictionary
        db["challenges"][challenge_id] = {
            "name": challenge_name,
            "description": form.description.data,
            "points": int(form.points.data),
            "code": hashed_code
        }

        flash("Challenge created!")
        return redirect(url_for('challenge', challenge_id=challenge_id))

    return render_template("admin/challenge-create.html",
        form = form,
        **context())

This code is almost identical to our team creation functionality. While hashing challenge codes may not be strictly necessary, it will prevent any users with access to our repl from cheating by viewing the database.

Challenge removal is a bit simpler:

@app.route("/admin/challenge-remove/<challenge_id>")
@web.authenticated
@admin_only
def admin_remove_challenge(challenge_id):

    # Remove challenge from team solutions
    for _, team in db["teams"].items():
        if challenge_id in team["challenges_solved"]:
            team["challenges_solved"].remove(challenge_id)
            team["score"] -= db["challenges"][challenge_id]["points"]

    # Delete challenge dictionary
    del db["challenges"][challenge_id]

    flash("Challenge removed!")
    return redirect(url_for('index'))

We'll allow admins to start and stop the competition with two routes that toggle a value in our database:

@app.route("/admin/competition-start")
@web.authenticated
@admin_only
def admin_start_competition():
    db["competition_started"] = True

    flash("Competition started!")
    return redirect(url_for('index'))

@app.route("/admin/competition-stop")
@web.authenticated
@admin_only
@competition_running
def admin_end_competition():
    db["competition_started"] = False

    flash("Competition ended!")
    return redirect(url_for('index'))

Finally, we'll define an admin route that deletes and reinitializes the application's general and user databases. This will be useful for running multiple competitions on the same app, and for debugging!

@app.route('/admin/db-flush')
@web.authenticated
@admin_only
def flush_db():
    del db["challenges"]
    del db["teams"]
    del db["competition_started"]

    for _, user in users.items():
        user["team"] = None

    db_init()

    return redirect(url_for("index"))

If we add any additional keys or values to either of our databases, we will need to remember to delete them in this function.

Challenge functionality

Finally, we need to add functionality that will allow users to solve challenges and score points. Add the following code below your admin routes:

# Challenge functionality
@app.route("/challenge/<challenge_id>", methods=["GET", "POST"])
@web.authenticated
@competition_running
def challenge(challenge_id):

    form = ChallengeSolveForm(request.form)

    if request.method == "POST" and form.validate():

        if bcrypt.check_password_hash(
                db["challenges"][challenge_id]["code"],
                form.code.data
            ):
            db["teams"][users.current["team"]]["challenges_solved"].append(challenge_id)
            db["teams"][users.current["team"]]["score"] += db["challenges"][challenge_id]["points"]
            flash("Challenge solved!")
        else:
            flash("Wrong challenge code!")

    return render_template("challenge.html",
        form = form,
        challenge_id = challenge_id,
        **context())

This function is very similar to team_join(). The main difference is that we will be hosting this form on the challenge description page, so we can fetch the challenge_id from the URL rather than asking the user which challenge they're submitting a code for in the form.

Building the web application front-end

We have a fully functional application back-end, but without some front-end pages, our users will have to join teams and submit challenge solutions using curl. So let's create an interface for our back-end using HTML and Jinja, Flask's powerful front-end templating language.

Creating the HTML templates

First, we'll need the following HTML files in a new directory called templates:

templates/
    |__ admin/
    |     |__  challenge-create.html
    |__  _macros.html
    |__  challenge.html
    |__  index.html
    |__  layout.html
    |__  leaderboard.html
    |__  team-create.html
    |__  team-join.html
    |__  team.html

HTML templates

Once you've created these files, let's populate them, starting with templates/layout.html:

<!DOCTYPE html>
<html>
    <head>
        <title>Challenge Leaderboard</title>
    </head>
    <body>
    {% with messages = get_flashed_messages() %}
        {% if messages %}
            <ul class=flashes>
            {% for message in messages %}
            <li>{{ message }}</li>
            {% endfor %}
            </ul>
        {% endif %}
    {% endwith %}

    {% if name != None %}
    <p>Logged in as {{ username }}</p>
    {% endif %}

    <ul>
        <li><a href="/">View challenges</a></li>
        <li><a href="/leaderboard">View leaderboard</a></li>
        <li><a href="/team-create">Create team</a></li>
        <li><a href="/team-join">Join team</a></li>
    </ul>


    {% block body %}{% endblock %}
    </body>
</html>

We'll use this file as the base of all our pages, so we don't need to repeat the same HTML. It contains features we want on every page, such as flashed messages, an indication of who's currently logged in, and a global navigation menu. All subsequent pages will inject content into the body block:

{% block body %}{% endblock %}

Next, we need to populate another helper file, templates/_macros.html:

{% macro render_field(field) %}
  <dt>{{ field.label }}
  <dd>{{ field(**kwargs)|safe }}
  {% if field.errors %}
    <ul class=errors>
    {% for error in field.errors %}
      <li>{{ error }}</li>
    {% endfor %}
    </ul>
  {% endif %}
  </dd>
{% endmacro %}

This file defines the Jinja macro render_field, which we'll use to give all our form fields their own error-handling, provided by WTForms.

Let's define our home page now, with a list of challenges. Add the following code to templates/index.html:

{% extends "layout.html" %}
{% block body %}
    <h1>Challenges</h1>
    <ul>
    {% for id, challenge in challenges.items()|sort(attribute='1.points') %}
        <li>
            <a href="/challenge/{{ id }}">{{ challenge.name }}</a> ({{ challenge.points }} points)
            {% if admin %}
            | <a href="/admin/challenge-remove/{{ id }}">DELETE</a>
            {% endif %}
        <li>
    {% endfor %}
    {% if admin %}
        <li><a href="/admin/challenge-create">NEW CHALLENGE...</a></li>
    {% endif %}
    </ul>

    {% if admin %}
        <h1>Admin functions</h1>
        <ul>
            {% if competition_running %}
                <li><a href="/admin/competition-stop">End competition</a></li>
            {% else %}
                <li><a href="/admin/competition-start">Start competition</a></li>
            {% endif %}
            <li><a href="/admin/db-flush">Flush database</a></li>
        </ul>
    {% endif %}
{% endblock %}

Here, {% extends "layout.html" %} tells our templating engine to use layout.html as a base template, and {% block body %} ... {% endblock %} defines the code to place inside layout.html's body block.

The following line will sort challenges in ascending order of points:

    {% for id, challenge in challenges.items()|sort(attribute='1.points') %}

In addition, we use {% if admin %} blocks to include links to admin functionality that will only display when an admin is logged in.

Next we define our team pages:

templates/team-create.html

{% extends "layout.html" %}
{% block body %}
    {% from "_macros.html" import render_field %}
    <h1>Create team</h1>
    <form action="/team-create" method="post" enctype="multipart/form-data">
        {{ render_field(form.name) }}
        {{ render_field(form.password) }}
        {{ form.csrf_token }}

        {{ form.submit }}
    </form>
{% endblock %}

templates/team-join.html

{% extends "layout.html" %}
{% block body %}
    {% from "_macros.html" import render_field %}
    <h1>Join team</h1>
    <form action="/team-join" method="post">
        {{ render_field(form.name) }}
        {{ render_field(form.password) }}
        {{ form.csrf_token }}

        {{ form.submit }}
    </form>
{% endblock %}

templates/team.html

{% extends "layout.html" %}
{% block body %}
    <h1>{{ teams[team_id].name }}</h1>

    <h2>Team members</h2>
    <ul>
    {% for user in teams[team_id].members %}
        <li>{{ user }}</li>
    {% endfor %}
    </ul>

    <h2>Challenges solved</h2>
    <ul>
    {% for id in teams[team_id].challenges_solved %}
        <li>
            <a href="/challenge/"{{ id }}>{{ challenges[id].name }}</a>
        <li>
    {% endfor %}
    </ul>
{% endblock %}

You'll notice that we've imported our render_function macro on these pages and used it to show our various form fields. Each form also has a hidden field specified by {{ form.csrf_token }}. This is a security feature WTForms provides to prevent cross-site request forgery vulnerabilities.

Now we can create our challenge page:

templates/challenge.html

{% extends "layout.html" %}
{% block body %}
    {% from "_macros.html" import render_field %}
    <h1>{{ challenges[challenge_id].name }}</h1>

    <p>{{ challenges[challenge_id].description }}</p>

    <p><b>Points: {{ challenges[challenge_id].points }}</b></p>

    {% if user_team != None and challenge_id not in teams[user_team]["challenges_solved"] %}
    <form action="/challenge/{{challenge_id}}" method="post">
        {{ render_field(form.code) }}
        {{ form.csrf_token }}

        {{ form.submit }}
    </form>
    {% endif %}
{% endblock %}

Then our challenge creation page (inside the templates/admin directory):

templates/admin/challenge-create.html

{% extends "layout.html" %}
{% block body %}
    {% from "_macros.html" import render_field %}
    <h1>Create challenge</h1>
    <form action="/admin/challenge-create" method="post" enctype="multipart/form-data">
        {{ render_field(form.name) }}
        {{ render_field(form.description) }}
        {{ render_field(form.points) }}
        {{ render_field(form.code) }}
        {{ form.csrf_token }}

        {{ form.submit }}
    </form>
{% endblock %}

We've referred to a lot of different variables in our front-end templates. Flask's Jinja templating framework allows us to pass the variables we need into render_template(), as we did when building the application backend. Most pages needed a form, and some pages, such as challenge and team, needed a challenge or team ID. In addition, we unpack the return value of a function named context to all of our rendered pages. Define this function now with our other helper functions in main.py, just below in_team:

def context():
    return {
        "username": web.auth.name,
        "user_team": in_team(web.auth.name),
        "admin": is_admin(web.auth.name),
        "teams": db["teams"],
        "challenges": db["challenges"],
        "competition_running": db["competition_started"]
    }

This will give every page most of the application's state. If we find we need another piece of state later, we can add it to the context helper function, and it will be available to all our pages.

Importantly, we're using a function rather than a static dictionary so that we can get the most up-to-date application state every time we serve a page.

Before we move on, we should change our app's home page from the initial demo version we made at the beginning of this tutorial to a proper page. Find the index() function and replace it with this code:

@app.route("/")
def index():
    return render_template("index.html",
        **context())

You'll notice we've removed the @web.authenticated decorator. This will allow unauthenticated users to get a glimpse of our site before being asked to log in. replit.web will prompt them to log in as soon as they attempt to access an authenticated page.

Building the leaderboard

We've left out a key part of our application: the leaderboard showing which team is winning! Let's add the leaderboard frontend now, with the following HTML and Jinja code in templates/leaderboard.html:

{% extends "layout.html" %}
{% block body %}
    <h1>Leaderboard</h1>
    <ul>
    {% for id, team in teams.items()|sort(attribute='1.score', reverse=True) %}
        <li {% if id == user_team %}style="font-weight: bold"{% endif %}>
            <a href="/team/{{ id }}">{{ team.name }}</a>: {{ team.score }} points
        <li>
    {% endfor %}
    </ul>
{% endblock %}

Similar to the list of challenges on our home page, we use Jinja's sort filter to order the teams from highest to lowest score.

    {% for id, team in teams.items()|sort(attribute='1.score', reverse=True) %}

We also use an if block to show the name of the current user's team in bold.

Finally, we can add one last route to main.py, just above the line web.run(app):

@app.route("/leaderboard")
def leaderboard():
    return render_template("leaderboard.html",
        **context())

We're leaving this one unauthenticated as well, so that spectators can see how the competition's going.

Using the app

We're done! Run your repl now to see your app in action. As your user account will be a site admin, you may need to enlist a couple of friends to test out all the app's functionality.

For best results, open your repl's web page in a new tab.

Open in new tab button

If you run into unexplained errors, you may need to clear your browser cookies, or flush the database.

Where next?

We've built a CRUD application with a fair amount of functionality, but there's still room for improvement. Some things you might want to add include:

  • CSS styling.
  • More admin functionality, such as adjusting scores, banning users and teams, and setting team size limitations.
  • File upload, for challenge files and/or team avatars.
  • Time-limited competitions, with a countdown.
  • Badges/achievements for things like being the first team to solve a given challenge.
  • A place for teams to submit challenge solution write-ups.

And of course, you can also use your site to host a competition right now.

You can find code for this tutorial here: