Dalam beberapa bulan terakhir, saya menghabiskan banyak waktu untuk mengulangi Pinecast, layanan hosting podcast saya. Hosting podcast cukup mudah: fungsi intinya sangat sederhana. Hubungkan bucket S3 ke umpan RSS dan Anda siap melakukannya. Namun, beberapa pengguna memerlukan lebih banyak fitur daripada sekadar hosting: podcast memerlukan situs web, analisis yang jelas diperlukan untuk memahami audiens, dan peningkatan kualitas hidup sering kali diperlukan untuk mencegah pelanggan beralih ke pesaing.

Saya telah menjalankan portal dukungan selama beberapa waktu, namun saya ingin mendapatkan lebih banyak masukan langsung dari pengguna saya tentang apa yang sebenarnya mereka inginkan. Saya dapat memberi mereka ide tentang fitur yang Saya inginkan, namun saya bisa saja melewatkan sesuatu. Saya juga ingin mengumpulkan prioritas: fitur yang diinginkan oleh satu pengguna kurang penting dibandingkan fitur yang diinginkan banyak pengguna. Saya memerlukan cara bagi pengguna untuk memilih fitur yang paling mereka inginkan.

Saat membaca kotak masukku, aku melihat postingan tentang layanan Canny. Canny adalah apa yang saya inginkan: cara yang bersih dan mudah diintegrasikan untuk memungkinkan pengguna saya memposting dan memilih fitur. Melihat rencananya, sepertinya cukup murah juga. $2/bln untuk paket Pemula? Tentu! Saya mendaftar dan memulai integrasi.

Tidak ada makan siang gratis

Seperti halnya mainan baru lainnya, faktor wow dengan cepat hilang. Pertama, integrasi SSO (kemampuan agar pengguna Anda secara otomatis memiliki akun di Canny) tidak memiliki contoh kode Python. “Cukup sederhana,” pikir saya, sampai saya menyadari bahwa ini melibatkan kripto.

Tidak seperti kebanyakan bahasa lainnya, tidak ada perpustakaan kripto yang mudah diakses untuk Python. Buka cari “perpustakaan kripto python”. Anda akan melihat hasil pertama, pycrypto. Anda akan disesatkan oleh hasil pencarian ini, karena pycrypto tidak mendukung Python 3.4 dan yang lebih baru (Pinecast berjalan pada 3.5). Saya mencoba mem-porting contoh kode lain agar berfungsi dengan perpustakaan "kriptografi", yang memiliki dukungan jauh lebih baik, tetapi akhirnya gagal. Setelah bolak-balik dengan seorang insinyur di Canny, kami mendapatkan versi yang berfungsi. Meski begitu, saya masih tidak mengerti mengapa atau bagaimana cara kerjanya.

Setelah widgetnya disematkan, saya menghadapi beberapa masalah lagi. Pertama, mereka memerlukan nama pengguna. Saya tidak tahu layanan seperti apa yang menanyakan nama asli pengguna saat mereka mendaftar, tapi Pinecast tidak termasuk di antara mereka. Saya akhirnya "menulis fungsi hashing yang konyol" untuk menghasilkan nama-nama konyol dari email pengguna.

Masalah lainnya adalah kemampuan penyesuaian. Meskipun memungkinkan Anda mengubah label kolom formulir di widget, teks “Buat postingan” tidak dapat diubah menjadi “Sarankan fitur” atau yang serupa. “Posting” bukanlah istilah yang jelas dalam konteks halaman umpan balik Pinecast.

Widgetnya juga sangat lambat dimuat. Setelah tiba di halaman yang saya buat untuk itu, widget Canny membutuhkan waktu hampir tiga detik di internet kabel saya untuk muncul. Tidak ada indikator pemuatan, tidak ada teks pemuatan, hanya kotak kosong besar. Masalah pada papan permintaan fitur mereka sendiri dibuka untuk ini pada bulan Maret.

Terakhir, ketika email pengguna di token SSO berubah, email mereka di Canny tidak diperbarui. Artinya, jika pengguna mengubah emailnya di Pinecast, emailnya di Canny tidak diperbarui. Jika postingan yang mereka buat diperbarui, mereka tidak akan menerima pemberitahuan email (bukan karena kesalahan mereka sendiri). Saya melaporkan hal ini ke Canny, tetapi mereka mengatakan ini adalah fitur yang direncanakan tanpa banyak penjelasan lebih lanjut.

Secara keseluruhan, widget tersebut berfungsi dengan baik dan saya mengumpulkan sejumlah saran yang layak.

Ketika saya belajar membaca

Saya seharusnya membaca cetakan kecilnya.

Di akhir uji coba gratis selama satu bulan, saya mendapat kesadaran yang membuat frustrasi: paket $2/bulan tidak seperti yang saya pikirkan. Faktanya, ini sama sekali tidak dapat digunakan untuk Pinecast.

  • Itu tidak mendukung widget; pengguna harus mengunjungi situs web Canny.
  • Itu tidak memberi saya subdomain di Canny, jadi pada dasarnya tidak bermerek.
  • Tidak ada dukungan “SSO”, jadi pengguna saya harus mempertahankan identitas kedua yang terpisah di Canny. Tidak jelas apakah identitas SSO mereka yang ada akan dicocokkan dengan identitas SSO yang baru

Satu-satunya paket yang layak adalah opsi $49/bln (bernama “Tim Kecil”). Dengan harga sebesar itu, pada dasarnya Anda mendapatkan apa yang Anda dapatkan dari uji coba gratis. Ada opsi $19/bln (dinamakan “Proyek Sampingan”), tetapi tidak memiliki dukungan SSO. Saya akan mempertimbangkannya, tetapi tagihan Heroku untuk Pinecast bahkan tidak mencapai $19/bln dan memiliki satu halaman di mana pengguna dapat memilih fitur-fiturnya tidak sebanding dengan biayanya.

Maaf Canny, itu tidak dimaksudkan.

Rencana B

Alasan utama saya memulai Pinecast lebih dari setahun yang lalu adalah karena saya sangat tidak setuju dengan pilihan buruk yang tersedia di host podcast, saya seorang insinyur, dan insinyur memecahkan masalahnya sendiri. Hal ini tidak akan berbeda.

Pertama, saya membuat model di Django untuk fitur baru:

class UserSuggestion(models.Model):
    created = models.DateTimeField(auto_now_add=True)
    title = models.CharField(max_length=512)
    description = models.TextField(blank=True)
    suggester = models.ForeignKey(User, related_name='user_suggestions')
    STATUS_CHOICES = (
        ('open', ugettext_lazy('Open')),
        ('closed', ugettext_lazy('Closed')),
        ('in progress', ugettext_lazy('In Progress')),
        ('complete', ugettext_lazy('Complete')),
    )
    status = models.CharField(choices=STATUS_CHOICES, default='open', max_length=max(len(k) for k, _ in STATUS_CHOICES))
    def __str__(self):
        return self.title
class UserSuggestionVote(models.Model):
    suggestion = models.ForeignKey(UserSuggestion, related_name='votes')
    voter = models.ForeignKey(User)
class UserSuggestionComment(models.Model):
    created = models.DateTimeField(auto_now_add=True)
    suggestion = models.ForeignKey(UserSuggestion, related_name='comments')
    commenter = models.ForeignKey(User)
    body = models.TextField(blank=True)

Ini sama sekali bukan piala, tapi berfungsi dengan baik.

Selanjutnya, saya menambahkan beberapa rute:

urlpatterns = [
    # ...
    url(r'^userfeedback$', views_usersuggestions.feedback, name='usersuggestions'),
    url(r'^userfeedback/new$', views_usersuggestions.feedback_new, name='usersuggestions_new'),
    url(r'^userfeedback/toggle_vote$', views_usersuggestions.feedback_toggle, name='usersuggestions_toggle'),
    url(r'^userfeedback/delete$', views_usersuggestions.feedback_delete, name='usersuggestions_delete'),
    url(r'^userfeedback/comments/(?P<id>[\w-]+)$', views_usersuggestions.feedback_comments, name='usersuggestions_comments'),
    url(r'^userfeedback/comments/(?P<id>[\w-]+)/new$', views_usersuggestions.feedback_comment, name='usersuggestions_comment'),
]

Saya tidak akan membahas semua tampilannya, tetapi Anda dapat "melihatnya sendiri di Github".

Pertama, saya perlu merender halaman dengan saran. Mudah!

@login_required
@require_GET
def feedback(req):
    suggestions = (
        UserSuggestion.objects
            .exclude(status='complete')
            .annotate(
                Count('votes', distinct=True),
                Count('comments', distinct=True))
            .order_by('-votes__count', '-created')
    )
    user_votes = UserSuggestionVote.objects.filter(
        voter=req.user,
        suggestion__in=suggestions,
    ).values('suggestion_id')
    ctx = {
        'was_added': 'added' in req.GET,
        'did_vote': 'voted' in req.GET,
        'did_delete': 'deleted' in req.GET,
        'suggestions': suggestions,
        'user_votes': {v['suggestion_id'] for v in user_votes},
    }
    return render(req, 'user_suggestions.html', ctx)

Pertama, kita menanyakan objek UserSuggestion. Metode annotate() pada rangkaian kueri Django mengijinkan Anda untuk menerapkan nilai bonus yang berasal dari fungsi seperti COUNT dan SUM. Output dari kueri ini tidak berfungsi dengan baik sama sekali. Singkatnya, penghitungan suara dan komentar bahkan tidak mendekati angka dua, dan menunjukkan kegilaan secara umum. Setelah mengintip dokumen Django, saya menemukan permata ini:

Menggabungkan beberapa agregasi dengan annotate() akan menghasilkan hasil yang salah karena gabungan yang digunakan bukan subkueri

Mengapa mereka bahkan memiliki fitur ini jika itu rusak, benar-benar di luar jangkauan saya. Menambahkan distinct=True ke objek Count akan memperbaiki masalah — tetapi hanya untuk Count. Jika Anda mencoba membuatnya berfungsi dengan fungsi agregat lainnya, semoga berhasil dalam pencarian Anda.

Hal berikutnya yang kami lakukan adalah menanyakan hal-hal yang dipilih pengguna. Kami cukup menanyakan berdasarkan pengguna dan daftar saran yang baru saja kami ambil. Saya menggunakan fungsi values() karena saya tidak ingin secara tidak sengaja menanyakan kembali bidang saran atau pemilih secara tidak sengaja.

Terakhir, saya memasukkan semua itu ke dalam objek konteks dan memasukkannya ke metode render saya.

Saya mempertimbangkan untuk membangun UI dengan React, tapi sejujurnya itu membutuhkan banyak pekerjaan, dan pada akhirnya akan menjadi kurang berguna. Saya memilih template Jinja biasa dengan taburan POJO. Inilah yang menggantikan widget penyematan:

<form class="sidebar" action="{{ url('usersuggestions_new') }}" method="post">
  <h2>{{ _('Suggest a feature') }}</h2>
  <label>
    <span>{{ _('Title') }}</span>
    <input name="title" placeholder="{{ _('Short, descriptive title') }}">
  </label>
  <label>
    <span>{{ _('Details') }}</span>
    <textarea name="description" placeholder="{{ _('Any additional details') }}"></textarea>
  </label>
  <div style="display: flex; max-width: 300px; justify-content: flex-end;">
    <button class="btn-accent">{{ _('Submit') }}</button>
  </div>
  <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
<div>
  {% if was_added %}
    <div class="success">{{ _('Your suggestion was added!') }}</div>
  {% endif %}
  {% if did_vote %}
    <div class="success">{{ _('Your vote was updated!') }}</div>
  {% endif %}
  {% for suggestion in suggestions %}
    <div class="suggestion">
      {% if suggestion.suggester_id != user_id %}
        <form action="{{ url('usersuggestions_toggle') }}?id={{ suggestion.id }}" method="post">
          {% if suggestion.id not in user_votes %}
            <button aria-label="{{ _('Vote') }}" class="vote-btn btn-plain" title="{{ _('Vote') }}">
              <i class="icon icon-angle-up"></i>
            </button>
          {% else %}
            <button aria-label="{{ _('Unvote') }}" class="unvote-btn btn-plain" title="{{ _('Unvote') }}">
              <i class="icon icon-angle-down"></i>
            </button>
          {% endif %}
          <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
        </form>
      {% else %}
        <form action="{{ url('usersuggestions_delete') }}?id={{ suggestion.id }}" method="post">
          <button aria-label="{{ _('Delete') }}" class="unvote-btn btn-plain" title="{{ _('Delete') }}">
            <i class="icon icon-trash-empty"></i>
          </button>
          <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
        </form>
      {% endif %}
      <span class="vote-count">{{ suggestion.votes__count }}</span>
      <div class="title-box">
        <a href="" class="show-comments">
          {{ suggestion.title }}
          {% if suggestion.status != 'open' %}
            <span class="status-tag">{{ suggestion.status }}</span>
          {% endif %}
        </a>
      </div>
      <a class="show-comments comment-count" href="">
        {{ suggestion.comments__count }}
        <i class="icon icon-megaphone"></i>
      </a>
      <div class="comment-container" data-url="{{ url('usersuggestions_comments', id=suggestion.id) }}"></div>
    </div>
  {% endfor %}
</div>

Beberapa catatan:

  • Ya, itu adalah gaya sebaris. Saya tidak memasukkan gaya kecil sekali saja ke dalam stylesheet. Begitulah cara Anda membuat kekacauan.
  • Ya, saya bisa membuat makro untuk masukan CSRF. Saya mungkin harus melakukannya. Tapi itu bukan masalah. Permintaan tarik diterima.
  • Fungsi _() adalah alias dari ugettext(). Fungsi url() adalah pembungkus di sekitar fungsi reverse() bawaan Django, tetapi dengan dukungan untuk **kwargs karena saya bukan orang barbar.

Langkah terakhir adalah menjadikan semuanya interaktif. Saya membuang ini ke dalam tag skrip di bagian bawah halaman:

// Bind the links to open the comments modal
let openComments;
Array.from(document.querySelectorAll('.show-comments')).forEach(cc => {
  cc.addEventListener('click', e => {
    e.preventDefault();
    if (openComments) {
      closeComment();
    }
    openComment(
      cc.parentNode.querySelector('.comment-container') ||
      cc.parentNode.parentNode.querySelector('.comment-container')
    );
  });
});
function openComment(node) {
  openComments = node;
  openComments.classList.add('is-open');
  if (node.hasAttribute('data-is-loaded')) {
    return;
  }
  const xhr = new XMLHttpRequest();
  xhr.open('get', node.getAttribute('data-url'), true);
  xhr.send();
  xhr.onload = () => {
    node.innerHTML = xhr.responseText;
    node.setAttribute('data-is-loaded', 'true');
  };
  xhr.onerror = () => {
    node.innerHTML = '{{ _('Sorry, there was a problem loading the comments.') }}';
  };
}
function closeComment() {
  if (!openComments) {
    return;
  }
  openComments.classList.remove('is-open');
  openComments = null;
}
// Handle the close button on the comments modal
document.body.addEventListener('click', e => {
  if (!e.target.classList.contains('comment-close-btn')) {
    return;
  }
  e.preventDefault();
  closeComment();
});
// Handle comment submission
document.body.addEventListener('submit', e => {
  if (!e.target.classList.contains('comment-submit')) {
    return;
  }
  e.preventDefault();
  const xhr = new XMLHttpRequest();
  xhr.open('post', e.target.action, true);
  xhr.send(new FormData(e.target));
  const parent = e.target.parentNode;
  xhr.onload = () => openComment(parent);
  parent.innerHTML = '';
  parent.removeAttribute('data-is-loaded');
  openComment(parent);
});

Ya, saya menggunakan skrip inline. Ini seperti 70 baris kode JavaScript yang dienkapsulasi sepenuhnya. Dibutuhkan lebih banyak boilerplate untuk mendapatkan komponen React di halaman dibandingkan untuk membangun UI lengkap saya. Lawan aku.

Area yang perlu ditingkatkan

  • Saya menggunakan parameter string kueri untuk Id titik akhir tertentu, dan segmen jalur untuk yang lain. Saya akan mengonversi semuanya menjadi segmen jalur.
  • Modal membutuhkan indikator pemuatan. Saya bisa melakukannya sepenuhnya dengan animasi CSS dan elemen semu, tapi saya malas.
  • Tidak ada penomoran halaman di daftar saran. Django membuatnya mudah untuk membuat paginasi, namun saya malas, dan mudah-mudahan saya tidak pernah mempunyai begitu banyak saran sehingga paginasi diperlukan.
  • Tidak mudah untuk melihat apakah Anda mengirimkan duplikat, tapi itu lebih pada saya daripada pengguna. Saya lebih suka pengguna mengirimkan duplikat yang kemudian saya gabungkan.
  • Tidak ada cara mudah untuk menggabungkan saran. Saya akan menambahkan bidang yang dapat dibatalkan untuk menghubungkan satu saran ke saran lainnya sebagai "digabung", dan menambahkan status untuk saran tersebut.
  • Saya akan menghabiskan beberapa jam lagi untuk memoles UI. Saya menyadari bahwa saya tidak pandai dalam desain visual, dan apa yang saya buat secara estetis kurang optimal.

Itu dia.

Sejujurnya hanya itu yang diperlukan. Mendorongnya ke produksi memakan waktu sekitar setengah jam sehingga saya dapat menelusuri selusin saran, suara, dan komentar dan menambahkannya ke database dengan referensi pengguna yang tepat.

Ketika semuanya sudah dikatakan dan dilakukan, semuanya berjalan sekitar 550 baris kode. Lumayan untuk menghemat $50/bulan.

Beberapa hal yang tidak saya bawa dari Canny:

  • Kemampuan untuk mengunggah gambar untuk postingan dan komentar.
  • Kemampuan bagi pengguna untuk mengedit postingan.
  • Kemampuan untuk menghapus/mengedit komentar.
  • Mencari dan menyusun ulang postingan.
  • Kemampuan untuk melihat siapa lagi yang memilih sebuah ide. Memang benar, ini tidak ada gunanya karena semua nama itu adalah nama palsu yang konyol.
  • Beberapa fitur kecil lainnya yang dihadapi pengguna yang tidak perlu diperhatikan.

Saya tidak akan kehilangan waktu tidur karena hal-hal itu.

Jika Anda menyukai postingan ini dan ingin mencoba Pinecast, Anda dapat mendaftar tanpa memerlukan kartu kredit untuk mencobanya selama yang Anda suka. Jika Anda memutuskan bahwa paket berbayar tepat untuk Anda, Anda dapat menggunakan kode luar biasa untuk mendapatkan diskon 50% pada layanan dua bulan pertama pada paket apa pun hingga akhir Mei.