├── api ├── __init__.py ├── migrations │ └── __init__.py ├── apps.py ├── urls.py └── views.py ├── books ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0010_remove_badge_image.py │ ├── 0017_auto_20181003_2024.py │ ├── 0025_rename_bookidconversion_bookconversion.py │ ├── 0021_auto_20201215_1716.py │ ├── 0029_alter_book_isbn.py │ ├── 0018_auto_20181106_1048.py │ ├── 0020_userbook_favorite.py │ ├── 0027_alter_search_term.py │ ├── 0028_alter_book_published.py │ ├── 0019_book_imagesize.py │ ├── 0012_auto_20180729_1715.py │ ├── 0007_auto_20180729_1024.py │ ├── 0003_auto_20180729_0935.py │ ├── 0026_alter_bookconversion_googlebooks_id.py │ ├── 0016_booknote_book.py │ ├── 0031_alter_importedbook_book.py │ ├── 0006_auto_20180729_1023.py │ ├── 0008_auto_20180729_1025.py │ ├── 0013_auto_20180729_1728.py │ ├── 0015_auto_20180729_2135.py │ ├── 0032_bookconversion_inserted.py │ ├── 0005_auto_20180729_1023.py │ ├── 0023_auto_20210703_1101.py │ ├── 0033_auto_20210823_1242.py │ ├── 0011_auto_20180729_1659.py │ ├── 0024_bookidconversion.py │ ├── 0014_auto_20180729_2056.py │ ├── 0035_auto_20220528_1033.py │ ├── 0034_auto_20220528_1002.py │ ├── 0004_auto_20180729_0939.py │ ├── 0022_auto_20210703_0953.py │ ├── 0030_importedbook.py │ ├── 0002_userbook.py │ ├── 0001_initial.py │ └── 0009_auto_20180729_1552.py ├── apps.py ├── forms.py ├── urls.py ├── templates │ ├── category.html │ ├── widget.html │ ├── import_books.html │ ├── user.html │ └── book.html ├── tasks.py ├── admin.py ├── googlebooks.py ├── goodreads.py ├── models.py └── views.py ├── goal ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_goal_share.py │ └── 0001_initial.py ├── apps.py ├── urls.py ├── models.py ├── templates │ └── goal.html └── views.py ├── lists ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_auto_20210703_1139.py │ ├── 0003_auto_20210703_1747.py │ ├── 0004_auto_20210703_1819.py │ └── 0001_initial.py ├── apps.py ├── admin.py ├── models.py ├── urls.py ├── templates │ └── lists │ │ ├── userlist_confirm_delete.html │ │ ├── userlist_form.html │ │ ├── userlist_list.html │ │ └── userlist_detail.html ├── mixins.py └── views.py ├── slack ├── __init__.py ├── migrations │ └── __init__.py ├── apps.py ├── urls.py └── views.py ├── pomodoro ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_alter_pomodoro_end.py │ └── 0001_initial.py ├── apps.py ├── admin.py ├── urls.py ├── views.py ├── models.py └── templates │ └── pomodoro │ └── pomodoro.html ├── runtime.txt ├── .flake8 ├── myreadinglist ├── __init__.py ├── static │ ├── img │ │ ├── close.png │ │ ├── loader.gif │ │ ├── favicon.ico │ │ └── book-badge.png │ ├── css │ │ ├── jquery.autocomplete.css │ │ ├── widget.css │ │ └── style.css │ └── js │ │ ├── search.js │ │ ├── script.js │ │ └── blazy.min.js ├── templates │ ├── django_registration │ │ ├── activation_email_subject.txt │ │ ├── activation_failed.html │ │ ├── registration_closed.html │ │ ├── registration_complete.html │ │ ├── activation_complete.html │ │ ├── activation_email_body.txt │ │ └── registration_form.html │ ├── registration │ │ ├── logout.html │ │ ├── password_change_done.html │ │ ├── password_reset_done.html │ │ ├── password_reset_email.html │ │ ├── password_reset_email.txt │ │ ├── password_reset_complete.html │ │ ├── password_reset_form.html │ │ ├── password_change_form.html │ │ ├── password_reset_confirm.html │ │ └── login.html │ ├── index.html │ └── base.html ├── test_settings.py ├── celery.py ├── wsgi.py ├── management │ └── commands │ │ ├── update_categories.py │ │ └── stats.py ├── templatetags │ └── tags.py ├── mail.py ├── urls.py ├── views.py └── settings.py ├── pytest.ini ├── Procfile ├── Dockerfile ├── startup.sh ├── Makefile ├── .env-template ├── .env-compose ├── requirements.in ├── .pre-commit-config.yaml ├── manage.py ├── docker-compose.yml ├── pyproject.toml ├── .github └── workflows │ └── python-app.yml ├── README.md ├── tests ├── test_books.py ├── test_pomodoro.py └── conftest.py ├── requirements.txt └── .gitignore /api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /books/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /goal/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lists/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /slack/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pomodoro/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /books/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /goal/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lists/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.9.1 2 | -------------------------------------------------------------------------------- /slack/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pomodoro/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501 3 | -------------------------------------------------------------------------------- /myreadinglist/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ['celery_app'] 4 | -------------------------------------------------------------------------------- /api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = "api" 6 | -------------------------------------------------------------------------------- /books/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BooksConfig(AppConfig): 5 | name = "books" 6 | -------------------------------------------------------------------------------- /goal/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GoalConfig(AppConfig): 5 | name = "goal" 6 | -------------------------------------------------------------------------------- /lists/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ListsConfig(AppConfig): 5 | name = "lists" 6 | -------------------------------------------------------------------------------- /slack/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SlackConfig(AppConfig): 5 | name = "slack" 6 | -------------------------------------------------------------------------------- /pomodoro/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PomodoroConfig(AppConfig): 5 | name = "pomodoro" 6 | -------------------------------------------------------------------------------- /myreadinglist/static/img/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/pybitesbooks/HEAD/myreadinglist/static/img/close.png -------------------------------------------------------------------------------- /myreadinglist/static/img/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/pybitesbooks/HEAD/myreadinglist/static/img/loader.gif -------------------------------------------------------------------------------- /pomodoro/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from pomodoro.models import Pomodoro 3 | 4 | admin.site.register(Pomodoro) 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = myreadinglist.test_settings 3 | python_files = test_*.py 4 | addopts = -p no:warnings 5 | -------------------------------------------------------------------------------- /myreadinglist/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/pybitesbooks/HEAD/myreadinglist/static/img/favicon.ico -------------------------------------------------------------------------------- /myreadinglist/static/img/book-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/pybitesbooks/HEAD/myreadinglist/static/img/book-badge.png -------------------------------------------------------------------------------- /myreadinglist/templates/django_registration/activation_email_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% trans "Confirm your email address on PyBites Books" %} 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: python manage.py migrate 2 | web: gunicorn myreadinglist.wsgi --log-file - 3 | worker: celery -A myreadinglist worker --concurrency 1 -l info 4 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |
{% trans "Logged out" %}
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /slack/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "slack" 6 | urlpatterns = [ 7 | path("", views.get_book, name="get_book"), 8 | ] 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt /app 6 | 7 | RUN pip install -r requirements.txt 8 | 9 | COPY . /app 10 | 11 | CMD ./startup.sh 12 | -------------------------------------------------------------------------------- /pomodoro/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "pomodoro" 6 | urlpatterns = [ 7 | path("", views.track_pomodoro, name="track_pomodoro"), 8 | ] 9 | -------------------------------------------------------------------------------- /goal/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from goal import views as goal_views 4 | 5 | app_name = "goal" 6 | urlpatterns = [ 7 | path("", goal_views.set_goal, name="set_goal"), 8 | ] 9 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/password_change_done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |{% trans "Password changed" %}
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /myreadinglist/test_settings.py: -------------------------------------------------------------------------------- 1 | from myreadinglist.settings import * # noqa F403 2 | 3 | STATICFILES_STORAGE = "" 4 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 5 | -------------------------------------------------------------------------------- /startup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Simple start up script to use in docker so that we can build the DB and then run the server. 4 | python manage.py migrate 5 | 6 | python manage.py runserver 0.0.0.0:8000 -------------------------------------------------------------------------------- /myreadinglist/templates/django_registration/activation_failed.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 6 |{% trans "Account activation failed" %}
7 | 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /myreadinglist/templates/django_registration/registration_closed.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |{% trans "Registration is currently closed." %}
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /myreadinglist/templates/django_registration/registration_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |{% trans "You are now registered. Activation email sent." %}
6 | {% endblock %} -------------------------------------------------------------------------------- /myreadinglist/templates/registration/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |{% trans "Email with password reset instructions has been sent." %}
6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /lists/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import UserList 4 | 5 | 6 | class UserListAdmin(admin.ModelAdmin): 7 | list_display = ("name", "user") 8 | 9 | 10 | admin.site.register(UserList, UserListAdmin) 11 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/password_reset_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}Reset password at {{ site_name }}{% endblocktrans %}: 3 | {% block reset_link %} 4 | {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uid token %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/password_reset_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}Reset password at {{ site_name }}{% endblocktrans %}: 3 | {% block reset_link %} 4 | {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uid token %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup 2 | setup: 3 | python3.9 -m venv venv && source venv/bin/activate && pip install -r requirements/requirements.txt 4 | 5 | .PHONY: lint 6 | lint: 7 | flake8 --exclude venv 8 | 9 | .PHONY: test 10 | test: 11 | pytest 12 | 13 | .PHONY: ci 14 | ci: lint test 15 | -------------------------------------------------------------------------------- /myreadinglist/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myreadinglist.settings") 6 | 7 | app = Celery("myreadinglist") 8 | app.config_from_object("django.conf:settings", namespace="CELERY") 9 | app.autodiscover_tasks() 10 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/password_reset_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 6 |{% trans "Password reset successfully" %}
7 | 8 | 9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /myreadinglist/templates/django_registration/activation_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |{% trans "Thanks for signing up, your account is now activated. " %}
6 |{% trans "You can now keep track of your reading, enjoy!" %}
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /myreadinglist/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | from whitenoise import WhiteNoise 6 | 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myreadinglist.settings") 8 | 9 | application = get_wsgi_application() 10 | application = WhiteNoise(application) 11 | -------------------------------------------------------------------------------- /myreadinglist/templates/django_registration/activation_email_body.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% trans "Welcome to PyBites Books! Please confirm your account" %}: 3 | 4 | http://{{ site.domain }}{% url 'django_registration_activate' activation_key %} 5 | 6 | {% blocktrans %}Link is valid for {{ expiration_days }} days.{% endblocktrans %} 7 | -------------------------------------------------------------------------------- /.env-template: -------------------------------------------------------------------------------- 1 | SECRET_KEY= 2 | ALLOWED_HOSTS=.localhost, .herokuapp.com 3 | DEBUG=True 4 | DATABASE_URL= 5 | SENDGRID_PASSWORD= 6 | SENDGRID_USERNAME= 7 | SENDGRID_API_KEY= 8 | ADMIN_EMAIL= 9 | FROM_EMAIL= 10 | DOMAIN=http://localhost:8000 11 | ENV=local 12 | SLACK_VERIFICATION_TOKEN= 13 | PYBITES_EMAIL_GROUP= 14 | ADMIN_USERS= 15 | CELERY_BROKER_URL= 16 | -------------------------------------------------------------------------------- /books/migrations/0010_remove_badge_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 15:53 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0009_auto_20180729_1552'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='badge', 15 | name='image', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /.env-compose: -------------------------------------------------------------------------------- 1 | SECRET_KEY=abcdefg 2 | ALLOWED_HOSTS=.localhost 3 | DEBUG=True 4 | POSTGRES_USER=postgres 5 | POSTGRES_PASSWORD=postgres 6 | POSTGRES_DB=myreadinglist 7 | DATABASE_URL=postgres://postgres:postgres@db:5432/myreadinglist 8 | SENDGRID_PASSWORD= 9 | SENDGRID_USERNAME= 10 | SENDGRID_API_KEY= 11 | ADMIN_EMAIL= 12 | FROM_EMAIL= 13 | DOMAIN=http://localhost:8000 14 | ENV=local 15 | SLACK_VERIFICATION_TOKEN=x 16 | PYBITES_EMAIL_GROUP= 17 | -------------------------------------------------------------------------------- /lists/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | 4 | 5 | class UserList(models.Model): 6 | user = models.ForeignKey(User, on_delete=models.CASCADE) 7 | name = models.CharField(max_length=100, unique=True) 8 | inserted = models.DateTimeField(auto_now_add=True) 9 | 10 | def __str__(self): 11 | return self.name 12 | 13 | class Meta: 14 | ordering = ["name"] 15 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | Django==4.2.25 2 | celery==5.2.7 3 | dj-database-url==1.0.0 4 | django-debug-toolbar==3.7.0 5 | django-registration==3.3 6 | gunicorn==23.0.0 7 | pip-tools==6.10.0 8 | psycopg2-binary==2.9.5 9 | pytest==7.2.0 10 | pytest-cov==4.0.0 11 | pytest-django==4.5.2 12 | python-decouple==3.6 13 | redis==4.5.4 14 | requests==2.32.4 15 | sendgrid==6.9.7 16 | sentry-sdk==2.8.0 17 | toml==0.10.2 18 | whitenoise==6.2.0 19 | pre_commit==4.0.0 20 | -------------------------------------------------------------------------------- /lists/migrations/0002_auto_20210703_1139.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.12 on 2021-07-03 11:39 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('lists', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='userlist', 15 | options={'ordering': ['-inserted']}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /lists/migrations/0003_auto_20210703_1747.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.12 on 2021-07-03 17:47 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('lists', '0002_auto_20210703_1139'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='userlist', 15 | options={'ordering': ['name']}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /books/migrations/0017_auto_20181003_2024.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-10-03 20:24 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0016_booknote_book'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='userbook', 15 | options={'ordering': ['-completed', '-id']}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /books/migrations/0025_rename_bookidconversion_bookconversion.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-21 10:22 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0024_bookidconversion'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameModel( 14 | old_name='BookIdConversion', 15 | new_name='BookConversion', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /goal/migrations/0002_goal_share.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2019-06-29 16:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('goal', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='goal', 15 | name='share', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0021_auto_20201215_1716.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-12-15 17:16 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0020_userbook_favorite'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='userbook', 15 | options={'ordering': ['-favorite', '-completed', '-id']}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /pomodoro/migrations/0002_alter_pomodoro_end.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.3 on 2023-06-06 14:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("pomodoro", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="pomodoro", 15 | name="end", 16 | field=models.DateTimeField(), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0029_alter_book_isbn.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-21 15:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0028_alter_book_published'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='book', 15 | name='isbn', 16 | field=models.CharField(max_length=30), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0018_auto_20181106_1048.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-11-06 10:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0017_auto_20181003_2024'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='search', 15 | name='term', 16 | field=models.CharField(max_length=100), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0020_userbook_favorite.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-14 18:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0019_book_imagesize'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='userbook', 15 | name='favorite', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0027_alter_search_term.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-21 12:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0026_alter_bookconversion_googlebooks_id'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='search', 15 | name='term', 16 | field=models.TextField(), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0028_alter_book_published.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-21 13:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0027_alter_search_term'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='book', 15 | name='published', 16 | field=models.CharField(max_length=30), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0019_book_imagesize.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2020-10-10 09:18 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0018_auto_20181106_1048'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='book', 15 | name='imagesize', 16 | field=models.CharField(default='1', max_length=2), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.forms import ModelForm 3 | 4 | from .models import UserBook 5 | 6 | 7 | class DateInput(forms.DateInput): 8 | input_type = "text" 9 | 10 | 11 | class ImportBooksForm(forms.Form): 12 | file = forms.FileField() 13 | 14 | 15 | class UserBookForm(ModelForm): 16 | class Meta: 17 | model = UserBook 18 | fields = ["status", "completed", "booklists"] 19 | widgets = { 20 | "completed": DateInput(), 21 | } 22 | -------------------------------------------------------------------------------- /lists/migrations/0004_auto_20210703_1819.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.12 on 2021-07-03 18:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('lists', '0003_auto_20210703_1747'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='userlist', 15 | name='name', 16 | field=models.CharField(max_length=100, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /lists/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | 6 | urlpatterns = [ 7 | path("", views.UserListListView.as_view(), name="lists-view"), 8 | path("{% trans "Password reset failed" %}
34 | 35 | {% endif %} 36 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /lists/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.12 on 2021-07-03 11:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='UserList', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=100)), 22 | ('inserted', models.DateTimeField(auto_now_add=True)), 23 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /books/migrations/0004_auto_20180729_0939.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 09:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0003_auto_20180729_0935'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='userbook', 15 | old_name='added', 16 | new_name='inserted', 17 | ), 18 | migrations.AddField( 19 | model_name='userbook', 20 | name='completed', 21 | field=models.DateTimeField(blank=True, null=True), 22 | ), 23 | migrations.AlterField( 24 | model_name='userbook', 25 | name='status', 26 | field=models.CharField(choices=[('r', 'I am reading this book'), ('c', 'I have completed this book'), ('t', 'I want to read this book')], default='reading', max_length=1), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /goal/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2019-06-29 16:06 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import goal.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Goal', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('year', models.IntegerField(default=goal.models.current_year)), 23 | ('number_books', models.IntegerField(default=0)), 24 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /pomodoro/views.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | 3 | from django.contrib import messages 4 | from django.contrib.auth.decorators import login_required 5 | from django.shortcuts import render 6 | from django.utils import timezone 7 | 8 | from pomodoro.models import Pomodoro, DEFAULT_POMO_GOAL, DEFAULT_POMO_MIN 9 | 10 | 11 | @login_required 12 | def track_pomodoro(request): 13 | post = request.POST 14 | user = request.user 15 | 16 | if post.get("add"): 17 | Pomodoro.objects.create(user=user, end=timezone.now()) 18 | msg = "Great job, another pomodoro done!" 19 | messages.success(request, msg) 20 | 21 | pomodori = Pomodoro.objects.filter(user=request.user) 22 | 23 | week_stats = Counter(pomo.week for pomo in pomodori) 24 | 25 | context = { 26 | "week_stats": sorted(week_stats.items(), reverse=True), 27 | "week_goal": DEFAULT_POMO_GOAL, 28 | "pomo_minutes": DEFAULT_POMO_MIN, 29 | } 30 | return render(request, "pomodoro/pomodoro.html", context) 31 | -------------------------------------------------------------------------------- /pomodoro/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-10-03 20:24 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Pomodoro', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('minutes', models.IntegerField(default=25)), 22 | ('end', models.DateTimeField(auto_now_add=True)), 23 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | options={ 26 | 'ordering': ['-end'], 27 | }, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /myreadinglist/management/commands/update_categories.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from books.googlebooks import get_book_info_from_api 6 | from books.models import Book 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Add categories to existing books" 11 | 12 | def handle(self, *args, **options): 13 | books = Book.objects.all() 14 | for book in books: 15 | self.stdout.write( 16 | f"Adding categories for book {book.bookid} ({book.title})" 17 | ) 18 | if book.categories.count() > 0: 19 | self.stderr.write("book already has categories, skipping") 20 | continue 21 | try: 22 | get_book_info_from_api(book.bookid) 23 | except KeyError: 24 | self.stderr.write( 25 | "Cannot get book info from Google Books API, skipping" 26 | ) 27 | sleep(0.5) # not sure about API rates 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pybitesbooks" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "celery==5.2.7", 9 | "dj-database-url==1.0.0", 10 | "django-debug-toolbar==3.7.0", 11 | "django-registration==3.3", 12 | "django==4.2.25", 13 | "gunicorn==23.0.0", 14 | "pre-commit==4.0.0", 15 | "psycopg2-binary==2.9.5", 16 | "pytest-cov==4.0.0", 17 | "pytest-django==4.5.2", 18 | "pytest==7.2.0", 19 | "python-decouple==3.6", 20 | "redis==4.5.4", 21 | "requests==2.32.4", 22 | "sendgrid==6.9.7", 23 | "sentry-sdk==2.8.0", 24 | "toml==0.10.2", 25 | "whitenoise==6.2.0", 26 | ] 27 | 28 | [tool.ruff] 29 | # In addition to the standard set of exclusions, omit all tests, plus a specific file. 30 | extend-exclude = [ 31 | "*/migrations/*", 32 | "*/templates/*", 33 | "*/static/*", 34 | "*.html", 35 | "*.css", 36 | "*.js", 37 | "__init__.py", 38 | ] 39 | force-exclude = true 40 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 28 | 29 |{% trans "Forgot your password" %}? {% trans "Reset it" %}
32 |{% trans "New here" %}? {% trans "Create an account" %}
33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /myreadinglist/templates/django_registration/registration_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |You hit the maximum number of reading lists 17 | ({{ max_num_user_lists }}), 18 | delete one or more to create new ones.
19 || List | 27 |By | 28 |Added | 29 |
|---|---|---|
| 35 | 36 | {{ userlist.name }} 37 | 38 | | 39 |40 | 41 | {% if request.user == userlist.user %} 42 | me 43 | {% else %} 44 | {{ userlist.user.username }} 45 | {% endif %} 46 | 47 | | 48 |{{ userlist.inserted|timesince }} ago | 49 |
{{ week_goal }} x {{ pomo_minutes }} min. Pomodori / week!
| Week | 37 |Pomodori | 38 |
|---|---|
| {{ week }} | {% if pomodori >= week_goal %}😄{% else %}😭{% endif %} | 44 |45 | {% filter multiply:pomodori %} 46 | 🍅 47 | {% endfilter %} 48 | {% with week_goal|subtract:pomodori as left %} 49 | {% filter multiply:left %} 50 | 📖 51 | {% endfilter %} 52 | {% endwith %} 53 | | 54 |
');
67 | var searchVal = $("#searchTitles").val();
68 | $("#searchTitles").val('');
69 |
70 | if(formatted.indexOf("notSelectRow") != -1) {
71 | $("#loading").hide();
72 | } else {
73 | var bookid = formatted.replace(/.*id="([^"]+)".*/gi, "$1");
74 | location.href= "/books/" + bookid;
75 | }
76 | });
77 |
78 | $(".js-favorite").change(function () {
79 | book = $(this).attr("bookid");
80 | checked = $(this).attr("checked");
81 | $.ajax({
82 | url: 'favorite/',
83 | data: {
84 | 'book': book,
85 | 'checked': checked
86 | }
87 | });
88 | });
89 |
90 | $(".deleteRow").click(function () {
91 | $(this).parents("tr").remove();
92 | });
93 | });
94 |
95 |
96 | $(function() {
97 | $( "#id_completed" ).datepicker({
98 | dateFormat: "yy-mm-dd"
99 | });
100 | });
101 |
--------------------------------------------------------------------------------
/tests/test_pomodoro.py:
--------------------------------------------------------------------------------
1 | from collections import Counter
2 |
3 | from django.contrib.auth.models import User
4 | from django.urls import reverse
5 | from django.utils import timezone
6 | import pytest
7 |
8 | from pomodoro.models import Pomodoro
9 |
10 |
11 | @pytest.fixture
12 | def pomo_user(db):
13 | return User.objects.create_user(username="testuser", password="12345")
14 |
15 |
16 | @pytest.fixture
17 | def pomodoros(pomo_user, db):
18 | pomodoros = []
19 | for i in range(5):
20 | pomodoro = Pomodoro.objects.create(
21 | user=pomo_user, end=timezone.now(), minutes=25
22 | )
23 | pomodoros.append(pomodoro)
24 | return pomodoros
25 |
26 |
27 | @pytest.fixture
28 | def pomodoros_multi_week(pomo_user, db):
29 | pomodoros = []
30 | for week in range(1, 5): # Creating Pomodoro objects for 4 weeks
31 | for _ in range(5):
32 | end_time = timezone.now() - timezone.timedelta(weeks=week)
33 | pomodoro = Pomodoro.objects.create(user=pomo_user, end=end_time, minutes=25)
34 | pomodoros.append(pomodoro)
35 | return pomodoros
36 |
37 |
38 | def test_track_pomodoro(db, client, pomo_user, pomodoros):
39 | client.login(username="testuser", password="12345")
40 |
41 | response = client.post(reverse("pomodoro:track_pomodoro"), {"add": "True"})
42 |
43 | assert response.status_code == 200
44 |
45 | # check that a new pomodoro was created
46 | assert Pomodoro.objects.filter(user=pomo_user).count() == 6
47 |
48 | # check correct data is passed to the template
49 | pomodori = Pomodoro.objects.filter(user=pomo_user)
50 | week_stats = Counter(pomo.week for pomo in pomodori)
51 | assert response.context["week_stats"] == sorted(week_stats.items(), reverse=True)
52 |
53 |
54 | def test_track_pomodoro_template_output(db, client, pomo_user, pomodoros_multi_week):
55 | client.login(username="testuser", password="12345")
56 |
57 | # 4 weeks in fixture, this post creates 5th week (see below in emoji counts)
58 | response = client.post(reverse("pomodoro:track_pomodoro"), {"add": "True"})
59 |
60 | assert response.status_code == 200
61 |
62 | # check that a new pomodoro was created
63 | expected_pomodori_count = 21
64 | assert Pomodoro.objects.filter(user=pomo_user).count() == expected_pomodori_count
65 |
66 | # check correct data is passed to the template
67 | pomodori = Pomodoro.objects.filter(user=pomo_user)
68 | week_stats = Counter(pomo.week for pomo in pomodori)
69 | assert response.context["week_stats"] == sorted(week_stats.items(), reverse=True)
70 |
71 | # check the content of the response
72 | pomo_goal = 12
73 | for week, count in week_stats.items():
74 | if count >= pomo_goal:
75 | assert f"{week} | 😄" in response.content.decode()
76 | else:
77 | assert f"{week} | 😭" in response.content.decode()
78 |
79 | # count the number of each emoji in the response
80 | # not exactly checking rows, but ok for now
81 | assert response.content.decode().count("🍅") == expected_pomodori_count
82 | expected_toread_count = (5 * pomo_goal) - expected_pomodori_count
83 | assert response.content.decode().count("📖") == expected_toread_count
84 |
--------------------------------------------------------------------------------
/books/googlebooks.py:
--------------------------------------------------------------------------------
1 | import requests
2 | from urllib import parse
3 |
4 | from .models import Category, Book, Search
5 |
6 | BASE_URL = "https://www.googleapis.com/books/v1/volumes"
7 | SEARCH_URL = BASE_URL + "?q={}"
8 | BOOK_URL = BASE_URL + "/{}"
9 | NOT_FOUND = "Not found"
10 | DEFAULT_LANGUAGE = "en"
11 |
12 |
13 | def get_book_info(book_id):
14 | """cache book info in db"""
15 | book = get_book_info_from_cache(book_id)
16 | if book is not None:
17 | return book
18 | return get_book_info_from_api(book_id)
19 |
20 |
21 | def get_book_info_from_cache(book_id):
22 | books = Book.objects.filter(bookid=book_id)
23 | return books[0] if books else None
24 |
25 |
26 | def get_book_info_from_api(book_id):
27 | query = BOOK_URL.format(book_id)
28 | resp = requests.get(query).json()
29 |
30 | volinfo = resp["volumeInfo"]
31 |
32 | bookid = book_id
33 | title = volinfo["title"]
34 | authors = ", ".join(volinfo.get("authors", NOT_FOUND))
35 | publisher = volinfo.get("publisher", NOT_FOUND).strip('"')
36 | published = volinfo.get("publishedDate", NOT_FOUND)
37 |
38 | identifiers = volinfo.get("industryIdentifiers")
39 | isbn = identifiers[-1]["identifier"] if identifiers else NOT_FOUND
40 |
41 | pages = volinfo.get("pageCount", 0)
42 | language = volinfo.get("language", DEFAULT_LANGUAGE)
43 | description = volinfo.get("description", "No description")
44 |
45 | categories = volinfo.get("categories", [])
46 | category_objects = []
47 | for category in categories:
48 | cat, _ = Category.objects.get_or_create(name=category)
49 | category_objects.append(cat)
50 |
51 | if "imageLinks" in volinfo and "small" in volinfo["imageLinks"]:
52 | image_size = parse.parse_qs(
53 | parse.urlparse(volinfo["imageLinks"]["small"]).query
54 | )["zoom"][0]
55 | else:
56 | image_size = "1"
57 |
58 | book, created = Book.objects.get_or_create(bookid=bookid)
59 |
60 | # make sure we don't created duplicates
61 | if created:
62 | book.title = title
63 | book.authors = authors
64 | book.publisher = publisher
65 | book.published = published
66 | book.isbn = isbn
67 | book.pages = pages
68 | book.language = language
69 | book.description = description
70 | book.imagesize = image_size
71 | book.save()
72 |
73 | # if no categories yet add them
74 | if category_objects and book.categories.count() == 0:
75 | book.categories.add(*category_objects)
76 | book.save()
77 |
78 | return book
79 |
80 |
81 | def search_books(term, request=None, lang=None):
82 | """autocomplete = keep this one api live / no cache"""
83 | search = Search(term=term)
84 | if request and request.user.is_authenticated:
85 | search.user = request.user
86 | search.save()
87 |
88 | query = SEARCH_URL.format(term)
89 |
90 | if lang is not None:
91 | query += f"&langRestrict={lang}"
92 |
93 | return requests.get(query).json()
94 |
95 |
96 | if __name__ == "__main__":
97 | term = "python for finance"
98 | for item in search_books(term)["items"]:
99 | try:
100 | id_ = item["id"]
101 | title = item["volumeInfo"]["title"]
102 | except KeyError:
103 | continue
104 | print(id_, title)
105 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # This file was autogenerated by uv via the following command:
2 | # uv pip compile requirements.in --output-file requirements.txt
3 | amqp==5.2.0
4 | # via kombu
5 | asgiref==3.8.1
6 | # via django
7 | attrs==24.2.0
8 | # via pytest
9 | billiard==3.6.4.0
10 | # via celery
11 | build==1.2.2.post1
12 | # via pip-tools
13 | celery==5.2.7
14 | # via -r requirements.in
15 | certifi==2024.8.30
16 | # via
17 | # requests
18 | # sentry-sdk
19 | cfgv==3.4.0
20 | # via pre-commit
21 | charset-normalizer==3.4.0
22 | # via requests
23 | click==8.1.7
24 | # via
25 | # celery
26 | # click-didyoumean
27 | # click-plugins
28 | # click-repl
29 | # pip-tools
30 | click-didyoumean==0.3.1
31 | # via celery
32 | click-plugins==1.1.1
33 | # via celery
34 | click-repl==0.3.0
35 | # via celery
36 | confusable-homoglyphs==3.3.1
37 | # via django-registration
38 | coverage[toml]==7.6.4
39 | # via pytest-cov
40 | distlib==0.3.9
41 | # via virtualenv
42 | dj-database-url==1.0.0
43 | # via -r requirements.in
44 | django==4.2.25
45 | # via
46 | # -r requirements.in
47 | # dj-database-url
48 | # django-debug-toolbar
49 | # django-registration
50 | django-debug-toolbar==3.7.0
51 | # via -r requirements.in
52 | django-registration==3.3
53 | # via -r requirements.in
54 | filelock==3.16.1
55 | # via virtualenv
56 | gunicorn==23.0.0
57 | # via -r requirements.in
58 | identify==2.6.1
59 | # via pre-commit
60 | idna==3.10
61 | # via requests
62 | iniconfig==2.0.0
63 | # via pytest
64 | kombu==5.4.2
65 | # via celery
66 | nodeenv==1.9.1
67 | # via pre-commit
68 | packaging==24.1
69 | # via
70 | # build
71 | # gunicorn
72 | # pytest
73 | pip-tools==6.10.0
74 | # via -r requirements.in
75 | platformdirs==4.3.6
76 | # via virtualenv
77 | pluggy==1.5.0
78 | # via pytest
79 | pre-commit==4.0.0
80 | # via -r requirements.in
81 | prompt-toolkit==3.0.48
82 | # via click-repl
83 | psycopg2-binary==2.9.5
84 | # via -r requirements.in
85 | pyproject-hooks==1.2.0
86 | # via build
87 | pytest==7.2.0
88 | # via
89 | # -r requirements.in
90 | # pytest-cov
91 | # pytest-django
92 | pytest-cov==4.0.0
93 | # via -r requirements.in
94 | pytest-django==4.5.2
95 | # via -r requirements.in
96 | python-decouple==3.6
97 | # via -r requirements.in
98 | python-http-client==3.3.7
99 | # via sendgrid
100 | pytz==2024.2
101 | # via celery
102 | pyyaml==6.0.2
103 | # via pre-commit
104 | redis==4.5.4
105 | # via -r requirements.in
106 | requests==2.32.0
107 | # via -r requirements.in
108 | sendgrid==6.9.7
109 | # via -r requirements.in
110 | sentry-sdk==2.8.0
111 | # via -r requirements.in
112 | sqlparse==0.5.1
113 | # via
114 | # django
115 | # django-debug-toolbar
116 | starkbank-ecdsa==2.2.0
117 | # via sendgrid
118 | toml==0.10.2
119 | # via -r requirements.in
120 | tzdata==2024.2
121 | # via kombu
122 | urllib3==2.5.0
123 | # via
124 | # requests
125 | # sentry-sdk
126 | vine==5.1.0
127 | # via
128 | # amqp
129 | # celery
130 | # kombu
131 | virtualenv==20.27.1
132 | # via pre-commit
133 | wcwidth==0.2.13
134 | # via prompt-toolkit
135 | wheel==0.44.0
136 | # via pip-tools
137 | whitenoise==6.2.0
138 | # via -r requirements.in
139 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | .idea/
161 |
--------------------------------------------------------------------------------
/books/goodreads.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import csv
3 | from enum import Enum
4 | from io import StringIO
5 | from time import sleep
6 |
7 | from django.contrib.auth.models import User
8 | import pytz
9 |
10 | from .googlebooks import (
11 | get_book_info_from_cache,
12 | get_book_info_from_api,
13 | search_books,
14 | DEFAULT_LANGUAGE,
15 | )
16 | from .models import UserBook, BookConversion, ImportedBook
17 |
18 | GOOGLE_TO_GOODREADS_READ_STATUSES = {
19 | "c": "read",
20 | "r": "currently-reading",
21 | "t": "to-read",
22 | }
23 |
24 |
25 | class BookImportStatus(Enum):
26 | TO_BE_ADDED = 1
27 | ALREADY_ADDED = 2
28 | COULD_NOT_FIND = 3
29 |
30 |
31 | def _cache_book_for_row(row, username, sleep_seconds):
32 | user = User.objects.get(username=username)
33 | title = row["Title"]
34 |
35 | # if import title is cached return it (this is done in
36 | # the view but this instance is useful if user uploads
37 | # a new csv file with only a few new titles)
38 | try:
39 | imported_book = ImportedBook.objects.get(title=title, user=user)
40 | return imported_book
41 | except ImportedBook.DoesNotExist:
42 | pass
43 |
44 | author = row["Author"]
45 | reading_status = row["Exclusive Shelf"]
46 | date_completed = datetime.strptime(
47 | row["Date Read"] or row["Date Added"], "%Y/%m/%d"
48 | )
49 |
50 | goodreads_id = row["Book Id"]
51 | book_status = BookImportStatus.TO_BE_ADDED
52 | book = None
53 |
54 | book_mapping, _ = BookConversion.objects.get_or_create(goodreads_id=goodreads_id)
55 |
56 | if not book_mapping.googlebooks_id:
57 | # only query API for new book mappings
58 | term = f"{title} {author}"
59 | # make sure we don't hit Google Books API rate limits
60 | sleep(sleep_seconds)
61 | google_book_response = search_books(term, lang=DEFAULT_LANGUAGE)
62 | try:
63 | bookid = google_book_response["items"][0]["id"]
64 | book_mapping.googlebooks_id = bookid
65 | book_mapping.save()
66 | except Exception as exc:
67 | print(f"Could not find google book for goodreads id {goodreads_id}")
68 | print("Exception:", exc)
69 | print("Google api response:", google_book_response)
70 |
71 | if book_mapping.googlebooks_id:
72 | bookid = book_mapping.googlebooks_id
73 | book = get_book_info_from_cache(bookid)
74 | if book is None:
75 | sleep(sleep_seconds)
76 | try:
77 | book = get_book_info_from_api(bookid)
78 | except Exception as exc:
79 | print(f"Could not retrieve info for google book id {bookid}")
80 | print("Exception:", exc)
81 | book = None
82 | book_status = BookImportStatus.COULD_NOT_FIND
83 | else:
84 | book_status = BookImportStatus.COULD_NOT_FIND
85 |
86 | if book is not None:
87 | user_books = UserBook.objects.filter(user=user, book=book)
88 | if user_books.count() > 0:
89 | book_status = BookImportStatus.ALREADY_ADDED
90 |
91 | imported_book = ImportedBook.objects.create(
92 | title=title,
93 | book=book,
94 | reading_status=reading_status,
95 | date_completed=pytz.utc.localize(date_completed),
96 | book_status=book_status.name,
97 | user=user,
98 | )
99 |
100 | return imported_book
101 |
102 |
103 | def convert_goodreads_to_google_books(file_content, username, sleep_seconds=0):
104 | # remove read().decode('utf-8') as it's not serializable
105 | reader = csv.DictReader(StringIO(file_content), delimiter=",")
106 |
107 | imported_books = []
108 | for row in reader:
109 | book = _cache_book_for_row(row, username, sleep_seconds)
110 | imported_books.append(book)
111 |
112 | return imported_books
113 |
--------------------------------------------------------------------------------
/slack/views.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from decouple import config
4 | from django.contrib.humanize.templatetags.humanize import naturalday
5 | from django.http import Http404, HttpResponse
6 | from django.views.decorators.csrf import csrf_exempt
7 |
8 | from api.views import get_users, get_user_last_book, get_random_book
9 |
10 | HOME = "https://pybitesbooks.com"
11 | BOOK_THUMB = "https://books.google.com/books?id={bookid}&printsec=frontcover&img=1&zoom={imagesize}&source=gbs_gdata" # noqa
12 | SLACK_TOKEN = config("SLACK_VERIFICATION_TOKEN", default="")
13 | HELP_TEXT = (
14 | "```"
15 | "/book help -> print this help message\n"
16 | "/book -> get a random book added to PyBites Books\n" # noqa E501
17 | '/book grep -> get a random book filtered on "grep" (if added)\n' # noqa E501
18 | "/book user -> get usernames and their most recent book read\n"
19 | '/book user username -> get the last book "username" added\n'
20 | "```"
21 | )
22 | COMMANDS = dict(
23 | rand=get_random_book,
24 | grep=get_random_book,
25 | user=get_users,
26 | username=get_user_last_book,
27 | )
28 |
29 |
30 | def _validate_token(request):
31 | token = request.get("token")
32 | if token is None or token != SLACK_TOKEN:
33 | raise Http404
34 |
35 |
36 | def _create_user_output(user_books):
37 | users = []
38 | for user, books in user_books.items():
39 | if books:
40 | last_book = sorted(books, key=lambda x: x.completed)[-1]
41 | else:
42 | last_book = "no books read yet"
43 | users.append((user, last_book))
44 |
45 | col1, col2 = "User", f"Last read -> {HOME}"
46 | msg = [f"{col1:<19}: {col2}"]
47 |
48 | for user, last_book in sorted(users, key=lambda x: x[1].completed, reverse=True):
49 | title = last_book.book.title
50 | title = len(title) > 32 and title[:32] + " ..." or f"{title:<36}"
51 | msg.append(f"{user:<19}: {title} ({naturalday(last_book.completed)})")
52 | return "```" + "\n".join(msg) + "```"
53 |
54 |
55 | def _get_attachment(msg, book=None):
56 | if book is None:
57 | return {"text": msg, "color": "#3AA3E3"}
58 | else:
59 | return {
60 | "title": book["title"],
61 | "title_link": book["url"],
62 | "image_url": BOOK_THUMB.format(
63 | bookid=book["bookid"], imagesize=book["imagesize"]
64 | ),
65 | "text": msg,
66 | "color": "#3AA3E3",
67 | }
68 |
69 |
70 | @csrf_exempt
71 | def get_book(request):
72 | request = request.POST
73 | _validate_token(request)
74 |
75 | headline, msg = None, None
76 |
77 | text = request.get("text")
78 | text = text.split()
79 | single_word_cmd = text and len(text) == 1 and text[0]
80 | book = None
81 | if single_word_cmd == "help":
82 | headline = "Command syntax:"
83 | msg = HELP_TEXT
84 |
85 | elif single_word_cmd == "user":
86 | headline = "PyBites Readers:"
87 | user_books = COMMANDS["user"]()
88 | msg = _create_user_output(user_books)
89 |
90 | else:
91 | # 0 or multiple words
92 | if len(text) == 0:
93 | book = COMMANDS["rand"]()
94 | headline = "Here is a random title for your reading list:"
95 |
96 | elif len(text) == 2 and text[0] == "user":
97 | username = text[-1]
98 | book = COMMANDS["username"](username)
99 | headline = f"Last book _{username}_ added:"
100 |
101 | else:
102 | grep = " ".join(text)
103 | book = COMMANDS["grep"](grep)
104 | headline = f'Here is a "{grep}" title for your reading list:'
105 |
106 | msg = f"Author: _{book['authors']}_ (pages: {book['pages']})"
107 |
108 | data = {
109 | "response_type": "in_channel",
110 | "text": headline,
111 | "attachments": [_get_attachment(msg, book)],
112 | }
113 |
114 | return HttpResponse(json.dumps(data), content_type="application/json")
115 |
--------------------------------------------------------------------------------
/myreadinglist/management/commands/stats.py:
--------------------------------------------------------------------------------
1 | from collections import defaultdict
2 | from datetime import date
3 | from decouple import config, Csv
4 |
5 | from django.conf import settings
6 | from django.core.management.base import BaseCommand
7 | from django.contrib.auth.models import User
8 | from django.db.models.functions import Lower
9 | from django.db.models import Q
10 | from django.utils import timezone
11 |
12 | from myreadinglist.mail import send_email
13 | from books.models import UserBook
14 | from goal.models import Goal, current_year
15 |
16 | PYBITES_EMAIL_GROUP = config("PYBITES_EMAIL_GROUP", cast=Csv())
17 | FRIDAY = 4
18 | ONE_WEEK_AGO = timezone.now() - timezone.timedelta(days=7)
19 | COMPLETED = "c"
20 | SUBJECT = "Weekly PyBites Books stats"
21 | MSG = """
22 | Usage stats:
23 | - {num_total_users} total users ({num_new_users} new users joined last week).
24 | - {num_books_clicked} books were clicked.
25 | - {num_books_completed} books were completed (= {num_books_completed_pages} pages read).
26 |
27 | New user profiles:
28 | {new_user_profiles}
29 |
30 | What books were completed last week? {books_completed}
31 |
32 | Most ambitious readers (# books to read goal this year):
33 | {goals}
34 |
35 | Update your reading here:
36 | https://pybitesbooks.com
37 | """
38 | PROFILE_PAGE = settings.DOMAIN + "/users/{username}"
39 | THIS_YEAR = current_year()
40 |
41 |
42 | class Command(BaseCommand):
43 | help = "email app stats"
44 |
45 | def add_arguments(self, parser):
46 | parser.add_argument(
47 | "--now",
48 | action="store_true",
49 | dest="now",
50 | help="flag to show stats now = bypass day of the week check",
51 | )
52 |
53 | def handle(self, *args, **options):
54 | run_now = options["now"]
55 |
56 | # seems heroku does not support weekly cronjobs
57 | if not run_now and date.today().weekday() != FRIDAY:
58 | return
59 |
60 | all_users = User.objects.all()
61 | new_users = all_users.filter(date_joined__gte=ONE_WEEK_AGO)
62 | num_new_users = new_users.count()
63 |
64 | num_books_clicked = UserBook.objects.filter(inserted__gte=ONE_WEEK_AGO).count()
65 |
66 | books_read_last_week = (
67 | UserBook.objects.select_related("book", "user")
68 | .filter(Q(completed__gte=ONE_WEEK_AGO) & Q(status=COMPLETED))
69 | .order_by(Lower("user__username"))
70 | )
71 |
72 | num_books_completed = books_read_last_week.count()
73 | num_books_completed_pages = sum(
74 | int(ub.book.pages) for ub in books_read_last_week
75 | )
76 | new_user_profiles = "| PyBites Books | 42 | 43 |44 | 66 | | 67 |
83 | {% if 'safe' in message.tags %}{{ message|safe }}{% else %}{{ message }}{% endif %} 84 |
85 | {% endfor %} 86 |
80 |
81 | {{ user_stats.num_books_done }}
82 | No books read yet for this year's challenge.
127 | {% endif %} 128 |Embed the books you have read on your website or blog (example). Use this code:
164 |<iframe src="https://pybitesbooks.com/widget/{{ username }}" frameborder="0" scrolling="no"></iframe>
165 | | Author(s) | 46 |{{ book.authors }} | 47 |
| Publisher | 50 |{{ book.publisher }} | 51 |
| Published | 54 |{{ book.published }} | 55 |
| ISBN | 58 |{{ book.isbn }} | 59 |
| Page Count | {{ book.pages }} | 62 |
| Language | 65 |{{ book.language }} | 66 |
| 69 | {% if user.is_authenticated %} 70 | 111 | 112 | {% else %} 113 | 114 | Add Book 115 | 116 | {% endif %} 117 | | 118 | 119 ||
{{ note.user.username|slice:":2"|upper }} {{ note.user.username }} added a {{ note.type_note_label }} {{ note.inserted|timesince }} ago:
229 | {% if note.quote %} 230 |{{ note.description }}231 | {% else %} 232 |
{{ note.description }}
233 | {% endif %} 234 | 235 | {% endif %} 236 |