Repository: internetarchive/openlibrary

Issue:
Create UI for viewing yearly reading goal progress



Discussion:

Resolving patch:
diff --git a/openlibrary/core/bookshelves_events.py b/openlibrary/core/bookshelves_events.py
--- a/openlibrary/core/bookshelves_events.py
+++ b/openlibrary/core/bookshelves_events.py
@@ -84,7 +84,46 @@ def select_by_book_user_and_type(cls, username, work_id, edition_id, event_type)
             event_type=$event_type
         """
 
-        return list(db.select(cls.TABLENAME, where=where, vars=data))
+        return list(oldb.select(cls.TABLENAME, where=where, vars=data))
+
+    @classmethod
+    def select_by_user_type_and_year(cls, username, event_type, year):
+        oldb = db.get_db()
+
+        data = {
+            'username': username,
+            'event_type': event_type,
+            'event_date': f'{year}%',
+        }
+
+        where = """
+            username=$username AND
+            event_type=$event_type AND
+            event_date LIKE $event_date
+        """
+
+        return list(oldb.select(cls.TABLENAME, where=where, vars=data))
+
+    @classmethod
+    def select_distinct_by_user_type_and_year(cls, username, event_type, year):
+        """Returns a list of the most recent check-in events, with no repeating
+        work IDs.  Useful for calculating one's yearly reading goal progress.
+        """
+        oldb = db.get_db()
+
+        data = {
+            'username': username,
+            'event_type': event_type,
+            'event_date': f'{year}%',
+        }
+        query = (
+            f"select distinct on (work_id) work_id, * from {cls.TABLENAME} "
+            "where username=$username and event_type=$event_type and "
+            "event_date LIKE $event_date "
+            "order by work_id, updated desc"
+        )
+
+        return list(oldb.query(query, vars=data))
 
     @classmethod
     def exists(cls, pid) -> bool:
diff --git a/openlibrary/core/yearly_reading_goals.py b/openlibrary/core/yearly_reading_goals.py
--- a/openlibrary/core/yearly_reading_goals.py
+++ b/openlibrary/core/yearly_reading_goals.py
@@ -11,11 +11,21 @@ class YearlyReadingGoals:
     def create(cls, username: str, year: int, target: int):
         oldb = db.get_db()
 
-        # TODO: Fetch number of books already read this year
-
         return oldb.insert(cls.TABLENAME, username=username, year=year, target=target)
 
     # Read methods:
+    @classmethod
+    def select_by_username(cls, username: str):
+        oldb = db.get_db()
+
+        where = 'username=$username'
+        order = 'year ASC'
+        data = {
+            'username': username,
+        }
+
+        return list(oldb.select(cls.TABLENAME, where=where, order=order, vars=data))
+
     @classmethod
     def select_by_username_and_year(cls, username: str, year: int):
         oldb = db.get_db()
diff --git a/openlibrary/plugins/upstream/checkins.py b/openlibrary/plugins/upstream/checkins.py
--- a/openlibrary/plugins/upstream/checkins.py
+++ b/openlibrary/plugins/upstream/checkins.py
@@ -3,12 +3,16 @@
 import json
 import web
 
+from datetime import datetime
+from math import floor
 from typing import Optional
 
 from infogami.utils import delegate
 from infogami.utils.view import public
 
 from openlibrary.accounts import get_current_user
+from openlibrary.app import render_template
+from openlibrary.core.yearly_reading_goals import YearlyReadingGoals
 from openlibrary.utils import extract_numeric_id_from_olid
 from openlibrary.core.bookshelves_events import BookshelfEvent, BookshelvesEvents
 from openlibrary.utils.decorators import authorized_for
@@ -144,5 +148,99 @@ def DELETE(self, check_in_id):
         return web.ok()
 
 
+class yearly_reading_goal_json(delegate.page):
+    path = '/reading-goal'
+    encoding = 'json'
+
+    @authorized_for('/usergroup/beta-testers')
+    def GET(self):
+        i = web.input(year=None)
+
+        user = get_current_user()
+        username = user['key'].split('/')[-1]
+
+        if i.year:
+            results = [
+                {'year': i.year, 'goal': record.target, 'progress': record.current}
+                for record in YearlyReadingGoals.select_by_username_and_year(
+                    username, i.year
+                )
+            ]
+        else:
+            results = [
+                {'year': record.year, 'goal': record.target, 'progress': record.current}
+                for record in YearlyReadingGoals.select_by_username(username)
+            ]
+
+        return delegate.RawText(json.dumps({'status': 'ok', 'goal': results}))
+
+    @authorized_for('/usergroup/beta-testers')
+    def POST(self):
+        i = web.input(goal=0, year=None, is_update=None)
+
+        goal = int(i.goal)
+
+        if not goal or goal < 0:
+            raise web.badrequest('Reading goal must be a positive integer')
+
+        if i.is_update and not i.year:
+            raise web.badrequest('Year required to update reading goals')
+
+        user = get_current_user()
+        username = user['key'].split('/')[-1]
+
+        current_year = i.year or datetime.now().year
+
+        if i.is_update:
+            YearlyReadingGoals.update_target(username, i.year, goal)
+        else:
+            YearlyReadingGoals.create(username, current_year, goal)
+
+        return delegate.RawText(json.dumps({'status': 'ok'}))
+
+
+@public
+def get_reading_goals(year=None):
+    user = get_current_user()
+    username = user['key'].split('/')[-1]
+
+    if not year:
+        year = datetime.now().year
+
+    if not (data := YearlyReadingGoals.select_by_username_and_year(username, year)):
+        return None
+
+    books_read = BookshelvesEvents.select_distinct_by_user_type_and_year(
+        username, BookshelfEvent.FINISH, year
+    )
+    read_count = len(books_read)
+    result = YearlyGoal(data[0].year, data[0].target, read_count)
+
+    return result
+
+
+class YearlyGoal:
+    def __init__(self, year, goal, books_read):
+        self.year = year
+        self.goal = goal
+        self.books_read = books_read
+        self.progress = floor((books_read / goal) * 100)
+
+    @classmethod
+    def calc_progress(cls, books_read, goal):
+        return floor((books_read / goal) * 100)
+
+
+class ui_partials(delegate.page):
+    path = '/reading-goal/partials'
+
+    def GET(self):
+        i = web.input(year=None)
+        year = i.year or datetime.now().year
+        goal = get_reading_goals(year=year)
+        component = render_template('check_ins/reading_goal_progress', [goal])
+        return delegate.RawText(component)
+
+
 def setup():
     pass


Test patch: