Back to Blog Posts

Adding tags to blog posts using many-to-many relationships in SQLAlchemy

Blog

Tuesday, 24 March 2020

The option to filter articles by tags lets the author diversify content but retains some organisation for themselves and the reader. This is useful if the author wanted to write a series of posts on one topic but chronologically publish articles on varied topics. It's also beneficial to the reader who may not want to scroll though topics irrelevant to their interests.

To get started there needed to be some changes and additions to the database, which in this application is managed with the SQLAlchemy ORM. A new model has been created to contain the tags names that can be assigned to a blog post (BlogPostTags) and an extra column for tags has been added to the existing blog posts model (BlogPost).

The BlogPostTags model just contains the tag name and an id. A classmethod called tag_names has been created which will return all of the values when called. This will be used when listing the tags a reader can filter by and when the author chooses tags to assign to a post.


class BlogPostTags(db.Model):

    id = db.Column(db.Integer, primary_key=True)
    blogpost_tag = db.Column(db.String(16), index=True, unique=True)

    @classmethod
    def tag_names(cls):

        return BlogPostTags.query.filter()

The BlogPost model defines the additional tag column as a relationship to the BlogPostTags model above passing several arguments:

  • secondary specifies an association table called blogtags_association which is explained below.

  • useList defaults to True anyway in a many-to-many relationship (False would instead load as a scalar) but it is useful to keep as a reminder to know what type to expect when handling the data later.

  • lazy being set to a value of subquery will run a subquery and join the two models to return all of the data at once. This will be useful when sending the data to a template so the tags can be listed along with the post content without the template needed to run another query later on when the tag data is actually requested.

  • backref refers to a field that will be created on the BlogPostTags model that can be used to query and return the associated tags to a post. Again, lazy has been set as subquery.


class BlogPost(db.Model):

    id = db.Column(db.Integer, primary_key=True)
    title  = db.Column(db.String(150))
    #<...>
    tag = db.relationship('BlogPostTags', secondary=blogtags_association, uselist=True, lazy='subquery', backref=db.backref('tag_name', lazy='subquery'))

Seeing as more than one post can suit more than one tag - the relationship between these two models will be many-to-many, a more detailed explanation can be found in the SQLAlchemy documentation but this post will just explain how it was configured for this application.

An extra table called blogtags_association has been created to be used as an association table. This table is created with two foreign keys, one primary key from each model where each row will associate a post and a tag. SQLAlchemy strongly recommends creating this as a table rather than a model as it will handle all of the interaction for you, there is no need for the application to insert associations directly.


blogtags_association = db.Table(
    'blogtags_association',
    db.Column('tag_id', db.Integer, db.ForeignKey('blog_post_tags.id')),
    db.Column('blogpost_id', db.Integer, db.ForeignKey('blog_post.id'))
)


db.create_all()

Calling db.create_all() at the end will create all the tables next time the application is started, shown below.


show tables where Tables_in_db like 'blog%';

+----------------------+
| Tables_in_db         |
+----------------------+
| blog_post            |
| blog_post_tags       |
| blogtags_association |
+----------------------+

For testing purposes a tag can be added manually:


insert into blog_post_tags (blogpost_tag) values ('python');

The more permanent method was to create a page with a simple form to enter tag names. The form is displayed when visiting the manage_tags route if the user has admin permissions. The data entered for the tag name is pulled from the form when submitted and it gets committed to the blog_post_tags table.


# Form 
class CreateTag(FlaskForm):

    tag = TextAreaField('Add tag:', validators=[DataRequired(message="Please don't leave this field blank.")])

    submit = SubmitField('Add')


# Route
@app.route('/admin_panel/manage_tags', methods=['GET', 'POST'])
@login_required
def manage_tags():

    if validate_if_admin_user(current_user):

        form = CreateTag()

        if form.validate_on_submit():

            tag = BlogPostTags(blogpost_tag=form.tag.data)

            db.session.add(tag)
            db.session.commit()

            flash('Tag Added.')

            return redirect(url_for('manage_tags'))

        query = BlogPostTags.tag_names()

        return render_template('manage_tags.html', blogtags=query, form=form)

    else:

        return redirect(url_for('user', username=current_user.username))

Now that there's data available in the blog_post_tags table it's time to modify the form for creating a blog post to populate a field with the assignable tags. As mentioned before, there can be more than one tag per post so this requires the SelectMultipleField import from wtforms allowing multiple entries to be selected from the tag field. This has been added as blog_tags in the CreateBlogPost form below.

The classmethod refresh_values was created because otherwise the listed tags will only be generated the first time the form is initialised - this is a more dynamic approach otherwise the application would need to be restarted every time a tag was added. The tags are returned from the tag_names classmethod created earlier in the model.


from wtforms import SelectMultipleField

class CreateBlogPost(FlaskForm):

    #<...>
    blog_tags = SelectMultipleField('Tags:')

    submit = SubmitField('Post')

    @classmethod
    def refresh_values(cls):

        form = cls()
        form.blog_tags.choices = [(str(tag.id), str(tag.blogpost_tag)) for tag in BlogPostTags.tag_names()]

        return form

The final modifications were made to the createpost route that calls the page with the CreateBlogPost form outlined above. Instead of calling the form it now calls the refresh_values classmethod to update the list of tags before generating the form. There also needed to be a tag variable to read in the data from the tag field on the form, this will be a loop as the author may select more than one. For each submitted tag the BlogPostTags model will be queried for the first result with the matching tag ID, this will return a database object which will be stored in an array. When adding/committing this array, SQLAlchemy will handle the inserts to the association table to create the many-to-many relationship.


@app.route('/createpost/', methods=['GET', 'POST'])
@login_required
def createpost():

    if validate_if_admin_user(current_user):

        form = CreateBlogPost.refresh_values()
        if form.validate_on_submit():

            blog_entry = BlogPost( \
                title=form.blog_title.data, \
                slug=re.sub('[^\w]+', '-', form.blog_title.data.lower()), \
                icon=form.blog_icon.data, \
                tag=[BlogPostTags.query.filter(BlogPostTags.id == int(tag)).first() for tag in form.blog_tags.data], \
                content=form.blog_content.data
            )

            db.session.add(blog_entry)
            db.session.commit()

            flash('Blog Post Created.')

            return redirect(url_for('readpost', slug=entry['slug']))

        return render_template('createpost.html', form=form)

    else:

        return redirect(url_for('user', username=current_user.username))

With the relationship in place it was then possible to update the blog route to render a page that listed the tags under each post title and also list all the tags along the top of the page for a user to click on and filter the list of posts.


@app.route('/blog')
def blog():

    blog_join = BlogPost.query.join(BlogPostTags, BlogPost.tag).order_by(BlogPost.timestamp.desc()).all()
    tag_query = BlogPostTags.query.filter()

    return render_template('blog.html', blogposts=blog_join, blogtags=tag_query)

<!-- ... -->
    <h2>Blog Posts</h2>
        <span class="span_tags">Tags:</span>
        {% for tag in blogtags %}
                  <span class="span_tags"><a href="{{ url_for('blog_tags', tag_name=tag.blogpost_tag) }} "> {{ tag.blogpost_tag }}</a></span>
          {% endfor %}
    <hr class="separator1">
<!-- ... -->

Each tag on that page links to a page that only displays posts associated with that tag. This is done by passing the tag name as a variable in the URL which is used query the BlogPost model for entries containing that tag.


@app.route('/blog/tag/<tag_name>')
def blog_tags(tag_name):

    blog_join = BlogPost.query.filter(BlogPostTags.blogpost_tag.contains(tag_name)).join(BlogPostTags, BlogPost.tag).order_by(BlogPost.timestamp.desc()).all()

    return render_template('blog_tag.html', blogposts=blog_join, tagged_as=tag_name)



Leave a Comment

Comments (0)