Repos / pytaku / 2688ad52cc
commit 2688ad52ccc8e3966f49a5914496e7fbaf21b285
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Wed Aug 5 20:25:45 2020 +0700

    show my follows
    
    next: implement reading history/progress

diff --git a/pyproject.toml b/pyproject.toml
index 0ec75e8..0726a55 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "pytaku"
-version = "0.1.3"
+version = "0.1.4"
 description = ""
 authors = ["Bùi Thành Nhân <hi@imnhan.com>"]
 license = "AGPL-3.0-only"
diff --git a/src/pytaku/database/common.py b/src/pytaku/database/common.py
index 6d51c1f..6701431 100644
--- a/src/pytaku/database/common.py
+++ b/src/pytaku/database/common.py
@@ -12,3 +12,9 @@ def get_conn():
         # Apparently you need to enable this pragma _per connection_
         _conn.cursor().execute("PRAGMA foreign_keys = ON;")
     return _conn
+
+
+def run_sql(*args, **kwargs):
+    cursor = get_conn().cursor()
+    results = cursor.execute(*args, **kwargs)
+    return list(results)
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 61b98c0..170d40a 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -17,13 +17,17 @@
 
 from . import mangadex
 from .conf import config
+from .decorators import require_login
 from .persistence import (
+    follow,
+    get_followed_titles,
     get_prev_next_chapters,
     load_chapter,
     load_title,
     register_user,
     save_chapter,
     save_title,
+    unfollow,
     verify_username_password,
 )
 
@@ -35,14 +39,30 @@
 
 @app.route("/")
 def home_view():
+    if session.get("user"):
+        return redirect(url_for("follows_view"))
     return render_template("home.html")
 
 
-@app.route("/follow", methods=["POST"])
-def follow_view():
-    title_id = request.form.get("title_id")
-    site = request.form.get("site", "mangadex")
-    return redirect(url_for(""))
+@app.route("/me", methods=["GET"])
+@require_login
+def follows_view():
+    titles = get_followed_titles(session["user"]["id"])
+    return render_template("follows.html", titles=titles)
+
+
+@app.route("/follow/<site>/<title_id>", methods=["POST"])
+@require_login
+def follow_view(site, title_id):
+    follow(session["user"]["id"], site, title_id)
+    return redirect(url_for("title_view", site=site, title_id=title_id))
+
+
+@app.route("/unfollow/<site>/<title_id>", methods=["POST"])
+@require_login
+def unfollow_view(site, title_id):
+    unfollow(session["user"]["id"], site, title_id)
+    return redirect(url_for("title_view", site=site, title_id=title_id))
 
 
 @app.route("/logout", methods=["POST"])
@@ -107,14 +127,16 @@ def auth_view():
             if not (username and password):
                 message = "Empty field(s) spotted. Protip: spaces don't count."
                 status_code = 400
-            elif not verify_username_password(username, password):
-                message = "Wrong username/password combination."
-                status_code = 400
-            else:  # success!
-                resp = redirect(request.args.get("next", url_for("home_view")))
-                session.permanent = remember
-                session["user"] = {"username": username}
-                return resp
+            else:
+                user_id = verify_username_password(username, password)
+                if user_id is None:
+                    message = "Wrong username/password combination."
+                    status_code = 400
+                else:  # success!
+                    resp = redirect(request.args.get("next", url_for("home_view")))
+                    session.permanent = remember
+                    session["user"] = {"username": username, "id": user_id}
+                    return resp
 
             return (
                 render_template(
@@ -134,7 +156,9 @@ def auth_view():
 
 @app.route("/title/<site>/<title_id>")
 def title_view(site, title_id):
-    title = load_title(site, title_id)
+    user = session.get("user", None)
+    user_id = user["id"] if user else None
+    title = load_title(site, title_id, user_id=user_id)
     if not title:
         print("Getting title", title_id)
         title = get_title(title_id)
diff --git a/src/pytaku/persistence.py b/src/pytaku/persistence.py
index f8c81ee..41e0a00 100644
--- a/src/pytaku/persistence.py
+++ b/src/pytaku/persistence.py
@@ -3,7 +3,7 @@
 import apsw
 import argon2
 
-from .database.common import get_conn
+from .database.common import get_conn, run_sql
 
 
 def save_title(title):
@@ -47,7 +47,7 @@ def save_title(title):
     )
 
 
-def load_title(site, title_id):
+def load_title(site, title_id, user_id=None):
     conn = get_conn()
     result = list(
         conn.cursor().execute(
@@ -67,7 +67,8 @@ def load_title(site, title_id):
         raise Exception(f"Found multiple results for title_id {title_id} on {site}!")
     else:
         title = result[0]
-        return {
+
+        return_val = {
             "id": title[0],
             "name": title[1],
             "site": title[2],
@@ -77,6 +78,15 @@ def load_title(site, title_id):
             "descriptions": json.loads(title[6]),
         }
 
+        if user_id is not None:
+            return_val["is_following"] = bool(
+                run_sql(
+                    "SELECT 1 FROM follow WHERE user_id=? AND site=? AND title_id=?;",
+                    (user_id, site, return_val["id"]),
+                )
+            )
+        return return_val
+
 
 def save_chapter(chapter):
     conn = get_conn()
@@ -186,17 +196,54 @@ def verify_username_password(username, password):
     data = list(
         get_conn()
         .cursor()
-        .execute("SELECT password FROM user WHERE username = ?;", (username,))
+        .execute("SELECT id, password FROM user WHERE username = ?;", (username,))
     )
     if len(data) != 1:
         print(f"User {username} doesn't exist.")
-        return False
+        return None
+
+    user_id = data[0][0]
+    hashed_password = data[0][1]
 
     hasher = argon2.PasswordHasher()
-    hash = data[0][0]
     try:
-        hasher.verify(hash, password)
-        return True
+        hasher.verify(hashed_password, password)
+        return user_id
     except argon2.exceptions.VerifyMismatchError:
         print(f"User {username} exists but password doesn't match.")
-        return False
+        return None
+
+
+def follow(user_id, site, title_id):
+    get_conn().cursor().execute(
+        "INSERT INTO follow (user_id, site, title_id) VALUES (?, ?, ?);",
+        (user_id, site, title_id),
+    )
+
+
+def unfollow(user_id, site, title_id):
+    get_conn().cursor().execute(
+        "DELETE FROM follow WHERE user_id=? AND site=? AND title_id=?;",
+        (user_id, site, title_id),
+    )
+
+
+def get_followed_titles(user_id):
+    titles = run_sql(
+        """
+        SELECT t.id, t.site, t.name, t.cover_ext, t.chapters
+        FROM title t
+          INNER JOIN follow f ON f.title_id = t.id AND f.site = t.site
+          INNER JOIN user u ON u.id = f.user_id
+        WHERE user_id=?;
+        """,
+        (user_id,),
+    )
+    keys = ("id", "site", "name", "cover_ext", "chapters")
+    title_dicts = []
+    for t in titles:
+        title = {key: t[i] for i, key in enumerate(keys)}
+        title["chapters"] = json.loads(title["chapters"])
+        title_dicts.append(title)
+
+    return title_dicts
diff --git a/src/pytaku/templates/base.html b/src/pytaku/templates/base.html
index 6fef79e..a917622 100644
--- a/src/pytaku/templates/base.html
+++ b/src/pytaku/templates/base.html
@@ -1,9 +1,9 @@
 {# vim: ft=htmldjango
 #}
 
-{% macro ibutton(href='', left_icon='', right_icon='', text='', color='red', disabled=False) -%}
+{% macro ibutton(href='', left_icon='', right_icon='', text='', color='red', title='', disabled=False) -%}
 {% set element = 'a' if href else 'button' %}
-<{{ element }} class="{{ color }} button {% if disabled %}disabled{% endif %}"
+<{{ element }} class="{{ color }} button {% if disabled %}disabled{% endif %}" title="{{title}}"
         {% if href %}href="{{ href }}"{% endif %}
         {% if disabled %}disabled{% endif %} >
   {% if left_icon %}
diff --git a/src/pytaku/templates/chapter.html b/src/pytaku/templates/chapter.html
index 04aa572..d506c62 100644
--- a/src/pytaku/templates/chapter.html
+++ b/src/pytaku/templates/chapter.html
@@ -1,8 +1,7 @@
 {% extends 'base.html' %}
 
 {% block title %}
-Ch. {{ num_major }}
-{% if num_minor %}.{{ num_minor }}{% endif %}
+Ch.{{ num_major }}{% if num_minor %}.{{ num_minor }}{% endif %}
 {% if name %} - {{ name }}{% endif %}
 {% endblock %}
 
diff --git a/src/pytaku/templates/follows.html b/src/pytaku/templates/follows.html
new file mode 100644
index 0000000..906df62
--- /dev/null
+++ b/src/pytaku/templates/follows.html
@@ -0,0 +1,89 @@
+{% extends 'base.html' %}
+
+{% block title %}
+Stuff I follow
+{% endblock %}
+
+{% block head %}
+<style>
+.title {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  margin-bottom: var(--body-padding);
+  background-color: #efefef;
+}
+
+.cover {
+  border: 1px solid #777;
+  margin-right: .5rem;
+}
+.cover:hover {
+  box-shadow: 0 0 3px black;
+}
+
+.chapters {
+  padding: .5rem .5rem .5rem 0;
+}
+
+.chapter {
+  display: block;
+  margin-bottom: .5rem;
+  background-color: white;
+  border: 1px solid #999;
+  padding: 6px;
+  border-radius: 5px;
+  text-decoration: none;
+  color: black;
+}
+.chapter:hover {
+  background-color: #eee;
+}
+
+.group {
+  font-size: .9em;
+  background-color: #ddd;
+  border-radius: 3px;
+  white-space: nowrap;
+  padding: 2px 5px;
+}
+
+.more {
+  display: inline-block;
+  font-style: italic;
+}
+</style>
+{% endblock %}
+
+{% block content %}
+
+
+{% for title in titles %}
+{% set title_url = url_for('title_view', site=title['site'], title_id=title['id']) %}
+<div class="title">
+  <div>
+    <a href="{{ title_url }}">
+      <img class="cover"
+           src="https://mangadex.org/images/manga/{{ title['id'] }}.large.{{ title['cover_ext'] }}"
+           alt="{{ title['name'] }}" />
+    </a>
+  </div>
+  <div class="chapters">
+    {% for ch in title['chapters'][:6] %}
+    <a class="chapter" href="{{ url_for('chapter_view', site=title['site'], chapter_id=ch['id']) }}">
+      Chapter {{ ch['num_major'] }}{% if ch['num_minor'] %}.{{ ch['num_minor'] }}{% endif %}
+              {% if ch['volume'] %}Volume {{ ch['volume'] }} {% endif %}
+              {% if ch['name'] %} - {{ ch['name'] }}{% endif %}
+        {% for group in ch['groups'] %}
+        <span class="group">{{ group | truncate(20) }}</span>
+        {% endfor %}
+    </a>
+    {% endfor %}
+    {% if title['chapters']|length > 6 %}
+    <a class="more chapter" href="{{ title_url }}">and more...</a>
+    {% endif %}
+  </div>
+</div>
+{% endfor %}
+
+{% endblock %}
diff --git a/src/pytaku/templates/title.html b/src/pytaku/templates/title.html
index 5c52ef3..3ed3462 100644
--- a/src/pytaku/templates/title.html
+++ b/src/pytaku/templates/title.html
@@ -6,6 +6,10 @@
     width: 400px;
     border: 1px solid black;
   }
+
+  .details > form {
+    display: inline-block;
+  }
 </style>
 {% endblock %}
 
@@ -15,9 +19,25 @@
 
 {% block content %}
 
-<div>
-<h1>{{ name }}</h1>
-{{ ibutton(href='https://mangadex.org/manga/' + id, right_icon='arrow-up-right', text='Source site', color='blue') }}
+<div class="details">
+  <h1>{{ name }}</h1>
+  {% if session['user'] %}
+    {% if is_following %}
+      {% set fview = 'unfollow_view' %}
+      {% set ftext = 'Following' %}
+      {% set fcolor = 'red' %}
+      {% set ftitle = 'Click to unfollow' %}
+    {% else %}
+      {% set fview = 'follow_view' %}
+      {% set ftext = 'Follow' %}
+      {% set fcolor = 'green' %}
+      {% set ftitle = 'Click to follow' %}
+    {% endif %}
+    <form action="{{ url_for(fview, site=site, title_id=id) }}" method="POST">
+      {{ ibutton(left_icon='bookmark', text=ftext, color=fcolor, title=ftitle) }}
+    </form>
+  {% endif %}
+  {{ ibutton(href='https://mangadex.org/manga/' + id, right_icon='arrow-up-right', text='Source site', color='blue', title='Go to source site') }}
 </div>
 
 <img class="cover" src="https://mangadex.org/images/manga/{{ id }}.{{ cover_ext }}" alt="cover" />