Menjadi Developer Web dengan Python dan Flask Bagian VI: Halaman Profil dan Avatar

Bagus Aji Santoso 19 Februari 2018

Menjadi Developer Web dengan Python dan Flask Bagian VI: Halaman Profil dan Avatar

Artikel ini merupakan terjemahan The Flask Mega-Tutorial Part VI: Profile Pagedari seri The Flask Mega-Tutorial karya Miguel Grinberg yang akan dirilis secara gratis bab per bab sampai bulan Mei 2018. Dukung penulis aslinya dengan membeli buku/video tutorial lengkapnya di sini.

Bab ini akan membahas bagaimana menambah halaman profil ke aplikasi yang sudah kita buat. Halaman profil adalah sebuah halaman dimana informasi mengenai seorang user ditampilkan, biasanya informasi-informasi yang dikirim oleh user sendiri. Penulis akan menunjukkan bagaimana membuat halaman profil untuk semua user secara dinamis, lalu kita akan tamabhkan sebuah profil editor yang bisa dipakai user untuk mengirimkan informasi tentang diri mereka.

Tautan GitHub untuk bab ini: Browse, Zip, Diff.

Halaman Profil

Untuk membuat halaman profil, pertama mari kita tulis sebuah fungsi view baru yang mengarahkan ke URL /user/.

app/routes.py: Fungsi view untuk profil User

@app.route('/user/<username>')
@login_required
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    posts = [
        {'author': user, 'body': 'Test post #1'},
        {'author': user, 'body': 'Test post #2'}
    ]
    return render_template('user.html', user=user, posts=posts)

Dekorator @app.route yang dipakai untuk membuat fungsi view ini agak berbeda dengan yang sebelum-sebelumnya. Disini kita ingin memiliki sebuah komponen dinamis yang diwakili oleh <username>. Saat sebuah route memiliki komponen dinamis, Flask akan menerima teks apapun yang posisinya sesuai dengan URL tersebut, lalu menggunakan teks tadi sebagai argumen yang bisa dipakai oleh fungsi view. Contoh, jika browser memanggil URL /user/susan, maka fungsi view akan dipanggil dengan argumen username berisi 'susan'. Fungsi view ini hanya akan tersedia bagi user yang masuk ke sistem, jadi penulis menambahkan dekorator @login_required dari Flask-Login.

Nenek 82 Tahun Ini Buktikan Semua Bisa Jadi Programmer

Isi fungsi view ini cukup sederhana. Pertama kita mencoba membaca user dari database dengan kueri menggunakan argumen username. Sebelumnya kita sudah pernah belajar bahwa pemanggilan all() untuk mendapatkan seluruh data, atau first() untuk mendapatkan hanya hasil yang pertama atau None jika tidak ada hasil. Disini kita memakai variasi first() bernama first_or_404(), yang cara kerjanya persis sama kecuali saat tidak ada result secara otomatis akan mengirim pesan 404 error ke browser. Dengan menggunakan kueri ini kita tidak perlu memproses sesuatu saat tidak ada username yang tersedia di database, cukup berikan error 404.

Jika kueri database tidak memanggil error 404, artinya ada user dengan username yang dikirim. Selanjutnya kita membuat daftar postingan buatan untuk user ini (karena belum ada data yang asli), lalu menampilkan template user.html dengan mengirimkan objek user dan daftar postingannya.

Template user.html dapat dilihat di bawah:

app/templates/user.html: Template profil User

{% extends "base.html" %}

{% block content %}
    <h1>User: {{ user.username }}</h1>
    <hr>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

Halaman profil sekarang telah selesai, tapi belum bisa kita akses karena belum ada tautan yang mengarah ke sana. Untuk mempermudah user memeriksa profil mereka, kita akan menambahkan sebuah tautan ke navigation bar di atas:

app/templates/base.html: Template profil User

    <div>
      Microblog:
      <a href="{{ url_for('index') }}">Home</a>
      {% if current_user.is_anonymous %}
      <a href="{{ url_for('login') }}">Login</a>
      {% else %}
      <a href="{{ url_for('user', username=current_user.username) }}">Profile</a>
      <a href="{{ url_for('logout') }}">Logout</a>
      {% endif %}
    </div>

Perbedaan yang ada di sini hanyalah pemanggilan url_for() yang dipakai untuk membuat link ke halaman profil. Karena fungsi view untuk profil user meminta argumen dinamis, fungsi url_for() memberikannya di argumen yang kedua. Lalu, karena link ini mengarah ke profil user sendiri, kita bisa memakai current_user dari Flask-Login untuk membuat URL yang sesuai.

User Profile Page

Coba akses aplikasi kita sekarang. Klik pada tautan Profile di atas dan kita akan dibawa ke halaman profil kita sendiri. Untuk saat ini belum ada tautan yang bisa membawa kita ke halaman profil user lain, tapi jika mau kita bisa menuliskan URL-nya secara manual di address bar. Misalnya, jika kita memiliki user bernama "john", kita bisa melihat profilnya dengan mengunjungi alamat http://localhost:5000/user/john di address bar.

Avatar

Penulis yakin pembaca juga setuju bahwa halaman profil yang sekarang masih kurang bagus. Maka, untuk membuatnya lebih menarik, kita akan menambah gambar avatar, tapi daripada kita mengurus sendiri kumpulan gambar yang akan diunggah ke server, kita akan memakai layanan Gravatar yang akan memberikan gambar avatar untuk seluruh user.

Layanan Gravatar sangat mudah untuk dipakai. Meminta gambar untuk user tertentu bisa dilakukan dengan format https://www.gravatar.com/avatar/, dimana <hash> adalah MD5 hash untuk alamat email user. Di bawah ini pembaca bisa melihat bagaimana cara mengambil URL Gravatar untuk user yang memiliki email john@example.com:

>>> from hashlib import md5
>>> 'https://www.gravatar.com/avatar/' + md5(b'john@example.com').hexdigest()
'https://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6'

Jika ingin melihat contoh aslinya, URL untuk Gravatar penulis ada di https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35. Berikut gambar yang dikembalikan dari URL ini:

Miguel's Gravatar

Gambar yang dikembalikan memiliki ukuran 80x80 pixels, tapi kita bisa meminta ukuran lain dengan memberikan argumen s ke URL. Misal, untuk meminta gambar 128x128 pixel untuk profil penulis, URL yang benar adalah https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35?s=128.

Argumen lain yang bisa dikirim ke Gravatar adalah d, yang menentukan gambar apa yang harus dikirim oleh Gravatar jika email yang diberikan tidak terdaftar (tidak memiliki gambar). Gambar bawaan yang penulis paling sukai adalah "identicon" dengan desain geometri yang menarik dan akan berbeda untuk setiap email. Berikut contohnya:

Identicon Gravatar

Catat bahwa beberapa ekstensi memblok gambar Gravatar seperti Ghostery karena menurutnya, Automattic (perusahaan pemilih layanan Gravatar) dapat menentukan situs apa yang kita kunjungi berdasarkan rikues yang mengambil avatar kita. Jika gambar avatar kita tidak muncul, mungkin permasalahannya ada di salah satu ekstensi browser yang terpasang.

Karena avatar terhubung ke seorang user, maka masuk akal bila kita menambahkan kode untuk mengambil URL avatar di dalam model user.

app/models.py: URL avatar User

from hashlib import md5
# ...

class User(UserMixin, db.Model):
    # ...
    def avatar(self, size):
        digest = md5(self.email.lower().encode('utf-8')).hexdigest()
        return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format(
            digest, size)

Method avatar() yang baru di dalam kelas User akan mengembalikan URL dari gambar avatar user tertentu berdasarkan ukuran yang diminta. Jika user tidak memiliki avatar, akan diberikan gambar "identicon". Untuk mendapatkan hash MD5, pertama kita ubah email menjadi bentuk lower case (huruf kecil semua) karena menjadi aturan Gravatar. Lalu, karena MD5 di Python diproses sebagai sebuah bytes bukannya string, maka kita ubah dulu string email user (encode) menjadi bytes sebelum mengirimnya ke fungsi hash.

Jika pembaca tertarik untuk mengenal lebih jauh opsi lain yang ditawarkan oleh layanan Gravatar, kunjungi dokumentasi resminya.

Langkah selanjutnya adalah menampilkan gambar avatar user di template profil:

app/templates/user.html: Menampilkan avatar user

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
    <p>
    {{ post.author.username }} says: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}

Keuntungan menggunakan kelas User untuk mengembalikan alamat URL adalah jika suatu saat kita memutuskan untuk mengganti sistem avatar dari Gravatar, maka kita cukup mengganti isi method avatar() untuk mengembalikan URL avatar yang baru, tanpa perlu mengubah-ubah kode di template.

Sekarang kita sudah memiliki satu gambar avatar besar di halaman profil, tapi kita tidak akan berhenti sampai di sini. Kita memiliki beberapa postingan user di bagian bawah yang bisa menampilkan avatar juga. Untuk halaman profil tentu saja akan daftar postingan akan menampilkan avatar yang sama, tapi kemudian kita bisa memakai cara yang sama untuk halaman depan, lalu setiap postingan akan menampilkan gambar avatar pemiliknya dan terlihat cukup bagus.

Empat Alternatif Open Source Untuk Google Analytics

Untuk menampilkan avatar di tiap postingan kita cukup mengubah sedikit template yang ada:

app/templates/user.html: Menampilkan avatar di postingan user

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
    <table>
        <tr valign="top">
            <td><img src="{{ post.author.avatar(36) }}"></td>
            <td>{{ post.author.username }} says:<br>{{ post.body }}</td>
        </tr>
    </table>
    {% endfor %}
{% endblock %}
Avatars

Menggunakan Sub-Templates Jinja2

Halaman profil user dibuat sedemikian rupa sehingga ia menampilkan postingan yang ditulis oleh si user, bersama dengan avatarnya. Sekarang kita ingin agar halaman index juga menampilkan daftar posting menggunakan layout yang sama. Copy/paste bagian template yang berhubungan dengan tampilan posting artikel bisa saja kita lakukan, tapi cara ini kurang ideal karena jika kita ingin mengubah layout-nya kita perlu mengupdate kedua template tersebut.

Maka dari itu, kita akan membuat sebuah sub-template untuk menampilkan satu posting, lalu kita memanggilnya dari template user.html dan index.html. Kita bisa mulai dengan membuat sub-template yang hanya berisi kode HTML satu posting. Kita beri nama template ini app/template/_post.html. Bagian _ hanya penamaan yang mengindikasikan bahwa kita file ini adalah sub-template.

app/templates/_post.html: Sub-template Post

    <table>
        <tr valign="top">
            <td><img src="{{ post.author.avatar(36) }}"></td>
            <td>{{ post.author.username }} says:<br>{{ post.body }}</td>
        </tr>
    </table>

Untuk memanggil sub-template ini dari template user.html kita menggunakan perintah include milik Jinja2:

app/templates/user.html: Pemanggilan sub-template

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td><h1>User: {{ user.username }}</h1></td>
        </tr>
    </table>
    <hr>
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
{% endblock %}

Halaman index belum diselesaikan, jadi belum kita tambahkan pemanggilan sub-template di sana.

More Interesting Profiles

Satu masalah di halaman profil kita yang baru adalah tidak adanya informasi yang cukup. User mungkin ingin memberitahu siapa mereka di halaman ini, jadi kita akan memperbolehkan user menulis sesuatu tentang diri mereka untuk ditampilkan di sini. Kita juga akan mencatat kapan terakhir kali setiap user mengakses situs ini dan menampilkannya di halaman profil.

Pertama, untuk menambah informasi ke setiap user bisa kita lakukan dengan menambah dua field baru:

app/models.py: Field baru di model User

class User(UserMixin, db.Model):
    # ...
    about_me = db.Column(db.String(140))
    last_seen = db.Column(db.DateTime, default=datetime.utcnow)

Setiap kali database di modifikasi kita perlu melakukan migrasi. Di bab 4 kita sudah belajar bagaimana memeriksa perubahan database dengan menggunakan skrip migrasi. Sekarang karena kita sudah menambah dua field baru ke database, kita perlu membuat skrip migrasi-nya:

(venv) $ flask db migrate -m "new fields in user model"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'user.about_me'
INFO  [alembic.autogenerate.compare] Detected added column 'user.last_seen'
  Generating /home/miguel/microblog/migrations/versions/37f06a334dbf_new_fields_in_user_model.py ... done

Pesan yang muncul dari perintah migrate terlihat cukup baik, karena pesan tersebut mampu mendeteksi dan menampilkan dua field baru di kelas User. Sekarang kita terapkan perubahan tadi ke database:

(venv) $ flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 780739b227a7 -> 37f06a334dbf, new fields in user model

Penulis harap pembaca menyadari betapa untungnya menggunakan migration framework. Semua user yang sudah ada di dalam database masih ada, migration framework mengaplikasikan semua perubahan tanpa menghapus data yang sebelumnya sudah dimasukkan.

Selanjutnya, kita akan menambah dua field baru ini ke template profil user:

app/templates/user.html: Menampilkasn informasi user di template profil

{% extends "base.html" %}

{% block content %}
    <table>
        <tr valign="top">
            <td><img src="{{ user.avatar(128) }}"></td>
            <td>
                <h1>User: {{ user.username }}</h1>
                {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
                {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %}
            </td>
        </tr>
    </table>
    ...
{% endblock %}

Perhatikan bahwa penulis membungkus dua field ini menggunakan perintah kodisinya Jinja2, karena kita hanya ingin mereka muncul jika sudah diatur. Saat ini dua field ini masih kosong karena belum diisi oleh user, jadi kita belum dapat melihat apa-apa.

Menyimpan Waktu Terakhir Kali User Aktif

Mari kita mulai dulu dengan membuat field last_seen. Apa yang kita inginkan ialah menyimpan waktu saat seorang user berusaha mengirim rikues ke server.

Mengaplikasikan Full Text Search dengan MySQL di Ubuntu 16.04

Menambahkan kode untuk menyimpan waktu disetiap fungsi view tentu saja kurang praktis, tapi menjalankan kode-kode tertentu sebelum melakukan rikues juga normal dilakukan dalam sebuah aplikasi web bahkan Flask memiliki fitur bawaan untuk itu. Coba lihat solusinya di bawah:

app/routes.py: Menyimpan aktivitas terakhir

from datetime import datetime

@app.before_request
def before_request():
    if current_user.is_authenticated:
        current_user.last_seen = datetime.utcnow()
        db.session.commit()

Dekorator @before_request dari Flask mendaftarkan fungsi di bawahnya untuk dijalankan sebelum sebuah fungsi view. Solusi ini amat berguna karena sekarang kita bisa menambahkan kode yang akan dijalankan lebih dulu sebelum fungsi view manapun dijalankan di satu tempat. Implementasi kode di dalamnya sekedar memeriksa jika current_user sedang masuk (logged in) atau tidak, jika ya maka kita akan mengisi field last_seen dengan waktu sekarang (method datetime.utcnow() akan mengembalikan waktu saat perintah tersebut di panggil). Seperti yang pernah penulis bahas sebelumnya, aplikasi server perlu menggunakan unit waktu yang konsisten dan standar yang dipakai pada umumnya adalah zona waktu UTC. Menggunakan local time kurang baik karena nilai waktu yang dikirim ke database bergantung pada lokasi kita. Langkah terakhir ialah melakukan commit untuk sesi database, sehingga penambahan data di atasnya disimpan. Jika pembaca bertanya kenapa tidak ada db.session.add() sebelum commit, hal tersebut karena saat menggunakan current_user, Flask-Login sudah memanggil fungsi callback user yang akan menjalankan kueri database khusus sehingga user yang dimaksud bisa ada di database session. Jadi, kita bisa menambahkan si user ke dalam fungsi ini lagi (fungsi callback), tapi tidak perlu dilakukan karena sudah ada.

Jika pembaca melihat halaman profil lagi setelah membuat perubahan ini, pembaca akan melihat ada baris "Last seen on" dengan waktu yang sangat dekat dengan waktu saat ini. Jika pembaca mencoba membuka halaman lain lalu kembali ke halaman profil, pembaca bisa melihat bahwa waktu yang tertulis terus berubah.

Karena data waktu disimpan dalam zona waktu UTC maka data yang tampil di profil juga dalam waktu UTC. Format yang ditampilkan mungkin bukan format yang enak untuk dilihat karena masih mengikuti objek datetime-nya Python. Untuk saat ini, kita tidak perlu khawatir tentang dua masalah tadi, kita akan membahas bagaimana menampilkan waktu di bab mendatang.

Last Seen Time

Profile Editor

Kita juga perlu memberikan form bagi user untuk memasukkan beberapa informasi tentang dirinya. Form tersebut bisa dipakai user untuk mengganti username, juga menulis sesuatu tentang dirinya yang akan disimpan di field baru about_me. Mari kita tambah kelas form untuk data-data tersebut:

app/forms.py: Form edit Profile

from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length

# ...

class EditProfileForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    about_me = TextAreaField('About me', validators=[Length(min=0, max=140)])
    submit = SubmitField('Submit')

Penulis disini menggunakan tipe field baru dengan sebuah validator baru juga. Untuk field "About" penulis menggunakan TextAreaField, yang merupakan kotak multi-line dimana user bisa mengirim teks yang agak panjang. Untuk melakukan validasi field ini kita menggunakan Length yang memastikan teks berada antara 0 sampai 140 karakter, sesuai dengan ukuran yang dialokasikan di database untuk field tersebut.

Template untuk me-render form ini terlihat sebagai berikut:

app/templates/edit_profile.html: Form edit Profile

{% extends "base.html" %}

{% block content %}
    <h1>Edit Profile</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.username.label }}<br>
            {{ form.username(size=32) }}<br>
            {% for error in form.username.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.about_me.label }}<br>
            {{ form.about_me(cols=50, rows=4) }}<br>
            {% for error in form.about_me.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

Dan terakhir, berikut ini fungsi view yang menyatukan semuanya:

app/routes.py: Fungsi view edit profile

from app.forms import EditProfileForm

@app.route('/edit_profile', methods=['GET', 'POST'])
@login_required
def edit_profile():
    form = EditProfileForm()
    if form.validate_on_submit():
        current_user.username = form.username.data
        current_user.about_me = form.about_me.data
        db.session.commit()
        flash('Your changes have been saved.')
        return redirect(url_for('edit_profile'))
    elif request.method == 'GET':
        form.username.data = current_user.username
        form.about_me.data = current_user.about_me
    return render_template('edit_profile.html', title='Edit Profile',
                           form=form)

Fungsi view ini agak berbeda dengan fungsi satunya lagi dalam memproses form. Jika validate_on_submit() mengembalikan nilai True maka kita akan menyalin data dari form dan menulisnya ke database. Tapi jika nilai kembaliannya False, bisa jadi salah satu dari dua alasan ini penyebabnya. Pertama, bisa jadi karena browser hanya mengirim rikues GET dimana kita akan menampilkan form awal. Kedua, bisa juga karena form sudah mengirimkan rikues POST dengan data dari form tapi ada salah satu data yang tidak valid. Untuk form ini kita perlu memproses dua kasus tadi secara terpisah. Saat pertama kali mengirim rikues GET, kita akan menampilkan data yang sudah terisi di database ke field field-field di dalam for. Untuk kasus dimana ada validasi yang gagal, maka kita tidak ingin menampilkan apapun ke field-field di dalam form karena sudah diisi secara otomatis oleh WTForms. Membedakan kedua kasus ini dilakukan dengan memeriksa request.method yang akan bernilai GET pada rikues awal, dan POST saat terjadi submission validasi yang gagal.

User Profile Editor

Dalam rangka mempermudah user mengakses halaman edit profil, kita bisa menambah satu tautan baru di halaman profil mereka:

app/templates/user.html: Tuatan edit profile

                {% if user == current_user %}
                <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p>
                {% endif %}

Perhatikan kondisi yang penulis tambahkan untuk memastiakn bahwa tautan Edit hanya akan tampil saat user melihat profil sendiri dan tidak akan tampil saat melihat profil orang lain.

User Profile Page with Edit Link