├── 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 |

{% trans "Log in" %}

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("", views.UserListDetailView.as_view(), name="lists-detail"), 9 | path("add/", views.UserListCreateView.as_view(), name="lists-add"), 10 | path("/", views.UserListUpdateView.as_view(), name="lists-update"), 11 | path("/delete/", views.UserListDeleteView.as_view(), name="lists-delete"), 12 | ] 13 | -------------------------------------------------------------------------------- /books/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from books import views as book_views 4 | 5 | app_name = "books" 6 | urlpatterns = [ 7 | path("import_books/preview", book_views.import_books, name="import_books"), 8 | path("import_books", book_views.import_books, name="import_books"), 9 | path("", book_views.book_page, name="book_page"), 10 | path( 11 | "categories/", 12 | book_views.books_per_category, 13 | name="books_per_category", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /books/migrations/0012_auto_20180729_1715.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 17:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0011_auto_20180729_1659'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='booknote', 15 | name='type_note', 16 | field=models.CharField(choices=[('q', 'Quote'), ('n', 'Note')], default='n', max_length=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /lists/templates/lists/userlist_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | 5 |

Confirm Delete User List

6 | 7 |
8 | {% csrf_token %} 9 | {{ userlist.name }} 10 |

Are you sure you want to delete this list?

11 | 12 | Go Back 13 |
14 | 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /books/migrations/0007_auto_20180729_1024.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 10:24 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('books', '0006_auto_20180729_1023'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='userbook', 16 | name='completed', 17 | field=models.DateTimeField(default=django.utils.timezone.now), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: check-json 6 | - id: check-yaml 7 | - id: debug-statements 8 | - id: detect-private-key 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | # Ruff version. 11 | rev: v0.6.9 12 | hooks: 13 | # Run the linter. 14 | - id: ruff 15 | types_or: [ python, pyi ] 16 | args: [ --fix ] 17 | # Run the formatter. 18 | - id: ruff-format 19 | types_or: [ python, pyi ] 20 | -------------------------------------------------------------------------------- /books/migrations/0003_auto_20180729_0935.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 09:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0002_userbook'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='userbook', 15 | name='status', 16 | field=models.CharField(choices=[('r', 'reading'), ('c', 'completed'), ('t', 'to read')], default='reading', max_length=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0026_alter_bookconversion_googlebooks_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-21 10:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0025_rename_bookidconversion_bookconversion'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='bookconversion', 15 | name='googlebooks_id', 16 | field=models.CharField(blank=True, max_length=20, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /myreadinglist/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | 4 | {% block og_url %}{{ request.path }}{% endblock %} 5 | 6 | {% block content %} 7 | 8 |
9 |
10 |
11 | {% for ub in user_books %} 12 | {{ ub.book.title }} 13 | {% endfor %} 14 |
15 |
16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /books/migrations/0016_booknote_book.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 21:44 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('books', '0015_auto_20180729_2135'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='booknote', 16 | name='book', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='books.Book'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /books/migrations/0031_alter_importedbook_book.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-22 05:53 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('books', '0030_importedbook'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='importedbook', 16 | name='book', 17 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='books.book'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myreadinglist.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "api" 6 | urlpatterns = [ 7 | path("users", views.user_books, name="user_books"), 8 | path("users/", views.user_books, name="user_books"), 9 | path("random", views.random_book, name="random_book"), 10 | path("random/", views.random_book, name="random_book"), 11 | path("books/", views.get_bookid, name="get_bookid"), 12 | path("lists/", views.get_book_list, name="get_book_list"), 13 | path("stats/", views.get_book_stats, name="get_book_stats"), 14 | ] 15 | -------------------------------------------------------------------------------- /books/migrations/0006_auto_20180729_1023.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 10:23 2 | 3 | import datetime 4 | from django.db import migrations, models 5 | from django.utils.timezone import utc 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('books', '0005_auto_20180729_1023'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='userbook', 17 | name='completed', 18 | field=models.DateTimeField(default=datetime.datetime(2018, 7, 29, 10, 23, 35, 379553, tzinfo=utc)), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /books/migrations/0008_auto_20180729_1025.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 10:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0007_auto_20180729_1024'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='userbook', 15 | name='status', 16 | field=models.CharField(choices=[('r', 'I am reading this book'), ('c', 'I have completed this book'), ('t', 'I want to read this book')], default='c', max_length=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /books/migrations/0013_auto_20180729_1728.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 17:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0012_auto_20180729_1715'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='booknote', 15 | name='public', 16 | ), 17 | migrations.AddField( 18 | model_name='booknote', 19 | name='private', 20 | field=models.BooleanField(default=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /books/migrations/0015_auto_20180729_2135.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 21:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0014_auto_20180729_2056'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='booknote', 15 | name='private', 16 | ), 17 | migrations.AddField( 18 | model_name='booknote', 19 | name='public', 20 | field=models.BooleanField(default=False), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/password_reset_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |
6 | {% csrf_token %} 7 | 8 |
9 | {% for error in form.non_field_errors %} 10 | {{error}} 11 | {% endfor %} 12 |
13 | 14 |
15 | {{ form.email.label }} 16 | {{ form.email }} 17 | {{ form.email.errors }} 18 |
19 | 20 | 21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /books/migrations/0032_bookconversion_inserted.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-23 08:07 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('books', '0031_alter_importedbook_book'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='bookconversion', 16 | name='inserted', 17 | field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /books/migrations/0005_auto_20180729_1023.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 10:23 2 | 3 | import datetime 4 | from django.db import migrations, models 5 | from django.utils.timezone import utc 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('books', '0004_auto_20180729_0939'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='userbook', 17 | name='completed', 18 | field=models.DateTimeField(blank=True, default=datetime.datetime(2018, 7, 29, 10, 23, 17, 982140, tzinfo=utc), null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /books/migrations/0023_auto_20210703_1101.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.12 on 2021-07-03 11:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('lists', '0001_initial'), 10 | ('books', '0022_auto_20210703_0953'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='userbook', 16 | name='booklists', 17 | field=models.ManyToManyField(related_name='booklists', to='lists.UserList'), 18 | ), 19 | migrations.DeleteModel( 20 | name='UserList', 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /books/migrations/0033_auto_20210823_1242.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-23 12:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0032_bookconversion_inserted'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='book', 15 | name='title', 16 | field=models.CharField(max_length=300), 17 | ), 18 | migrations.AlterField( 19 | model_name='importedbook', 20 | name='title', 21 | field=models.TextField(), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /goal/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.contrib.auth.models import User 4 | from django.db import models 5 | 6 | 7 | def current_year(): 8 | return date.today().year 9 | 10 | 11 | class Goal(models.Model): 12 | user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) 13 | year = models.IntegerField(default=current_year) 14 | number_books = models.IntegerField(default=0) 15 | share = models.BooleanField(default=False) 16 | 17 | def __str__(self): 18 | return ( 19 | f"{self.user} => {self.number_books} " 20 | f"(in {self.year} / shared: {self.share})" 21 | ) 22 | -------------------------------------------------------------------------------- /books/migrations/0011_auto_20180729_1659.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 16:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0010_remove_badge_image'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='booknote', 15 | old_name='note', 16 | new_name='description', 17 | ), 18 | migrations.AddField( 19 | model_name='booknote', 20 | name='type_note', 21 | field=models.CharField(choices=[('q', 'Quote'), ('n', 'Note')], default='q', max_length=1), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /books/migrations/0024_bookidconversion.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-21 10:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0023_auto_20210703_1101'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='BookIdConversion', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('goodreads_id', models.CharField(max_length=20)), 18 | ('googlebooks_id', models.CharField(max_length=20)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /books/migrations/0014_auto_20180729_2056.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 20:56 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('books', '0013_auto_20180729_1728'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='booknote', 16 | name='book', 17 | ), 18 | migrations.AddField( 19 | model_name='booknote', 20 | name='userbook', 21 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='books.UserBook'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | web: 4 | depends_on: 5 | - db 6 | build: . 7 | ports: 8 | - "8000:8000" 9 | env_file: 10 | - .env-compose 11 | 12 | db: 13 | # Stolen from https://medium.com/analytics-vidhya/getting-started-with-postgresql-using-docker-compose-34d6b808c47c 14 | image: "postgres" # use latest official postgres version 15 | env_file: 16 | - .env-compose 17 | volumes: 18 | - database-data:/var/lib/postgresql/data/ # persist data even if container shuts down 19 | ports: 20 | - 5432:5432 21 | 22 | volumes: 23 | database-data: # named volumes can be managed easier using docker-compose 24 | 25 | 26 | -------------------------------------------------------------------------------- /lists/mixins.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.http import HttpResponseRedirect 3 | from django.urls import reverse 4 | 5 | 6 | class OwnerRequiredMixin: 7 | """ 8 | Making sure that only owners can update their objects. 9 | See https://stackoverflow.com/a/18176411 10 | """ 11 | 12 | def dispatch(self, request, *args, **kwargs): 13 | if not self.request.user.is_authenticated: 14 | return HttpResponseRedirect(reverse("login")) 15 | obj = self.get_object() 16 | if obj.user != self.request.user: 17 | messages.error(self.request, "You are not the owner of this list") 18 | return HttpResponseRedirect(reverse("lists-view")) 19 | return super().dispatch(request, *args, **kwargs) 20 | -------------------------------------------------------------------------------- /books/migrations/0035_auto_20220528_1033.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-05-28 10:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0034_auto_20220528_1002'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='category', 15 | options={'verbose_name_plural': 'categories'}, 16 | ), 17 | migrations.AlterModelOptions( 18 | name='search', 19 | options={'verbose_name_plural': 'searches'}, 20 | ), 21 | migrations.AlterField( 22 | model_name='book', 23 | name='bookid', 24 | field=models.CharField(max_length=20, unique=True), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /books/migrations/0034_auto_20220528_1002.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-05-28 10:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0033_auto_20210823_1242'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Category', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('name', models.CharField(max_length=128)), 18 | ], 19 | ), 20 | migrations.AddField( 21 | model_name='book', 22 | name='categories', 23 | field=models.ManyToManyField(related_name='categories', to='books.Category'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/password_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |
6 | {% csrf_token %} 7 | 8 |
9 | {% for error in form.non_field_errors %} 10 | {{error}} 11 | {% endfor %} 12 |
13 | 14 | 15 |
16 | {{ form.new_password1.label }} 17 | {{ form.new_password1 }} 18 | {{ form.new_password1.errors }} 19 |
20 |
21 | {{ form.new_password2.label }} 22 | {{ form.new_password2 }} 23 | {{ form.new_password2.errors }} 24 |
25 | 26 | 27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /lists/templates/lists/userlist_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | 5 |

{% if userlist %}Edit{% else %}New{% endif %} User List

6 |
7 | {% csrf_token %} 8 | 9 | {% for field in form %} 10 | 11 |
12 | {{ field.errors }} 13 |

{{ field.label_tag }}

14 | {{ field }} 15 | {% if field.help_text %} 16 |

{{ field.help_text|safe }}

17 | {% endif %} 18 |
19 | 20 | {% endfor %} 21 | 22 | 23 | Go Back 24 |
25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /goal/templates/goal.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% load tags %} 4 | 5 | {% block content %} 6 | 7 |

Number of books to read in {{goal.year}}:

8 | 9 |
10 | {% csrf_token %} 11 |
12 | 13 |
14 |
15 | 19 |
20 | 21 | 22 |
23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /myreadinglist/static/css/jquery.autocomplete.css: -------------------------------------------------------------------------------- 1 | .ac_results { 2 | width: 100%; 3 | padding: 0px; 4 | border: 1px solid #ccc; 5 | background-color: white; 6 | overflow: hidden; 7 | z-index: 99999; 8 | } 9 | .ac_results ul { 10 | width: 100%; 11 | list-style-position: outside; 12 | list-style: none; 13 | padding: 0; 14 | margin: 0; 15 | } 16 | .ac_results li { 17 | margin: 0px; 18 | padding: 2px 5px; 19 | cursor: default; 20 | display: block; 21 | font: menu; 22 | font-size: 16px; 23 | /* 24 | it is very important, if line-height not set or set 25 | in relative units scroll will be broken in firefox 26 | */ 27 | line-height: 20px; 28 | overflow: hidden; 29 | } 30 | .ac_loading { 31 | background: white url('../img/loader.gif') right center no-repeat; 32 | } 33 | .ac_odd { 34 | background-color: #eee; 35 | } 36 | .ac_over { 37 | background-color: #2196F3; 38 | color: white; 39 | } 40 | -------------------------------------------------------------------------------- /myreadinglist/templates/registration/password_reset_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 6 | {% if validlink %} 7 | 8 |
9 | {% csrf_token %} 10 | 11 |
12 | {% for error in form.non_field_errors %} 13 | {{error}} 14 | {% endfor %} 15 |
16 | 17 |
18 | {{ form.new_password1.label }} 19 | {{ form.new_password1 }} 20 | {{ form.new_password1.errors }} 21 |
22 |
23 | {{ form.new_password2.label }} 24 | {{ form.new_password2 }} 25 | {{ form.new_password2.errors }} 26 |
27 | 28 | 29 |
30 | 31 | {% else %} 32 | 33 |

{% 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 |
6 | {% csrf_token %} 7 | 8 |
9 | {% for error in form.non_field_errors %} 10 | {{error}} 11 | {% endfor %} 12 |
13 | 14 |
15 | {{ form.username.label }} 16 | {{ form.username }} 17 | {{ form.username.errors }} 18 |
19 |
20 | {{ form.password.label }} 21 | {{ form.password }} 22 | {{ form.password.errors }} 23 |
24 | 25 | 26 | 27 |
28 | 29 |

30 | 31 |

{% 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 |

Take your reading to the next level ...

5 |
6 | {% csrf_token %} 7 | 8 |
9 | {% for error in form.non_field_errors %} 10 | {{error}} 11 | {% endfor %} 12 |
13 | 14 |
15 | {{ form.username.label }} 16 | {{ form.username }} 17 | {{ form.username.errors }} 18 |
19 |
20 | {{ form.email.label }} 21 | {{ form.email }} 22 | {{ form.email.errors }} 23 |
24 |
25 | {{ form.password1.label }} 26 | {{ form.password1 }} 27 | {{ form.password1.errors }} 28 |
29 |
30 | {{ form.password2.label }} 31 | {{ form.password2 }} 32 | {{ form.password2.errors }} 33 |
34 | 35 | 36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ develop ] 9 | pull_request: 10 | branches: [ develop ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v3 20 | 21 | - name: Set up Python 22 | run: uv python install 3.12 23 | 24 | - name: Install the project 25 | run: uv sync 26 | 27 | - name: Run tests 28 | run: uv run pytest 29 | env: 30 | SECRET_KEY: 21290asdkja 31 | DEBUG: True 32 | DATABASE_URL: sqlite:///test.db 33 | ENV: local 34 | ALLOWED_HOSTS: localhost 35 | SENDGRID_API_KEY: 123 36 | FROM_EMAIL: pybites@example.com 37 | ADMIN_USERS: pybites 38 | CELERY_BROKER_URL: amqp://guest:guest@localhost 39 | -------------------------------------------------------------------------------- /books/migrations/0022_auto_20210703_0953.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.12 on 2021-07-03 09:53 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 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('books', '0021_auto_20201215_1716'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='UserList', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=100)), 21 | ('inserted', models.DateTimeField(auto_now_add=True)), 22 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 23 | ], 24 | ), 25 | migrations.AddField( 26 | model_name='userbook', 27 | name='booklists', 28 | field=models.ManyToManyField(related_name='booklists', to='books.UserList'), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /books/migrations/0030_importedbook.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-22 05:47 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 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('books', '0029_alter_book_isbn'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='ImportedBook', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('title', models.CharField(max_length=200)), 21 | ('reading_status', models.CharField(max_length=20)), 22 | ('date_completed', models.DateTimeField()), 23 | ('book_status', models.CharField(max_length=20)), 24 | ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.book')), 25 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /books/migrations/0002_userbook.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 09:23 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 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('books', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='UserBook', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('status', models.CharField(choices=[('reading', 'r'), ('completed', 'c'), ('to read', 't')], default='reading', max_length=1)), 21 | ('added', models.DateTimeField(auto_now_add=True)), 22 | ('updated', models.DateTimeField(auto_now=True)), 23 | ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.Book')), 24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /books/templates/category.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% load tags %} 4 | 5 | {% block title %}{{ category.name }}{% endblock %} 6 | {% block og_title %}{{ category.name }}{% endblock %} 7 | {% block og_url %}{{ request.path }}{% endblock %} 8 | 9 | {% block content %} 10 | 11 |
12 |

{{ category.name }} books:

13 | {% for book in books %} 14 |
15 | 16 | 17 | {{ book.title }} 18 | 19 | 20 |
21 | {% with users_by_bookid|get_item:book.bookid as users %} 22 | {% for user in users %} 23 | {{ user.username|slice:":2"|upper }} 24 | {% endfor %} 25 | {% endwith %} 26 |
27 |
28 | {% endfor %} 29 |
30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /books/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | from django.conf import settings 3 | from django.contrib.auth.models import User 4 | 5 | from books.goodreads import convert_goodreads_to_google_books 6 | from myreadinglist.mail import send_email 7 | 8 | SUBJECT = "[PyBites Books] Your goodreads import has been processed" 9 | MESSAGE_TEMPLATE = """ 10 | Hey {username}, 11 | 12 | We converted {num_converted} books for you. Please check out the preview selecting the books you want to import: 13 | {url} 14 | 15 | Cheers, 16 | The PyBites Team 17 | """ # noqa E501 18 | PREVIEW_PAGE = f"{settings.DOMAIN}/books/import_books/preview" 19 | 20 | 21 | @shared_task 22 | def retrieve_google_books(file_content, username): 23 | """Convert goodreads to google books, sleeping 24 | one second in between requests to not hit Google 25 | API rate limits. Sent user email when done 26 | """ 27 | books = convert_goodreads_to_google_books(file_content, username, sleep_seconds=1) 28 | 29 | num_converted = len(books) 30 | msg = MESSAGE_TEMPLATE.format( 31 | username=username, num_converted=num_converted, url=PREVIEW_PAGE 32 | ) 33 | email = User.objects.get(username=username).email 34 | send_email(email, SUBJECT, msg) 35 | 36 | return f"{num_converted} books processed!" 37 | -------------------------------------------------------------------------------- /myreadinglist/static/js/search.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | // Initialize 3 | var bLazy = new Blazy(); 4 | // filter on the fly 5 | //http://www.marceble.com/2010/02/simple-jquery-table-row-filter/ 6 | $.expr[':'].containsIgnoreCase = function(n,i,m){ 7 | return jQuery(n).text().toUpperCase().indexOf(m[3].toUpperCase())>=0; 8 | }; 9 | 10 | 11 | $(".qVal").click(function(){ 12 | $(this).select(); 13 | }); 14 | 15 | $(".qVal").keyup(function(){ 16 | $("#booksWrapper").children().hide(); 17 | var data = this.value.split(" "); 18 | var jo = $("#booksWrapper").find(".book"); 19 | $.each(data, function(i, v){ 20 | jo = jo.filter("*:containsIgnoreCase('"+v+"')"); 21 | }); 22 | jo.show(); 23 | }).focus(function(){ 24 | this.value=""; 25 | $(this).css({"color":"#999"}); 26 | $(this).unbind('focus'); 27 | }).css({"color":"#C0C0C0"}); 28 | 29 | $(".defaultText").focus(function(srcc){ 30 | if ($(this).val() == $(this)[0].title){ 31 | $(this).removeClass("defaultTextActive"); 32 | $(this).val(""); 33 | } 34 | }); 35 | $(".defaultText").blur(function(){ 36 | if ($(this).val() == ""){ 37 | $(this).addClass("defaultTextActive"); 38 | $(this).val($(this)[0].title); 39 | } 40 | }); 41 | $(".defaultText").blur(); 42 | }); 43 | -------------------------------------------------------------------------------- /pomodoro/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | 6 | # "5 hour rule" would be 12 pomodori of 25 min 7 | # = good reading defaults 8 | DEFAULT_POMO_GOAL, DEFAULT_POMO_MIN = 12, 25 9 | TODAY = date.today() 10 | 11 | 12 | def this_week(dt=TODAY): 13 | return f'{dt.strftime("%Y")}/{dt.isocalendar()[1]}' 14 | 15 | 16 | class Pomodoro(models.Model): 17 | # in case we use it in a project with login 18 | user = models.ForeignKey(User, on_delete=models.CASCADE) 19 | # a counter would be enough but keeping track of minutes in case 20 | # we want various pomodori sizes 21 | minutes = models.IntegerField(default=DEFAULT_POMO_MIN) 22 | # pomodoro will be added upon finishing the pomo 23 | end = models.DateTimeField() 24 | 25 | @property 26 | def start(self): 27 | """Deduct start time calculating # minutes back from self.end""" 28 | return self.end - timedelta(minutes=self.minutes) 29 | 30 | @property 31 | def week(self): 32 | """Deduct YYYY-WW (year/week) from end datetime""" 33 | return this_week(self.end) 34 | 35 | def __str__(self): 36 | return f"{self.user} - {self.minutes} " f"({self.start}-{self.end})" 37 | 38 | class Meta: 39 | ordering = ["-end"] 40 | -------------------------------------------------------------------------------- /goal/views.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.shortcuts import render 4 | from django.contrib.auth.decorators import login_required 5 | from django.contrib import messages 6 | 7 | from .models import Goal 8 | 9 | 10 | @login_required 11 | def set_goal(request): 12 | post = request.POST 13 | user = request.user 14 | 15 | # take the current year, so switches to new challenge 16 | # when the new year starts 17 | goal, _ = Goal.objects.get_or_create(user=user, year=date.today().year) 18 | 19 | if "deleteGoal" in post: 20 | goal.delete() 21 | messages.success(request, "You deleted your goal") 22 | 23 | elif "updateGoal" in post: 24 | try: 25 | num_books = int(post.get("numBooks", 0)) 26 | except ValueError: 27 | error = "Please provide a numeric value" 28 | messages.error(request, error) 29 | else: 30 | old_number = goal.number_books 31 | 32 | goal.number_books = num_books 33 | goal.share = post.get("share", False) 34 | goal.save() 35 | 36 | action = "added" if old_number == 0 else "updated" 37 | msg = f"Successfully {action} goal for {goal.year}" 38 | messages.success(request, msg) 39 | 40 | return render(request, "goal.html", {"goal": goal}) 41 | -------------------------------------------------------------------------------- /books/templates/widget.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | My PyBites Books shelf 7 | 8 | 9 | 10 | 11 | 14 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /myreadinglist/templatetags/tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | # https://sashat.me/2017/01/11/list-of-20-simple-distinct-colors/ 6 | COLORS = [ 7 | ("#e6194b", "#fff"), 8 | ("#3cb44b", "#fff"), 9 | ("#ffe119", "#000"), 10 | ("#0082c8", "#fff"), 11 | ("#f58231", "#fff"), 12 | ("#911eb4", "#fff"), 13 | ("#46f0f0", "#000"), 14 | ("#f032e6", "#fff"), 15 | ("#d2f53c", "#000"), 16 | ("#fabebe", "#000"), 17 | ("#008080", "#fff"), 18 | ("#e6beff", "#000"), 19 | ("#aa6e28", "#fff"), 20 | ("#fffac8", "#000"), 21 | ("#800000", "#fff"), 22 | ("#aaffc3", "#000"), 23 | ("#808000", "#fff"), 24 | ("#ffd8b1", "#000"), 25 | ("#000080", "#fff"), 26 | ("#808080", "#000"), 27 | ("#FFFFFF", "#000"), 28 | ("#000000", "#fff"), 29 | ] 30 | 31 | 32 | @register.filter 33 | def get_item(dictionary, key): 34 | return dictionary.get(key) 35 | 36 | 37 | @register.filter 38 | def user2rgb(userid): 39 | """Generates a random color for user avatar""" 40 | idx = userid % len(COLORS) 41 | bg, fg = COLORS[idx] 42 | return f"background-color: {bg}; color: {fg};" 43 | 44 | 45 | @register.filter 46 | def unslugify(value): 47 | return value.replace("-", " ") 48 | 49 | 50 | @register.filter 51 | def multiply(string, times): 52 | return string * times 53 | 54 | 55 | @register.filter 56 | def subtract(value, arg): 57 | return max(value - arg, 0) 58 | -------------------------------------------------------------------------------- /myreadinglist/mail.py: -------------------------------------------------------------------------------- 1 | from decouple import config 2 | from django.conf import settings 3 | 4 | import sendgrid 5 | from sendgrid.helpers.mail import To, From, Mail 6 | 7 | FROM_EMAIL = config("FROM_EMAIL") 8 | PYBITES = "PyBites" 9 | 10 | sg = sendgrid.SendGridAPIClient(api_key=config("SENDGRID_API_KEY")) 11 | 12 | 13 | def send_email(to_email, subject, body, from_email=FROM_EMAIL, html=True): 14 | from_email = From(email=from_email, name=PYBITES) 15 | to_email = To(to_email) 16 | 17 | # if local no emails 18 | if settings.LOCAL: 19 | body = body.replace("
", "\n") 20 | print("local env - no email, only print send_email args:") 21 | print(f"to_email: {to_email.email}") 22 | print(f"subject: {subject}") 23 | print(f"body: {body}") 24 | print(f"from_email: {from_email.email}") 25 | print(f"html: {html}") 26 | print() 27 | return 28 | 29 | # newlines get wrapped in email, use html 30 | body = body.replace("\n", "
") 31 | message = Mail( 32 | from_email=from_email, 33 | to_emails=to_email, 34 | subject=subject, 35 | plain_text_content=body if not html else None, 36 | html_content=body if html else None, 37 | ) 38 | 39 | response = sg.send(message) 40 | 41 | if str(response.status_code)[0] != "2": 42 | # TODO logging 43 | print(f"ERROR sending message, status_code {response.status_code}") 44 | 45 | return response 46 | 47 | 48 | if __name__ == "__main__": 49 | send_email("test-email@gmail.com", "my subject", "my message") 50 | -------------------------------------------------------------------------------- /myreadinglist/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import include, path 4 | from django_registration.backends.activation.views import RegistrationView 5 | from django_registration.forms import RegistrationFormUniqueEmail 6 | 7 | from . import views 8 | from books import views as book_views 9 | 10 | urlpatterns = [ 11 | path("", views.index, name="index"), 12 | path("query_books/", views.query_books, name="query_books"), 13 | path("api/", include("api.urls", namespace="api")), 14 | path("slack/", include("slack.urls", namespace="slack")), 15 | path("books/", include("books.urls", namespace="books")), 16 | path("users/favorite/", book_views.user_favorite, name="favorite"), 17 | path("users/", book_views.user_page, name="user_page"), 18 | path("widget/", book_views.user_page_widget, name="user_page_widget"), 19 | # no dup emails - https://stackoverflow.com/a/19383392 20 | path( 21 | "accounts/register/", 22 | RegistrationView.as_view(form_class=RegistrationFormUniqueEmail), 23 | name="registration_register", 24 | ), 25 | path("accounts/", include("django_registration.backends.activation.urls")), 26 | path("accounts/", include("django.contrib.auth.urls")), 27 | path("5hours/", include("pomodoro.urls")), 28 | path("goal/", include("goal.urls")), 29 | path("lists/", include("lists.urls")), 30 | path("super-reader/", admin.site.urls), 31 | ] 32 | 33 | if settings.DEBUG: 34 | import debug_toolbar 35 | 36 | urlpatterns = [ 37 | path("__debug__/", include(debug_toolbar.urls)), 38 | ] + urlpatterns 39 | -------------------------------------------------------------------------------- /lists/templates/lists/userlist_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block search %}{% endblock %} 4 | 5 | {% block content %} 6 | 7 |
8 | {% if not request.user.is_authenticated %} 9 | Login to add reading lists 10 | {% elif num_lists_left > 0 %} 11 | 12 | Add a new list ({{num_lists_left}} left) 13 | 14 | {% else %} 15 |
16 |

You hit the maximum number of reading lists 17 | ({{ max_num_user_lists }}), 18 | delete one or more to create new ones.

19 |
20 | {% endif %} 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% for userlist in userlist_list %} 33 | 34 | 39 | 48 | 49 | 50 | {% endfor %} 51 | 52 |
ListByAdded
35 | 36 | {{ userlist.name }} 37 | 38 | 40 | 41 | {% if request.user == userlist.user %} 42 | me 43 | {% else %} 44 | {{ userlist.user.username }} 45 | {% endif %} 46 | 47 | {{ userlist.inserted|timesince }} ago
53 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /myreadinglist/static/css/widget.css: -------------------------------------------------------------------------------- 1 | body { 2 | font : 75%/1.5 "Lucida Grande", Helvetica, "Lucida Sans Unicode", Arial, Verdana, sans-serif; 3 | color: #000; 4 | background-color: #fff; 5 | margin: 0 auto; 6 | } 7 | ul { 8 | padding: 0; 9 | margin: 20px 0; 10 | overflow: hidden; 11 | } 12 | li { 13 | list-style: none; 14 | float: left; 15 | padding: 0 25px; 16 | border-bottom: 12px solid black; 17 | } 18 | li a { 19 | display: block; 20 | padding: 0px; 21 | } 22 | li img { 23 | display: inherit; 24 | position: relative; 25 | top: 5px; 26 | margin: 25px 10px 0px 10px; 27 | height: 168px; 28 | width: 128px; 29 | } 30 | #search { 31 | background: #f2f2f2; 32 | border: 1px solid #ddd; 33 | width: 100%; 34 | } 35 | .qVal { 36 | width: 90%; 37 | margin: 10px; 38 | } 39 | .hide { 40 | display: none; 41 | } 42 | .tooltip { 43 | display: inline; 44 | position: relative; 45 | } 46 | .tooltip { 47 | display: inline; 48 | position: relative; 49 | } 50 | 51 | .tooltip:hover:after { 52 | background: #333; 53 | background: rgba(0,0,0,.8); 54 | border-radius: 5px; 55 | bottom: 26px; 56 | color: #fff; 57 | content: attr(data-tooltip); 58 | left: 20%; 59 | padding: 5px 15px; 60 | position: absolute; 61 | z-index: 98; 62 | width: 220px; 63 | } 64 | 65 | .tooltip:hover:before { 66 | border: solid; 67 | border-color: #333 transparent; 68 | border-width: 6px 6px 0 6px; 69 | bottom: 20px; 70 | content: ""; 71 | left: 50%; 72 | position: absolute; 73 | z-index: 99; 74 | } 75 | 76 | .js-favorite { 77 | visibility: hidden; 78 | font-size: 30px; 79 | position: absolute; 80 | color: orange; 81 | bottom: 170px; 82 | left: 115px; 83 | } 84 | .js-favorite:before { 85 | content: "\2606"; 86 | visibility: visible; 87 | background: #fff; 88 | opacity: 0.7; 89 | } 90 | -------------------------------------------------------------------------------- /myreadinglist/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import HttpResponse 3 | from django.shortcuts import render 4 | 5 | from books.googlebooks import search_books 6 | from books.models import UserBook, COMPLETED 7 | 8 | DEFAULT_THUMB = f"{settings.DOMAIN}/static/img/book-badge.png" 9 | BOOK_ENTRY = ( 10 | '' 11 | '' 12 | '' 13 | '{title} ({authors})' 14 | "\n" 15 | ) 16 | 17 | 18 | def _parse_response(items): 19 | for item in items: 20 | try: 21 | id_ = item["id"] 22 | volinfo = item["volumeInfo"] 23 | title = volinfo["title"] 24 | authors = volinfo["authors"][0] 25 | except KeyError: 26 | continue 27 | 28 | img = volinfo.get("imageLinks") 29 | thumb = img and img.get("smallThumbnail") 30 | thumb = thumb and thumb or DEFAULT_THUMB 31 | 32 | book_entry = BOOK_ENTRY.format( 33 | id=id_, title=title, authors=authors, thumb=thumb 34 | ) 35 | yield book_entry 36 | 37 | 38 | def query_books(request): 39 | no_result = HttpResponse("fail") 40 | 41 | try: 42 | term = request.GET.get("q") 43 | except Exception: 44 | return no_result 45 | 46 | term = request.GET.get("q", "") 47 | books = search_books(term, request) 48 | items = books.get("items") 49 | if not items: 50 | return no_result 51 | 52 | data = list(_parse_response(items)) 53 | 54 | return HttpResponse(data) 55 | 56 | 57 | def index(request): 58 | user_books = ( 59 | UserBook.objects.select_related("book", "user") 60 | .filter(status=COMPLETED) 61 | .order_by("-inserted")[:100] 62 | ) 63 | context = {"user_books": user_books} 64 | return render(request, "index.html", context) 65 | -------------------------------------------------------------------------------- /books/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-28 22: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='Book', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('bookid', models.CharField(max_length=20)), 22 | ('title', models.CharField(max_length=200)), 23 | ('authors', models.CharField(max_length=200)), 24 | ('publisher', models.CharField(max_length=100)), 25 | ('published', models.CharField(max_length=20)), 26 | ('isbn', models.CharField(max_length=15)), 27 | ('pages', models.CharField(max_length=5)), 28 | ('language', models.CharField(max_length=2)), 29 | ('description', models.TextField()), 30 | ('inserted', models.DateTimeField(auto_now_add=True, verbose_name='inserted')), 31 | ('edited', models.DateTimeField(auto_now=True, verbose_name='last modified')), 32 | ], 33 | ), 34 | migrations.CreateModel( 35 | name='Search', 36 | fields=[ 37 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 38 | ('term', models.CharField(max_length=20)), 39 | ('inserted', models.DateTimeField(auto_now_add=True, verbose_name='inserted')), 40 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 41 | ], 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /pomodoro/templates/pomodoro/pomodoro.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load tags %} 3 | 4 | {% block title %}The 5-Hour Rule Challenge{% endblock %} 5 | {% block og_title %}The 5-Hour Rule Challenge{% endblock %} 6 | {% block og_url %}{{ request.path }}{% endblock %} 7 | 8 | {% block content %} 9 | 10 |
11 |
12 |

The 5-Hour Rule Challenge

13 |
14 |

{{ week_goal }} x {{ pomo_minutes }} min. Pomodori / week!

15 |
16 |
17 | 18 |
19 |
20 |
Just hit Start Pomodoro and start reading, do this twelve times a week and we see you at the top!
21 |
22 | 23 | 24 | 25 | 26 |
27 | {% csrf_token %} 28 | 29 |
30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {% for week, pomodori in week_stats %} 42 | 43 | 44 | 54 | 55 | {% endfor %} 56 | 57 |
WeekPomodori
{{ week }} | {% if pomodori >= week_goal %}😄{% else %}😭{% endif %} 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 |
58 | 59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /books/migrations/0009_auto_20180729_1552.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-07-29 15:52 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 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('books', '0008_auto_20180729_1025'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Badge', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('books', models.IntegerField()), 21 | ('title', models.CharField(max_length=50)), 22 | ('image', models.CharField(max_length=20)), 23 | ], 24 | ), 25 | migrations.CreateModel( 26 | name='BookNote', 27 | fields=[ 28 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('note', models.TextField()), 30 | ('public', models.BooleanField(default=False)), 31 | ('inserted', models.DateTimeField(auto_now_add=True)), 32 | ('edited', models.DateTimeField(auto_now=True)), 33 | ], 34 | ), 35 | migrations.AlterField( 36 | model_name='book', 37 | name='edited', 38 | field=models.DateTimeField(auto_now=True), 39 | ), 40 | migrations.AlterField( 41 | model_name='book', 42 | name='inserted', 43 | field=models.DateTimeField(auto_now_add=True), 44 | ), 45 | migrations.AlterField( 46 | model_name='search', 47 | name='inserted', 48 | field=models.DateTimeField(auto_now_add=True), 49 | ), 50 | migrations.AddField( 51 | model_name='booknote', 52 | name='book', 53 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='books.Book'), 54 | ), 55 | migrations.AddField( 56 | model_name='booknote', 57 | name='user', 58 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /lists/templates/lists/userlist_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% load tags %} 4 | 5 | {% block title %}{{ userlist.name|unslugify|title }}{% endblock %} 6 | {% block og_title %}{{ userlist.name|unslugify|title }}{% endblock %} 7 | {% block og_url %}{{ request.path }}{% endblock %} 8 | 9 | {% block content %} 10 | 11 |
12 |

Reading list: {{ userlist.name|unslugify|title }} (by {{ userlist.user.username }})

13 | 14 | 23 | 24 |
25 | {% for category, books in books_by_category %} 26 |
27 |
28 |

{{ category }}

29 |
30 |
31 | {% for book in books %} 32 |
33 | 34 | 35 | {{ book.title }} 36 | 37 | 38 |
39 | {% with users_by_bookid|get_item:book.bookid as users %} 40 | {% for user in users %} 41 | {{ user.username|slice:":2"|upper }} 42 | {% endfor %} 43 | {% endwith %} 44 |
45 |
46 | {% endfor %} 47 |
48 |
49 | {% endfor %} 50 |
51 | 52 |
53 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyBites Books 2 | 3 | > What gets measured gets managed. - Peter Drucker 4 | 5 | Our simple yet effective reading tracking app: [PyBites Books](https://pybitesbooks.com) 6 | 7 | (Warning: it can be addictive and will cause you to read more!) 8 | 9 | ## Setup 10 | 11 | ![](https://img.shields.io/badge/Python-3.9.0-blue.svg) 12 | ![](https://img.shields.io/badge/Django-4.1.13-blue.svg) 13 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) 14 | 15 | 1. Create a [virtual env](https://pybit.es/the-beauty-of-virtualenv.html) and activate it (`source venv/bin/activate`) 16 | 2. Install the dependencies: `pip install -r requirements.txt` 17 | 3. Create a database, e.g. `pybites_books` and define the full DB URL for the next step, e.g. `DATABASE_URL=postgres://postgres:password@0.0.0.0:5432/pybites_books`. 18 | 4. Set this env variable together with `SECRET_KEY` in a file called `.env` in the root of the project: `cp .env-template .env && vi .env`. That's the bare minimum. If you want to have email working create a [Sendgrid](https://sendgrid.com/) account obtaining an API key. Same for Slack integration, this requires a `SLACK_VERIFICATION_TOKEN`. The other variables have sensible defaults. 19 | 5. Sync the DB: `python manage.py migrate`. 20 | 6. And finally run the app server: `python manage.py runserver`. 21 | 22 | ## Run pre-commit 23 | 24 | Install the git hook scripts 25 | ```bash 26 | pre-commit install 27 | ``` 28 | Run against all the files: 29 | ```bash 30 | pre-commit run --all-files 31 | ``` 32 | 33 | ## Local Via docker-compose 34 | 35 | You can use docker / docker compose to run both the postgresql database as well as the app itself. This makes local testing a lot easier, and allows you to worry less about environmental details. 36 | 37 | To run, simply run the below command. This should spin up the db, and then the application which you can reach at http://0.0.0.0:8000. 38 | 39 | `docker-compose rm && docker-compose build && docker-compose up` 40 | 41 | ### DB Data 42 | In order to prevent recreating the DB every time you run docker-compose, and in order to keep state from use to use, a volume is mounted, and tied to the local directory database-data. This is ignored in the .gitignore so that you don't accidentally upload data to github. 43 | 44 | ### .env-compose 45 | This has environment variables set so that you can get up and running easily. Tweak these as needed to add things like Slack and SendGrid integration. 46 | 47 | ## Contributions 48 | 49 | ... are more than welcome, just [open an issue](https://github.com/pybites/pbreadinglist/issues) and/or [PR new features](https://github.com/pybites/pbreadinglist/pulls). 50 | 51 | Remember _leaders are readers_, read every day! 52 | -------------------------------------------------------------------------------- /tests/test_books.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | pytestmark = pytest.mark.django_db 6 | 7 | 8 | def test_homepage_shows_user_completed_books(client, user_books): 9 | response = client.get("/") 10 | html = response.content.decode() 11 | book1 = ( 12 | '' 16 | ) 17 | book2 = ( 18 | '' 22 | "" 23 | ) 24 | assert book1 in html 25 | assert book2 in html 26 | 27 | 28 | def test_book_page_logged_out(client, books): 29 | response = client.get("/books/nneBa6-mWfgC") 30 | html = response.content.decode() 31 | assert "Peter Seibel" in html 32 | assert "Apress" in html 33 | assert "978143021948463" in html 34 | assert not re.search(r"4<.* books added", html) 57 | assert re.search(r"of which.*>2<.* read .*>729<.* pages", html) 58 | 59 | 60 | @pytest.mark.parametrize( 61 | "snippet", 62 | [ 63 | "nneBa6-mWfgC >", 64 | "__CvAFrcWY0C >", 65 | "3V_6DwAAQBAJ >", 66 | "bK1ktwAACAAJ >", 67 | "jaM7DwAAQBAJ checked>", 68 | "UCJMRAAACAAJ checked>", 69 | ], 70 | ) 71 | def test_user_profile_page_stars(client, user, user_fav_books, snippet): 72 | response = client.get(f"/users/{user.username}") 73 | html = response.content.decode() 74 | assert ( 75 | f' limit: 50 | return f"{obj.description[:limit]} ..." 51 | else: 52 | return obj.description 53 | 54 | short_desc.short_description = "short description" 55 | 56 | 57 | class BadgeAdmin(admin.ModelAdmin): 58 | list_display = ("books", "title") 59 | 60 | 61 | class BookConversionAdmin(admin.ModelAdmin): 62 | list_display = ("goodreads_id", "book_link", "inserted") 63 | search_fields = ("goodreads_id", "googlebooks_id") 64 | 65 | def book_link(self, obj): 66 | return mark_safe( 67 | f"{obj.googlebooks_id}" 69 | ) 70 | 71 | book_link.short_description = "Google / PyBites book link" 72 | 73 | 74 | class ImportedBookAdmin(admin.ModelAdmin): 75 | list_display = ( 76 | "title", 77 | "book", 78 | "reading_status", 79 | "date_completed", 80 | "book_status", 81 | "user", 82 | ) 83 | search_fields = ("title",) 84 | 85 | 86 | admin.site.register(Category, CategoryAdmin) 87 | admin.site.register(Book, BookAdmin) 88 | admin.site.register(Search, SearchAdmin) 89 | admin.site.register(UserBook, UserBookAdmin) 90 | admin.site.register(BookNote, BookNoteAdmin) 91 | admin.site.register(Badge, BadgeAdmin) 92 | admin.site.register(BookConversion, BookConversionAdmin) 93 | admin.site.register(ImportedBook, ImportedBookAdmin) 94 | -------------------------------------------------------------------------------- /myreadinglist/static/js/script.js: -------------------------------------------------------------------------------- 1 | var pomodoroTimer; 2 | 3 | 4 | function countDown(){ 5 | var timer = document.getElementById("timer") 6 | timer.disabled = true; 7 | var cancelTimer = document.getElementById("cancelTimer") 8 | cancelTimer.style.display = "inline"; 9 | 10 | var now = new Date().getTime(); 11 | var end = new Date(now) 12 | // test 13 | // var countDownDate = end.setSeconds(end.getSeconds() + 3); 14 | var countDownDate = end.setMinutes(end.getMinutes() + 25); 15 | 16 | console.log(countDownDate); 17 | 18 | pomodoroTimer = setInterval(function() { 19 | 20 | now = new Date().getTime(); 21 | var distance = countDownDate - now; 22 | 23 | var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); 24 | var seconds = Math.floor((distance % (1000 * 60)) / 1000); 25 | 26 | timer.innerHTML = "Time left: " + minutes + "m " + seconds + "s "; 27 | 28 | if (distance < 0) { 29 | cancelPomodoro(); 30 | logPomodoro(); 31 | } 32 | }, 1000); 33 | } 34 | 35 | 36 | function cancelPomodoro(){ 37 | clearInterval(pomodoroTimer); 38 | var timer = document.getElementById("timer") 39 | timer.innerHTML = "Start Pomodoro"; 40 | timer.disabled = false; 41 | var cancelTimer = document.getElementById("cancelTimer") 42 | cancelTimer.style.display = "none"; 43 | } 44 | 45 | 46 | function logPomodoro(){ 47 | document.getElementById("addPomo").submit(); 48 | } 49 | 50 | 51 | function ConfirmAction(msg){ 52 | // triggers a popup to make sure user is ok with a destructive operation 53 | if(confirm(msg)){ 54 | return true; 55 | } else { 56 | return false; 57 | } 58 | } 59 | 60 | 61 | $(document).ready(function(){ 62 | $("#searchTitles").autocomplete( "/query_books/", { minChars:3 }); 63 | 64 | // http://forum.jquery.com/topic/jquery-autocomplete-submit-form-on-result 65 | $("#searchTitles").result(function (event, data, formatted) { 66 | $("#searchProgress").append('Loading ...'); 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 = "
".join( 77 | (f"- {uu.username} > " f"{PROFILE_PAGE.format(username=uu.username)}") 78 | for uu in new_users 79 | ) 80 | 81 | books_completed_per_user = defaultdict(list) 82 | for ub in books_read_last_week: 83 | books_completed_per_user[ub.user.username].append(ub.book) 84 | 85 | books_completed = [] 86 | for username, user_books in books_completed_per_user.items(): 87 | books_completed.append(f"
* {username}:") 88 | books_completed.append( 89 | "".join(f"
- {book.title} > {book.url}" for book in user_books) 90 | ) 91 | 92 | goals = Goal.objects.filter(year=THIS_YEAR, number_books__gt=0).order_by( 93 | "-number_books" 94 | ) 95 | goals_out = "
".join( 96 | f"{goal.user.username} > {goal.number_books}" for goal in goals 97 | ) 98 | 99 | msg = MSG.format( 100 | num_total_users=all_users.count(), 101 | num_new_users=num_new_users, 102 | new_user_profiles=new_user_profiles, 103 | num_books_clicked=num_books_clicked, 104 | num_books_completed=num_books_completed, 105 | num_books_completed_pages=num_books_completed_pages, 106 | books_completed="".join(books_completed), 107 | goals=goals_out, 108 | ) 109 | 110 | for to_email in PYBITES_EMAIL_GROUP: 111 | send_email(to_email, SUBJECT, msg) 112 | -------------------------------------------------------------------------------- /myreadinglist/static/js/blazy.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | hey, [be]Lazy.js - v1.6.2 - 2016.05.09 3 | A fast, small and dependency free lazy load script (https://github.com/dinbror/blazy) 4 | (c) Bjoern Klinggaard - @bklinggaard - http://dinbror.dk/blazy 5 | */ 6 | (function(p,m){"function"===typeof define&&define.amd?define(m):"object"===typeof exports?module.exports=m():p.Blazy=m()})(this,function(){function p(b){var c=b._util;c.elements=B(b.options.selector);c.count=c.elements.length;c.destroyed&&(c.destroyed=!1,b.options.container&&k(b.options.container,function(a){n(a,"scroll",c.validateT)}),n(window,"resize",c.save_viewportOffsetT),n(window,"resize",c.validateT),n(window,"scroll",c.validateT));m(b)}function m(b){for(var c=b._util,a=0;a=e.left&&f.bottom>=e.top&&f.left<=e.right&&f.top<=e.bottom||q(d,b.options.successClass))b.load(d),c.elements.splice(a,1),c.count--,a--}0===c.count&&b.destroy()}function x(b,c,a){if(!q(b,a.successClass)&&(c||a.loadInvisible||0=window.screen.width)return r=a.src,!1});setTimeout(function(){p(a)})}}); 7 | -------------------------------------------------------------------------------- /lists/views.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from django.contrib.auth.mixins import LoginRequiredMixin 4 | from django.views.generic import DetailView 5 | from django.views.generic.edit import CreateView, DeleteView, UpdateView 6 | from django.views.generic.list import ListView 7 | from django.urls import reverse_lazy 8 | from django.utils.text import slugify 9 | from decouple import config, Csv 10 | 11 | from books.models import UserBook 12 | from books.views import MIN_NUM_BOOKS_SHOW_SEARCH 13 | from .models import UserList 14 | from .mixins import OwnerRequiredMixin 15 | 16 | MAX_NUM_USER_LISTS, MAX_NUM_ADMIN_LISTS = 10, 100 17 | ADMIN_USERS = set(config("ADMIN_USERS", cast=Csv())) 18 | 19 | 20 | def get_max_books(request): 21 | if request.user.username in ADMIN_USERS: 22 | return MAX_NUM_ADMIN_LISTS 23 | return MAX_NUM_USER_LISTS 24 | 25 | 26 | class UserListListView(ListView): 27 | model = UserList 28 | 29 | def get_context_data(self, **kwargs): 30 | context = super().get_context_data(**kwargs) 31 | max_num_user_lists, num_lists_left = 0, 0 32 | if self.request.user.is_authenticated: 33 | max_num_user_lists = get_max_books(self.request) 34 | num_user_lists = UserList.objects.filter(user=self.request.user).count() 35 | num_lists_left = max_num_user_lists - num_user_lists 36 | context["num_lists_left"] = num_lists_left 37 | context["max_num_user_lists"] = max_num_user_lists 38 | return context 39 | 40 | 41 | class UserListDetailView(DetailView): 42 | model = UserList 43 | slug_field = "name" 44 | slug_url_kwarg = "name" 45 | 46 | def get_context_data(self, **kwargs): 47 | context = super().get_context_data(**kwargs) 48 | obj = self.get_object() 49 | user_books = UserBook.objects.select_related("book", "user").order_by( 50 | "book__title" 51 | ) 52 | 53 | # TODO: deduplicate 54 | # users_by_bookid needs all user_books 55 | users_by_bookid = defaultdict(set) 56 | for ub in user_books: 57 | bookid = ub.book.bookid 58 | users_by_bookid[bookid].add(ub.user) 59 | 60 | # ... hence this filter should go after that 61 | user_books = user_books.filter(booklists__id=obj.id) 62 | 63 | users_by_bookid_sorted = { 64 | bookid: sorted(users, key=lambda user: user.username.lower()) 65 | for bookid, users in users_by_bookid.items() 66 | } 67 | 68 | books_by_category = defaultdict(list) 69 | # make sure books are only added once 70 | books_done = set() 71 | for ub in user_books: 72 | for category in ub.book.categories.all(): 73 | if ub.book in books_done: 74 | continue 75 | # only order by high level category 76 | category = category.name.split(" / ")[0].title() 77 | books_by_category[category].append(ub.book) 78 | books_done.add(ub.book) 79 | 80 | books_by_category_sorted = sorted(books_by_category.items()) 81 | 82 | context["user_books"] = user_books 83 | context["users_by_bookid"] = users_by_bookid_sorted 84 | context["books_by_category"] = books_by_category_sorted 85 | 86 | context["min_num_books_show_search"] = MIN_NUM_BOOKS_SHOW_SEARCH 87 | is_auth = self.request.user.is_authenticated 88 | context["is_me"] = is_auth and self.request.user == obj.user 89 | return context 90 | 91 | 92 | class UserListCreateView(LoginRequiredMixin, CreateView): 93 | model = UserList 94 | fields = ["name"] 95 | success_url = reverse_lazy("lists-view") 96 | 97 | def form_valid(self, form): 98 | form.instance.name = slugify(form.instance.name) 99 | user_lists = UserList.objects 100 | if user_lists.filter(name=form.instance.name).count() > 0: 101 | form.add_error("name", "This list already exists") 102 | return self.form_invalid(form) 103 | max_num_user_lists = get_max_books(self.request) 104 | if user_lists.filter(user=self.request.user).count() > max_num_user_lists: 105 | form.add_error("name", f"You can have {max_num_user_lists} lists at most") 106 | return self.form_invalid(form) 107 | form.instance.user = self.request.user 108 | return super().form_valid(form) 109 | 110 | 111 | class UserListUpdateView(OwnerRequiredMixin, UpdateView): 112 | model = UserList 113 | fields = ["name"] 114 | success_url = reverse_lazy("lists-view") 115 | 116 | def form_valid(self, form): 117 | form.instance.name = slugify(form.instance.name) 118 | old_value = UserList.objects.get(pk=self.object.pk).name 119 | # this if is there in case user saves existing value without change 120 | if old_value != form.instance.name: 121 | if UserList.objects.filter(name=form.instance.name).count() > 0: 122 | form.add_error("name", "This list already exists") 123 | return self.form_invalid(form) 124 | return super().form_valid(form) 125 | 126 | 127 | class UserListDeleteView(OwnerRequiredMixin, DeleteView): 128 | model = UserList 129 | success_url = reverse_lazy("lists-view") 130 | -------------------------------------------------------------------------------- /myreadinglist/static/css/style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Body CSS 3 | */ 4 | html, 5 | body { 6 | height: 100%; 7 | } 8 | 9 | html, 10 | body, 11 | input, 12 | textarea, 13 | button { 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.004); 17 | } 18 | 19 | 20 | /** 21 | * Header CSS 22 | */ 23 | header { 24 | position: fixed; 25 | top: 0; 26 | right: 0; 27 | left: 0; 28 | z-index: 2; 29 | } 30 | 31 | header ul.mui-list--inline { 32 | margin-bottom: 0; 33 | } 34 | 35 | header a { 36 | color: #fff; 37 | } 38 | 39 | header table { 40 | width: 100%; 41 | } 42 | 43 | 44 | /** 45 | * Content CSS 46 | */ 47 | #content-wrapper { 48 | min-height: 100%; 49 | 50 | /* sticky footer */ 51 | box-sizing: border-box; 52 | margin-bottom: -100px; 53 | padding-bottom: 100px; 54 | } 55 | 56 | 57 | /** 58 | * Footer CSS 59 | */ 60 | footer { 61 | box-sizing: border-box; 62 | height: 100px; 63 | background-color: #eee; 64 | border-top: 1px solid #e0e0e0; 65 | padding-top: 35px; 66 | margin-top: 35px; 67 | } 68 | 69 | 70 | 71 | /* autocomplete */ 72 | 73 | .searchResWrapper { 74 | float: left; 75 | clear: both; 76 | padding-left: 0px; 77 | margin-left: 0px; 78 | } 79 | .bookThumb { 80 | height: 50px; 81 | width: 30px; 82 | padding: 0px; 83 | margin-right: 10px; 84 | } 85 | .titleAndAuthors { 86 | position: relative; 87 | bottom: 20px; 88 | } 89 | 90 | #actionWrapper { 91 | margin-top: 10px; 92 | } 93 | 94 | #bookInfo ul { 95 | padding: 0px; 96 | } 97 | 98 | /* custom styles */ 99 | blockquote { 100 | border-left: 1px solid #f2f2f2; 101 | padding: 0.5em 10px; 102 | quotes: "“" "”"; 103 | } 104 | blockquote:before, blockquote:after { 105 | color: #ccc; 106 | font-size: 4em; 107 | line-height: 0.1em; 108 | margin-right: 0.10em; 109 | vertical-align: -0.4em; 110 | } 111 | blockquote:before { 112 | content: open-quote; 113 | } 114 | blockquote:after { 115 | content: " "; 116 | } 117 | 118 | .thumbNail { 119 | height: 100px; 120 | width: 70px; 121 | margin: 5px 3px; 122 | padding: 5px; 123 | border: 1px solid #ddd; 124 | border-radius: 5px; 125 | } 126 | 127 | #navigation li { 128 | margin-left: 5px; 129 | padding: 10px; 130 | } 131 | 132 | #navigation li:hover { 133 | border-bottom: 1px solid #f2f2f2; 134 | } 135 | #navigation li a:hover { 136 | text-decoration: none; 137 | opacity: 0.85; 138 | } 139 | 140 | .userAvatar { 141 | width: 30px; 142 | height: 30px; 143 | margin: 5px; 144 | float: left; 145 | border-radius: 50%; 146 | padding: 5px; 147 | color: #fff; 148 | border: 1px solid #ccc; 149 | } 150 | .userAvatarSmall { 151 | width: 20px; 152 | height: 20px; 153 | margin: 2px; 154 | padding: 2px 3px 8px 7px; 155 | font-size: 85%; 156 | } 157 | 158 | .note .usernameAndTime { 159 | margin-left: 20px; 160 | } 161 | 162 | .note .description { 163 | float: left; 164 | margin-left: 20px; 165 | } 166 | 167 | .badge { 168 | width: 120px; 169 | height: 120px; 170 | position: relative; 171 | } 172 | .badge .numBooks { 173 | position: absolute; 174 | top: 52px; 175 | left: 46px; 176 | width: 20px; 177 | font-weight: bold; 178 | padding: 10px; 179 | text-align: center; 180 | } 181 | .rankAvatar { 182 | background-color: #2196F3; 183 | color: white; 184 | position: absolute; 185 | top: 90px; 186 | left: 80px; 187 | } 188 | #readingProgress { 189 | position: relative; 190 | top: 5px; 191 | margin-right: 10px; 192 | } 193 | 194 | progress[value] { 195 | width: 90%; 196 | height: 20px; 197 | } 198 | 199 | .js-favorite { 200 | visibility: hidden; 201 | font-size: 30px; 202 | cursor: pointer; 203 | position: relative; 204 | color: #E26313; 205 | bottom: 20px; 206 | right: 30px; 207 | } 208 | .js-favorite:before { 209 | content: "\2606"; 210 | position: absolute; 211 | visibility: visible; 212 | } 213 | .js-favorite:checked:before { 214 | content: "\2605"; 215 | position: relative; 216 | } 217 | .scrollbar { 218 | overflow: auto; 219 | white-space: nowrap; 220 | } 221 | 222 | .errorlist li { 223 | list-style: none; 224 | padding: 5px; 225 | } 226 | .errorlist { 227 | color: red; 228 | margin: 10px 0; 229 | padding: 0; 230 | border: 1px solid #ccc; 231 | } 232 | .clear { 233 | clear: both; 234 | } 235 | .right { 236 | float: right; 237 | } 238 | .marginTop { 239 | margin-top: 50px; 240 | } 241 | 242 | .userbooks { 243 | padding: 10px; 244 | } 245 | ul#bookCategories { 246 | padding-left: 0; 247 | margin: 0 0 20px 0; 248 | font-size: 90%; 249 | overflow: hidden; 250 | } 251 | li.tag { 252 | list-style: none; 253 | background-color: #f0f8ff; 254 | border: 1px solid #f2f2f2; 255 | border-radius: 5px; 256 | margin: 5px; 257 | padding: 5px 10px; 258 | float: left; 259 | } 260 | #bookTabs { 261 | clear: both; 262 | } 263 | #categoryTable { 264 | margin-top: 20px; 265 | } 266 | .bookCard { 267 | height: 250px; 268 | position: relative; 269 | margin: 5px; 270 | } 271 | .categoryRow { 272 | margin-top: 15px; 273 | overflow: hidden; 274 | } 275 | .categoryCard { 276 | margin-top: 5px; 277 | height: 280px; 278 | background: #f2f2f2; 279 | } 280 | .readers { 281 | padding: 5px; 282 | border: 1px solid #f2f2f2; 283 | background: #f0f8ff; 284 | opacity: 0.8; 285 | overflow: hidden; 286 | position: absolute; 287 | bottom: 0px; 288 | right: 0px; 289 | } 290 | -------------------------------------------------------------------------------- /myreadinglist/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PyBites Books | {% block title %}Because we love to read and learn!{% endblock %} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% block javascript %}{% endblock %} 31 | 32 | 33 | {% block css %}{% endblock %} 34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 | 42 | 43 | 67 | 68 |
PyBites Books 44 | 66 |
69 |
70 |
71 | 72 |
73 | 74 |
75 |

76 | 77 |
78 |
79 | 80 |
81 | {% for message in messages %} 82 |

83 | {% if 'safe' in message.tags %}{{ message|safe }}{% else %}{{ message }}{% endif %} 84 |

85 | {% endfor %} 86 |
87 | 88 | {% block search_books %} 89 |
90 | 92 |
93 |
94 | {% endblock %} 95 | 96 | {% block content %} 97 | {% endblock %} 98 | 99 |
100 |
101 | 102 |
103 | 104 |
105 |
106 |

107 | A reader lives a thousand lives before he dies, said Jojen. The man who never reads lives only one. - George R.R. Martin, A Dance with Dragons 108 |
Made with ♥ using MUICSS by PyBites | Want to Learn Python? 109 |

110 |
111 |
112 | 113 | 116 | 117 | 118 | 119 | {% block js %}{% endblock %} 120 | 121 | 122 | -------------------------------------------------------------------------------- /books/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.contrib.auth.models import User 4 | from django.utils import timezone 5 | 6 | from lists.models import UserList 7 | 8 | READING = "r" 9 | COMPLETED = "c" 10 | TO_READ = "t" 11 | QUOTE = "q" 12 | NOTE = "n" 13 | 14 | 15 | class Category(models.Model): 16 | name = models.CharField(max_length=128) 17 | 18 | def __str__(self): 19 | return self.name 20 | 21 | class Meta: 22 | verbose_name_plural = "categories" 23 | 24 | 25 | class Book(models.Model): 26 | bookid = models.CharField(max_length=20, unique=True) # google bookid 27 | title = models.CharField(max_length=300) 28 | authors = models.CharField(max_length=200) 29 | publisher = models.CharField(max_length=100) 30 | published = models.CharField(max_length=30) 31 | isbn = models.CharField(max_length=30) 32 | pages = models.CharField(max_length=5) 33 | language = models.CharField(max_length=2) 34 | description = models.TextField() 35 | imagesize = models.CharField(max_length=2, default="1") 36 | inserted = models.DateTimeField(auto_now_add=True) 37 | edited = models.DateTimeField(auto_now=True) 38 | categories = models.ManyToManyField(Category, related_name="categories") 39 | 40 | @property 41 | def title_and_authors(self): 42 | return f"{self.title} ({self.authors})" 43 | 44 | @property 45 | def url(self): 46 | return f"{settings.DOMAIN}/books/{self.bookid}" 47 | 48 | def __str__(self): 49 | return f"{self.id} {self.bookid} {self.title}" 50 | 51 | def __repr__(self): 52 | return ( 53 | f"{self.__class__.__name__}('{self.id}', " 54 | f"'{self.bookid}', '{self.title}', '{self.authors}', " 55 | f"'{self.publisher}', '{self.published}', '{self.isbn}', " 56 | f"'{self.pages}', '{self.language}', '{self.description}')" 57 | ) 58 | 59 | 60 | class Search(models.Model): 61 | term = models.TextField() 62 | user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) 63 | inserted = models.DateTimeField(auto_now_add=True) 64 | 65 | def __str__(self): 66 | return self.term 67 | 68 | class Meta: 69 | verbose_name_plural = "searches" 70 | 71 | 72 | class UserBook(models.Model): 73 | READ_STATUSES = ( 74 | (READING, "I am reading this book"), 75 | (COMPLETED, "I have completed this book"), 76 | (TO_READ, "I want to read this book"), # t of 'todo' 77 | ) 78 | user = models.ForeignKey(User, on_delete=models.CASCADE) 79 | book = models.ForeignKey(Book, on_delete=models.CASCADE) 80 | status = models.CharField(max_length=1, choices=READ_STATUSES, default=COMPLETED) 81 | favorite = models.BooleanField(default=False) 82 | completed = models.DateTimeField(default=timezone.now) 83 | booklists = models.ManyToManyField(UserList, related_name="booklists") 84 | inserted = models.DateTimeField(auto_now_add=True) # != completed 85 | updated = models.DateTimeField(auto_now=True) 86 | 87 | @property 88 | def done_reading(self): 89 | return self.status == COMPLETED 90 | 91 | def __str__(self): 92 | return f"{self.user} {self.book} {self.status} {self.completed}" 93 | 94 | class Meta: 95 | # -favorite - False sorts before True so need to reverse 96 | ordering = ["-favorite", "-completed", "-id"] 97 | 98 | 99 | class BookNote(models.Model): 100 | NOTE_TYPES = ( 101 | (QUOTE, "Quote"), 102 | (NOTE, "Note"), 103 | ) 104 | user = models.ForeignKey(User, on_delete=models.CASCADE) 105 | book = models.ForeignKey(Book, on_delete=models.CASCADE, blank=True, null=True) 106 | userbook = models.ForeignKey( 107 | UserBook, on_delete=models.CASCADE, blank=True, null=True 108 | ) 109 | type_note = models.CharField(max_length=1, choices=NOTE_TYPES, default=NOTE) 110 | description = models.TextField() 111 | public = models.BooleanField(default=False) 112 | inserted = models.DateTimeField(auto_now_add=True) 113 | edited = models.DateTimeField(auto_now=True) 114 | 115 | @property 116 | def quote(self): 117 | return self.type_note == QUOTE 118 | 119 | @property 120 | def type_note_label(self): 121 | for note, label in self.NOTE_TYPES: 122 | if note == self.type_note: 123 | return label.lower() 124 | return None 125 | 126 | def __str__(self): 127 | return f"{self.user} {self.userbook} {self.type_note} {self.description} {self.public}" 128 | 129 | 130 | class Badge(models.Model): 131 | books = models.IntegerField() 132 | title = models.CharField(max_length=50) 133 | 134 | def __str__(self): 135 | return f"{self.books} -> {self.title}" 136 | 137 | 138 | class BookConversion(models.Model): 139 | """Cache table to store goodreads -> Google Books mapping""" 140 | 141 | goodreads_id = models.CharField(max_length=20) 142 | googlebooks_id = models.CharField(max_length=20, null=True, blank=True) 143 | inserted = models.DateTimeField(auto_now_add=True) 144 | 145 | def __str__(self): 146 | return f"{self.goodreads_id} -> {self.googlebooks_id}" 147 | 148 | 149 | class ImportedBook(models.Model): 150 | """Cache table for preview goodreads import data""" 151 | 152 | title = models.TextField() 153 | book = models.ForeignKey(Book, on_delete=models.CASCADE, null=True, blank=True) 154 | reading_status = models.CharField(max_length=20) 155 | date_completed = models.DateTimeField() 156 | book_status = models.CharField(max_length=20) 157 | user = models.ForeignKey(User, on_delete=models.CASCADE) 158 | 159 | def __str__(self): 160 | return f"{self.user} -> {self.title}" 161 | -------------------------------------------------------------------------------- /api/views.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import json 3 | from random import randint, choice 4 | 5 | from django.contrib.auth.models import User 6 | from django.http import HttpResponse, Http404 7 | from django.shortcuts import get_object_or_404 8 | 9 | from books.models import Book, UserBook 10 | 11 | 12 | def get_users(): 13 | user_books = defaultdict(list) 14 | books = UserBook.objects.select_related("user").all() 15 | for book in books: 16 | user_books[book.user.username].append(book) 17 | return user_books 18 | 19 | 20 | def get_user_last_book(username): 21 | user = get_object_or_404(User, username=username) 22 | 23 | books = UserBook.objects.select_related("book") 24 | books = books.filter(user=user).order_by("-inserted") 25 | if not books: 26 | raise Http404 27 | 28 | book = books[0] 29 | data = dict( 30 | bookid=book.book.bookid, 31 | title=book.book.title, 32 | url=book.book.url, 33 | authors=book.book.authors, 34 | published=book.book.published, 35 | isbn=book.book.isbn, 36 | pages=book.book.pages, 37 | language=book.book.language, 38 | description=book.book.description, 39 | imagesize=book.book.imagesize, 40 | ) 41 | return data 42 | 43 | 44 | def get_user_books(username): 45 | data = defaultdict(list) 46 | user = get_object_or_404(User, username=username) 47 | books = UserBook.objects.select_related("book").filter(user=user) 48 | 49 | for book in books: 50 | row = dict( 51 | bookid=book.book.bookid, 52 | title=book.book.title, 53 | url=book.book.url, 54 | authors=book.book.authors, 55 | favorite=book.favorite, 56 | published=book.book.published, 57 | isbn=book.book.isbn, 58 | pages=book.book.pages, 59 | language=book.book.language, 60 | description=book.book.description, 61 | imagesize=book.book.imagesize, 62 | ) 63 | data[book.status].append(row) 64 | return data 65 | 66 | 67 | def user_books(request, username=None): 68 | if username is None: 69 | data = get_users() 70 | else: 71 | data = get_user_books(username) 72 | 73 | json_data = json.dumps(data, indent=4, default=str, sort_keys=False) 74 | 75 | return HttpResponse(json_data, content_type="application/json") 76 | 77 | 78 | def get_random_book(grep=None): 79 | books = UserBook.objects.select_related("book").all() 80 | 81 | if grep is not None: 82 | books = books.filter(book__title__icontains=grep.lower()) 83 | if not books: 84 | raise Http404 85 | book = choice(books) 86 | else: 87 | count = books.count() 88 | book = books[randint(0, count - 1)] 89 | 90 | data = dict( 91 | bookid=book.book.bookid, 92 | title=book.book.title, 93 | url=book.book.url, 94 | authors=book.book.authors, 95 | published=book.book.published, 96 | isbn=book.book.isbn, 97 | pages=book.book.pages, 98 | language=book.book.language, 99 | description=book.book.description, 100 | imagesize=book.book.imagesize, 101 | ) 102 | 103 | return data 104 | 105 | 106 | def random_book(request, grep=None): 107 | """Return a random book with optional filter""" 108 | data = get_random_book(grep) 109 | 110 | json_data = json.dumps(data, indent=4, default=str, sort_keys=False) 111 | 112 | return HttpResponse(json_data, content_type="application/json") 113 | 114 | 115 | def get_bookid(request, bookid): 116 | books = Book.objects.filter(bookid=bookid) 117 | if not books: 118 | raise Http404 119 | 120 | book = books[0] 121 | data = dict( 122 | bookid=book.bookid, 123 | title=book.title, 124 | url=book.url, 125 | authors=book.authors, 126 | publisher=book.publisher, 127 | published=book.published, 128 | isbn=book.isbn, 129 | pages=book.pages, 130 | language=book.language, 131 | description=book.description, 132 | imagesize=book.imagesize, 133 | ) 134 | 135 | json_data = json.dumps(data, indent=4, default=str, sort_keys=False) 136 | 137 | return HttpResponse(json_data, content_type="application/json") 138 | 139 | 140 | def get_book_list(request, name): 141 | books = ( 142 | UserBook.objects.select_related("book") 143 | .filter(booklists__name=name) 144 | .order_by("book__title") 145 | ) 146 | 147 | if not books: 148 | raise Http404 149 | 150 | data = [] 151 | for book in books: 152 | book_obj = dict( 153 | bookid=book.book.bookid, 154 | title=book.book.title, 155 | url=book.book.url, 156 | authors=book.book.authors, 157 | pages=book.book.pages, 158 | description=book.book.description, 159 | ) 160 | data.append(book_obj) 161 | 162 | return HttpResponse( 163 | json.dumps(data, indent=4, default=str, sort_keys=False), 164 | content_type="application/json", 165 | ) 166 | 167 | 168 | def get_book_stats(request, username): 169 | user_books = UserBook.objects.select_related("book").filter(user__username=username) 170 | 171 | data = [] 172 | for user_book in user_books: 173 | row = dict( 174 | bookid=user_book.book.bookid, 175 | title=user_book.book.title, 176 | url=user_book.book.url, 177 | status=user_book.status, 178 | favorite=user_book.favorite, 179 | completed=user_book.completed, 180 | ) 181 | data.append(row) 182 | 183 | json_data = json.dumps(data, indent=4, default=str, sort_keys=False) 184 | return HttpResponse(json_data, content_type="application/json") 185 | -------------------------------------------------------------------------------- /myreadinglist/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for myreadinglist project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.0.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | from decouple import config, Csv 16 | import dj_database_url 17 | import sentry_sdk 18 | from sentry_sdk.integrations.django import DjangoIntegration 19 | 20 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 21 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 22 | 23 | 24 | # Quick-start development settings - unsuitable for production 25 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 26 | 27 | SECRET_KEY = config("SECRET_KEY") 28 | DEBUG = config("DEBUG", default=False, cast=bool) 29 | ENV = config("ENV", default="heroku") 30 | LOCAL = ENV.lower() == "local" 31 | ALLOWED_HOSTS = config("ALLOWED_HOSTS", cast=Csv()) 32 | 33 | EMAIL_HOST = "smtp.sendgrid.net" 34 | EMAIL_HOST_USER = "apikey" 35 | EMAIL_HOST_PASSWORD = config("SENDGRID_API_KEY") 36 | EMAIL_PORT = 587 37 | EMAIL_USE_TLS = True 38 | DEFAULT_FROM_EMAIL = config("FROM_EMAIL") 39 | 40 | if DEBUG: 41 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 42 | INTERNAL_IPS = ["127.0.0.1"] 43 | else: 44 | EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 45 | sentry_sdk.init( 46 | dsn=os.environ.get("SENTRY_DSN"), integrations=[DjangoIntegration()] 47 | ) 48 | 49 | PROD_DOMAIN = "https://pybitesbooks.com" 50 | DOMAIN = config("DOMAIN", default=PROD_DOMAIN) 51 | 52 | # Application definition 53 | DJANGO_APPS = [ 54 | "django.contrib.admin", 55 | "django.contrib.auth", 56 | "django.contrib.contenttypes", 57 | "django.contrib.sessions", 58 | "django.contrib.messages", 59 | "django.contrib.staticfiles", 60 | "django.contrib.humanize", 61 | ] 62 | EXTERNAL_APPS = [ 63 | "django_registration", 64 | "debug_toolbar", 65 | ] 66 | OWN_APPS = [ 67 | "myreadinglist", 68 | "books", 69 | "pomodoro", 70 | "goal", 71 | "lists", 72 | ] 73 | INSTALLED_APPS = DJANGO_APPS + EXTERNAL_APPS + OWN_APPS 74 | 75 | MIDDLEWARE = [ 76 | "whitenoise.middleware.WhiteNoiseMiddleware", 77 | "debug_toolbar.middleware.DebugToolbarMiddleware", 78 | "django.middleware.security.SecurityMiddleware", 79 | "django.contrib.sessions.middleware.SessionMiddleware", 80 | "django.middleware.common.CommonMiddleware", 81 | "django.middleware.csrf.CsrfViewMiddleware", 82 | "django.contrib.auth.middleware.AuthenticationMiddleware", 83 | "django.contrib.messages.middleware.MessageMiddleware", 84 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 85 | ] 86 | 87 | ROOT_URLCONF = "myreadinglist.urls" 88 | 89 | TEMPLATES = [ 90 | { 91 | "BACKEND": "django.template.backends.django.DjangoTemplates", 92 | "DIRS": [ 93 | os.path.join(BASE_DIR, "myreadinglist/templates"), 94 | os.path.join(BASE_DIR, "myreadinglist/templates/registration"), 95 | ], 96 | "APP_DIRS": True, 97 | "OPTIONS": { 98 | "context_processors": [ 99 | "django.template.context_processors.debug", 100 | "django.template.context_processors.request", 101 | "django.contrib.auth.context_processors.auth", 102 | "django.contrib.messages.context_processors.messages", 103 | ], 104 | }, 105 | }, 106 | ] 107 | 108 | WSGI_APPLICATION = "myreadinglist.wsgi.application" 109 | 110 | 111 | # Database 112 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 113 | 114 | DATABASES = {"default": dj_database_url.config(default=config("DATABASE_URL"))} 115 | 116 | 117 | # Password validation 118 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 119 | 120 | AUTH_PASSWORD_VALIDATORS = [ 121 | { 122 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 123 | }, 124 | { 125 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 126 | }, 127 | { 128 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 129 | }, 130 | { 131 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 132 | }, 133 | ] 134 | 135 | 136 | # Internationalization 137 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 138 | 139 | LANGUAGE_CODE = "en-us" 140 | 141 | TIME_ZONE = "UTC" 142 | 143 | USE_I18N = True 144 | 145 | USE_L10N = True 146 | 147 | USE_TZ = True 148 | 149 | 150 | # Static files (CSS, JavaScript, Images) 151 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 152 | PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) 153 | STATIC_URL = "/static/" 154 | STATIC_ROOT = os.path.join(PROJECT_ROOT, "staticfiles") 155 | STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 156 | 157 | STATICFILES_DIRS = (os.path.join(PROJECT_ROOT, "static"),) 158 | 159 | LOGGING = { 160 | "version": 1, 161 | "disable_existing_loggers": False, 162 | "handlers": { 163 | "console": { 164 | "class": "logging.StreamHandler", 165 | }, 166 | }, 167 | "loggers": { 168 | "django": { 169 | "handlers": ["console"], 170 | "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), 171 | }, 172 | }, 173 | } 174 | LOGIN_URL = "login" 175 | LOGOUT_URL = "logout" 176 | LOGIN_REDIRECT_URL = "index" 177 | LOGOUT_REDIRECT_URL = "index" 178 | 179 | ACCOUNT_ACTIVATION_DAYS = 7 180 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 181 | 182 | # Celery settings 183 | CELERY_BROKER_URL = config("CELERY_BROKER_URL") 184 | 185 | # needed for big goodreads imports 186 | DATA_UPLOAD_MAX_NUMBER_FIELDS = None 187 | -------------------------------------------------------------------------------- /books/templates/import_books.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% load tags %} 4 | 5 | {% block title %}Import books from other services{% endblock %} 6 | 7 | {% block search_books %}{% endblock %} 8 | 9 | {% block css %} 10 | 24 | {% endblock %} 25 | 26 | {% block content %} 27 | 28 |
29 | 30 | {% if imported_books %} 31 |

Please confirm which books you want to import: 32 |

Upload a new file

33 |

34 | 35 |

36 | 37 |
38 | {% csrf_token %} 39 | 40 | 42 | 44 |

45 | 46 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | {% for imported_book in imported_books %} 63 | 64 | 68 | 71 | {% if imported_book.book_status == not_found %} 72 | 73 | {% else %} 74 | 77 | 81 | {% endif %} 82 | 100 | 110 | 119 | 120 | {% endfor %} 121 | 122 |
Import statusBookReading statusDate completedAdd
65 | {{ imported_book.book_status }} 66 | {{imported_book.title}} 67 | 69 | {{ imported_book.book_status }} 70 | {{ imported_book.title }} 75 | {{ imported_book.title }} 76 | 78 | 79 | {{ imported_book.title }} 80 | 83 | {% if imported_book.book_status == to_add %} 84 |
85 | 95 |
96 | {% else %} 97 | {{ imported_book.reading_status }} 98 | {% endif %} 99 |
101 | {% if imported_book.book_status == to_add %} 102 |
103 | 105 |
106 | {% else %} 107 | {{ imported_book.date_completed|date:"Y-m-d" }} 108 | {% endif %} 109 |
111 | {% if imported_book.book_status == to_add %} 112 |
113 | x icon 114 |
115 | 117 | {% endif %} 118 |
123 | 125 | 127 |

128 |
129 | 130 | {% else %} 131 | 132 |

133 | Import your goodreads books export csv [info] 134 |

135 | 136 |
137 | {% csrf_token %} 138 |
139 | {{ import_form.file.errors }} 140 | {{ import_form.file }} 141 |
142 | 144 |

145 |

You will get the opportunity to mark the ones you want to import ...

146 |
147 | 148 | {% endif %} 149 | 150 |
151 | 152 | {% block js %} 153 | 154 | 155 | {% endblock %} 156 | {% endblock %} 157 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from itertools import cycle 2 | 3 | from django.contrib.auth.models import User 4 | import pytest 5 | 6 | from books.models import Book, Category, UserBook 7 | 8 | 9 | @pytest.fixture(scope="module", autouse=True) 10 | def categories(django_db_setup, django_db_blocker): 11 | with django_db_blocker.unblock(): 12 | categories = [ 13 | "Computers / Programming / General", 14 | "Computers / Information Technology", 15 | "Self-Help / General", 16 | "Self-Help / Personal Growth / General", 17 | "Business & Economics / Personal Success", 18 | "Self-Help / Affirmations", 19 | "Body, Mind & Spirit / Inspiration & Personal Growth", 20 | "Body, Mind & Spirit / New Thought", 21 | "Health & Fitness / Alternative Therapies" 22 | "Self-Help / Personal Growth / Success", 23 | "Fiction / Science Fiction / Space Opera" 24 | "Fiction / Science Fiction / Action & Adventure", 25 | "Fiction / Science Fiction / Military", 26 | ] 27 | 28 | with django_db_blocker.unblock(): 29 | for category in categories: 30 | Category.objects.create(name=category) 31 | 32 | categories = Category.objects.all() 33 | return categories 34 | 35 | 36 | @pytest.fixture(scope="module") 37 | def books(django_db_setup, django_db_blocker, categories): 38 | with django_db_blocker.unblock(): 39 | book = Book( 40 | bookid="nneBa6-mWfgC", 41 | title="Coders at Work", 42 | authors="Peter Seibel", 43 | publisher="Apress", 44 | published="2009-09-16", 45 | isbn="978143021948463", 46 | pages=632, 47 | language="en", 48 | description="Peter Seibel interviews 15 of the most ...", 49 | ) 50 | book.save() 51 | book.categories.add(*categories[:2]) 52 | book.save() 53 | 54 | book = Book( 55 | bookid="__CvAFrcWY0C", 56 | title="Unlimited Power", 57 | authors="Tony Robbins", 58 | publisher="Simon and Schuster", 59 | published="2008-06-30", 60 | isbn="978141658637144", 61 | pages=448, 62 | language="en", 63 | description="Anthony Robbins calls it the ...", 64 | ) 65 | book.save() 66 | book.categories.add(*categories[2:6]) 67 | book.save() 68 | 69 | book = Book( 70 | bookid="3V_6DwAAQBAJ", 71 | title="Power Vs. Force", 72 | authors="David R. Hawkins", 73 | publisher="Hay House, Inc", 74 | published="2014", 75 | isbn="978140194507741", 76 | pages=412, 77 | language="en", 78 | description="Imagine—what if you had access to a simple ...", 79 | ) 80 | book.save() 81 | book.categories.add(*categories[6:9]) 82 | book.save() 83 | 84 | book = Book( 85 | bookid="bK1ktwAACAAJ", 86 | title="177 Mental Toughness Secrets of the World Class", 87 | authors="Steve Siebold", 88 | publisher="London House Press", 89 | published="2010", 90 | isbn="9780975500354", 91 | pages=281, 92 | language="en", 93 | description="NEW EDITION: Is it possible for a person of ...", 94 | ) 95 | book.save() 96 | book.categories.add(categories[9]) 97 | book.save() 98 | 99 | books = Book.objects.all() 100 | return books 101 | 102 | 103 | @pytest.fixture(scope="module") 104 | def two_books(django_db_setup, django_db_blocker, categories): 105 | with django_db_blocker.unblock(): 106 | book = Book( 107 | bookid="jaM7DwAAQBAJ", 108 | title="Ender's Game", 109 | authors="Orson Scott Card", 110 | publisher="Tom Doherty Associates", 111 | published="2017-10-17", 112 | isbn="9780765394866", 113 | pages=448, 114 | language="en", 115 | description="This engaging, collectible, ...", 116 | ) 117 | book.save() 118 | book.categories.add(*categories[10:]) 119 | book.save() 120 | 121 | book = Book( 122 | bookid="UCJMRAAACAAJ", 123 | title="Elantris", 124 | authors="Brandon Sanderson", 125 | publisher="Gollancz", 126 | published="2011", 127 | isbn="9780575097445", 128 | pages=656, 129 | language="en", 130 | description="Elantris was built on magic and it thrived ...", 131 | ) 132 | book.save() 133 | # this one did not yield categories from Google Books API 134 | 135 | added_titles = ["Ender's Game", "Elantris"] 136 | books = Book.objects.filter(title__in=added_titles) 137 | return books 138 | 139 | 140 | @pytest.fixture(scope="module") 141 | def user(django_db_setup, django_db_blocker): 142 | with django_db_blocker.unblock(): 143 | username, password = "user1", "bar" 144 | return User.objects.create_user(username=username, password=password) 145 | 146 | 147 | @pytest.fixture 148 | def login(django_db_setup, django_db_blocker, client, user): 149 | client.force_login(user) 150 | return client 151 | 152 | 153 | @pytest.fixture(scope="module") 154 | def user_books(django_db_setup, django_db_blocker, books, user): 155 | with django_db_blocker.unblock(): 156 | statuses = cycle("r c".split()) 157 | user_books = [ 158 | UserBook(user=user, book=book, status=next(statuses)) for book in books 159 | ] 160 | UserBook.objects.bulk_create(user_books) 161 | return UserBook.objects.all() 162 | 163 | 164 | @pytest.fixture(scope="module") 165 | def user_fav_books(django_db_setup, django_db_blocker, two_books, user): 166 | with django_db_blocker.unblock(): 167 | statuses = cycle("r c".split()) 168 | user_books = [ 169 | UserBook(user=user, book=book, status=next(statuses), favorite=True) 170 | for book in two_books 171 | ] 172 | UserBook.objects.bulk_create(user_books) 173 | return UserBook.objects.filter(favorite=True) 174 | -------------------------------------------------------------------------------- /books/templates/user.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% load humanize %} 4 | {% load tags %} 5 | 6 | {% block title %}{{ username|title }}{% if username|slice:"-1:" == "s" %}'{% else %}'s{% endif %} Reading List{% endblock %} 7 | {% block og_title %}{{ username|title }}{% if username|slice:"-1:" == "s" %}'{% else %}'s{% endif %} Reading List{% endblock %} 8 | {% block og_url %}{{ request.path }}{% endblock %} 9 | 10 | {% block css %} 11 | 25 | {% endblock %} 26 | 27 | {% block content %} 28 |
29 |
30 |
31 |
32 | {% if username == request.user.username %} 33 | Your 34 | {% else %} 35 | {{ username|title }}{% if username|slice:"-1:" == "s" %}'{% else %}'s{% endif %} 36 | {% endif %} 37 | Reading List 38 |
39 |
40 | {% if share_goal %} 41 |
42 | {% if username == request.user.username %}You have{% else %}{{ username }} has{% endif %} read {{completed_books_this_year|length}} of {{ goal.number_books }} books this year. {% if username == request.user.username %}{edit}{% endif %} 43 | 44 |

45 | 46 | {{ perc_completed }}% 47 | 48 |

49 | {% if username == request.user.username %} 50 | 51 | Share on Twitter 52 |

53 | {% endif %} 54 |
55 | {% endif %} 56 | 57 |
58 | Total reading: {{ user_stats.num_books_added }} books added, of which {{ user_stats.num_books_done }} read totalling {{ user_stats.num_pages_read|intcomma }} pages. 59 |
60 | 61 | {% if user_lists %} 62 |
63 | Reading lists: 64 | {% for ul in user_lists %} 65 | {{ ul.name }} 66 | {% if not forloop.last %} | {% endif %} 67 | {% endfor %} 68 | {% if is_me %} 69 | | + 70 | {% endif %} 71 |
72 | {% endif %} 73 | 74 |
75 | 76 |
77 |
78 | 79 | book badge 80 | 81 | {{ user_stats.num_books_done }} 82 |
83 |
84 |
85 |
86 | 87 |

88 | 89 | {% if not user_stats.num_books_added %} 90 |
91 | {% if username == request.user.username %}You{% else %}{{ username }}{% endif %} did not add any books yet. 92 |
93 | 94 | {% else %} 95 | 96 | 113 | 114 | {% if share_goal %} 115 |
116 |
117 |
118 | {% if completed_books_this_year %} 119 | {% for book in completed_books_this_year %} 120 | 121 | {{ book.book.title }} 122 | 123 | 124 | {% endfor %} 125 | {% else %} 126 |

No books read yet for this year's challenge.

127 | {% endif %} 128 |
129 |
130 | {% endif %} 131 | 132 | {% for books in grouped_user_books.values %} 133 | 157 | {% endfor %} 158 | {% endif %} 159 | 160 | {% if request.user.username == username %} 161 |
162 |
163 |

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 |
166 | {% endif %} 167 | 168 | {% endblock %} 169 | 170 | {% block javascript %} 171 | 172 | 173 | {% endblock %} 174 | -------------------------------------------------------------------------------- /books/templates/book.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load static %} 3 | {% load tags %} 4 | 5 | {% block title %}{{ book.title }}{% endblock %} 6 | {% block og_title %}{{ book.title }}{% endblock %} 7 | {% block og_url %}{{ request.path }}{% endblock %} 8 | 9 | {% block content %} 10 | 11 | {% if book.categories.count > 0 %} 12 |
    13 | {% for category in book.categories.all %} 14 |
  • 15 | {{ category.name }} 16 |
  • 17 | {% endfor %} 18 |
19 | {% endif %} 20 | 21 | 25 | 26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 | {{ book.title }} 35 |
36 |
37 | 38 |
39 |

{{ book.title }}

40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 118 | 119 | 120 | 121 | 122 |
Author(s){{ book.authors }}
Publisher{{ book.publisher }}
Published{{ book.published }}
ISBN{{ book.isbn }}
Page Count{{ book.pages }}
Language{{ book.language }}
69 | {% if user.is_authenticated %} 70 |
71 | {% csrf_token %} 72 | 73 |
74 | {{ book_form.status.errors }} 75 | {{ book_form.status }} 76 |
77 | 78 |
79 | {{ book_form.completed.errors }} 80 | {{ book_form.completed }} 81 |
82 | 83 | {% if user_lists %} 84 |

Add to one or more lists:

85 | {% for ul in user_lists %} 86 |
87 | 97 |
98 | {% endfor %} 99 | manage lists 100 | {% endif %} 101 | 102 |
103 | 104 | 105 | {% if userbook %} 106 | 107 | {% endif %} 108 |
109 | 110 |
111 | 112 | {% else %} 113 | 114 | Add Book 115 | 116 | {% endif %} 117 |
123 | 124 |
125 | 126 |
127 | 128 | 129 | 130 |
131 | 132 | {% if book_users %} 133 |

Read by:

134 | 135 | {% for user in book_users %} 136 | {{ user.username|slice:":2"|upper }} 137 | {% endfor %} 138 | 139 | {% endif %} 140 | 141 | {% if book_on_lists %} 142 |
143 |

On lists:

144 | 145 | {% for list_name in book_on_lists %} 146 | - {{ list_name }}
147 | {% endfor %} 148 | {% endif %} 149 | 150 |
151 | 152 |
153 | 154 |
155 |
156 | 157 |
158 | 159 |
160 |
161 |
162 | {{ book.description|safe }} 163 |
164 |
165 | 166 | 167 | {% if notes or userbook %} 168 | 169 |

170 | 171 |
Notes & Quotes
172 |
173 | 174 | {% if userbook %} 175 |
176 |
177 | {% csrf_token %} 178 |
179 | 183 |
184 |
185 | 186 |
187 |
188 | 192 |
193 | 194 | 195 |
196 |
197 | {% endif %} 198 | 199 | 200 | {% for note in notes %} 201 |
202 | {% if note.user == request.user %} 203 |
204 | {% csrf_token %} 205 |
206 | 210 |
211 |
212 | 213 |
214 |
215 | 219 |
220 | 221 | 222 | 223 | 224 |
225 | 226 | {% else %} 227 | 228 |

{{ 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 |
237 | {% endfor %} 238 | 239 | {% endif %} 240 | 241 | {% endblock %} 242 | -------------------------------------------------------------------------------- /books/views.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from collections import OrderedDict, namedtuple, defaultdict 3 | from datetime import date, datetime 4 | from io import StringIO 5 | 6 | from django.contrib import messages 7 | from django.contrib.auth.models import User 8 | from django.db.models import Q 9 | from django.shortcuts import render, redirect, get_object_or_404 10 | from django.utils import timezone 11 | from django.views.decorators.clickjacking import xframe_options_exempt 12 | from django.contrib.auth.decorators import login_required 13 | from django.http import JsonResponse 14 | import pytz 15 | 16 | from .goodreads import BookImportStatus, GOOGLE_TO_GOODREADS_READ_STATUSES 17 | from .googlebooks import get_book_info 18 | from .forms import UserBookForm, ImportBooksForm 19 | from .models import ( 20 | Category, 21 | Book, 22 | UserBook, 23 | BookNote, 24 | ImportedBook, 25 | READING, 26 | COMPLETED, 27 | TO_READ, 28 | ) 29 | from .tasks import retrieve_google_books 30 | from goal.models import Goal 31 | from lists.models import UserList 32 | 33 | MIN_NUM_BOOKS_SHOW_SEARCH = 20 34 | TO_ADD = BookImportStatus.TO_BE_ADDED.name 35 | NOT_FOUND = BookImportStatus.COULD_NOT_FIND.name 36 | REQUIRED_GOODREADS_FIELDS = ( 37 | "Title", 38 | "Author", 39 | "Exclusive Shelf", 40 | "Date Read", 41 | "Date Added", 42 | "Book Id", 43 | ) 44 | UserStats = namedtuple( 45 | "UserStats", ["num_books_added", "num_books_done", "num_pages_read"] 46 | ) 47 | 48 | 49 | def book_page(request, bookid): 50 | post = request.POST 51 | 52 | try: 53 | book = get_book_info(bookid) 54 | except KeyError: 55 | messages.error(request, f"Could not retrieve book {bookid}") 56 | return redirect("index") 57 | 58 | userbook = None 59 | if request.user.is_authenticated: 60 | try: 61 | userbook = UserBook.objects.get(user=request.user, book=book) 62 | except UserBook.DoesNotExist: 63 | pass 64 | 65 | # a form was submitted 66 | book_edit = post.get("addOrEditBook") 67 | note_submit = post.get("noteSubmit") 68 | 69 | # book form 70 | if book_edit: 71 | status = post.get("status") 72 | completed = post.get("completed") or None 73 | 74 | userlists = post.getlist("userlists[]", []) 75 | booklists = UserList.objects.filter(name__in=userlists) 76 | 77 | if completed: 78 | completed = timezone.datetime.strptime(completed, "%Y-%m-%d") 79 | 80 | # this works without pk because Userbook has max 1 entry for user+book 81 | userbook, created = UserBook.objects.get_or_create(book=book, user=request.user) 82 | userbook.booklists.set(booklists) 83 | 84 | action = None 85 | if created: 86 | action = "added" 87 | elif userbook.user != request.user: 88 | messages.error(request, "You can only edit your own books") 89 | return redirect("book_page") 90 | 91 | if post.get("deleteBook"): 92 | action = "deleted" 93 | userbook.delete() 94 | userbook = None 95 | else: 96 | action = "updated" 97 | userbook.status = status 98 | userbook.completed = completed 99 | userbook.save() 100 | 101 | messages.success(request, f"Successfully {action} book") 102 | 103 | # note form (need a valid userbook object!) 104 | elif userbook and note_submit: 105 | type_note = post.get("type_note") 106 | description = post.get("description") 107 | public = post.get("public") and True or False 108 | 109 | noteid = post.get("noteid") 110 | action = None 111 | # delete/ update 112 | if noteid: 113 | try: 114 | usernote = BookNote.objects.get(pk=noteid, user=request.user) 115 | except BookNote.DoesNotExist: 116 | messages.error(request, "Note does not exist for this user") 117 | return redirect("book_page") 118 | 119 | if usernote: 120 | if post.get("deleteNote"): 121 | action = "deleted" 122 | usernote.delete() 123 | usernote = None 124 | else: 125 | action = "updated" 126 | usernote.type_note = type_note 127 | usernote.description = description 128 | usernote.public = public 129 | usernote.save() 130 | # add 131 | else: 132 | action = "added" 133 | usernote = BookNote( 134 | user=request.user, 135 | book=book, 136 | userbook=userbook, 137 | type_note=type_note, 138 | description=description, 139 | public=public, 140 | ) 141 | usernote.save() 142 | 143 | messages.success(request, f"Successfully {action} note") 144 | 145 | # prepare book form (note form = multiple = best manual) 146 | if userbook: 147 | # make sure to bounce back previously entered form values 148 | book_form = UserBookForm( 149 | initial=dict(status=userbook.status, completed=userbook.completed) 150 | ) 151 | else: 152 | book_form = UserBookForm() 153 | 154 | # all notes (do last as new note might have been added) 155 | book_notes = BookNote.objects.select_related("user") 156 | if request.user.is_authenticated: 157 | filter_criteria = Q(book=book) & (Q(user=request.user) | Q(public=True)) 158 | notes = book_notes.filter(filter_criteria) 159 | else: 160 | notes = book_notes.filter(book=book, public=True) 161 | notes = notes.order_by("-edited").all() 162 | 163 | # userbooks has some dup, make sure we deduplicate 164 | # them for template display 165 | book_users = sorted( 166 | { 167 | ub.user 168 | for ub in UserBook.objects.select_related("user").filter( 169 | book=book, status=COMPLETED 170 | ) 171 | }, 172 | key=lambda user: user.username.lower(), 173 | ) 174 | 175 | user_lists = [] 176 | if request.user.is_authenticated: 177 | user_lists = UserList.objects.filter(user=request.user) 178 | 179 | userbook_lists = {} 180 | if userbook: 181 | userbook_lists = {ul.name for ul in userbook.booklists.all()} 182 | 183 | book_on_lists = [ 184 | ul.userlist.name 185 | for ul in UserBook.booklists.through.objects.select_related( 186 | "userbook__book" 187 | ).filter(userbook__book=book) 188 | ] 189 | 190 | return render( 191 | request, 192 | "book.html", 193 | { 194 | "book": book, 195 | "notes": notes, 196 | "userbook": userbook, 197 | "userbook_lists": userbook_lists, 198 | "book_form": book_form, 199 | "book_users": book_users, 200 | "user_lists": user_lists, 201 | "book_on_lists": book_on_lists, 202 | }, 203 | ) 204 | 205 | 206 | def books_per_category(request, category_name): 207 | category = get_object_or_404(Category, name=category_name) 208 | user_books = ( 209 | UserBook.objects.select_related("book", "user") 210 | .filter(book__categories__id=category.id) 211 | .all() 212 | ) 213 | 214 | users_by_bookid = defaultdict(set) 215 | for ub in user_books: 216 | bookid = ub.book.bookid 217 | users_by_bookid[bookid].add(ub.user) 218 | 219 | users_by_bookid_sorted = { 220 | bookid: sorted(users, key=lambda user: user.username.lower()) 221 | for bookid, users in users_by_bookid.items() 222 | } 223 | 224 | # only show books added by users (vs. just navigated) 225 | # there are some dups in db unfortunately 226 | books = sorted({ub.book for ub in user_books}, key=lambda book: book.title.lower()) 227 | 228 | context = { 229 | "category": category, 230 | "books": books, 231 | "users_by_bookid": users_by_bookid_sorted, 232 | } 233 | return render(request, "category.html", context) 234 | 235 | 236 | def get_user_goal(user): 237 | try: 238 | goal = Goal.objects.get(year=date.today().year, user=user, number_books__gt=0) 239 | except Goal.DoesNotExist: 240 | goal = None 241 | return goal 242 | 243 | 244 | def group_userbooks_by_status(books): 245 | userbooks = OrderedDict([(READING, []), (COMPLETED, []), (TO_READ, [])]) 246 | for book in books: 247 | userbooks[book.status].append(book) 248 | return userbooks 249 | 250 | 251 | def get_num_pages_read(books): 252 | return sum( 253 | int(book.book.pages) if str(book.book.pages).isdigit() else 0 254 | for book in books 255 | if book.done_reading 256 | ) 257 | 258 | 259 | def user_page(request, username): 260 | user = get_object_or_404(User, username=username) 261 | user_books = UserBook.objects.select_related("book").filter(user=user) 262 | 263 | completed_books_this_year, perc_completed = [], 0 264 | goal = get_user_goal(user) 265 | 266 | if goal is not None: 267 | completed_books_this_year = UserBook.objects.filter( 268 | user=user, status=COMPLETED, completed__year=goal.year 269 | ) 270 | 271 | if goal.number_books > 0: 272 | perc_completed = int( 273 | completed_books_this_year.count() / goal.number_books * 100 274 | ) 275 | 276 | is_me = request.user.is_authenticated and request.user == user 277 | share_goal = goal and (goal.share or is_me) 278 | 279 | grouped_user_books = group_userbooks_by_status(user_books) 280 | 281 | user_stats = UserStats( 282 | num_books_added=len(user_books), 283 | num_books_done=len(grouped_user_books[COMPLETED]), 284 | num_pages_read=get_num_pages_read(user_books), 285 | ) 286 | user_lists = UserList.objects.filter(user=user) 287 | 288 | return render( 289 | request, 290 | "user.html", 291 | { 292 | "grouped_user_books": grouped_user_books, 293 | "username": username, 294 | "user_stats": user_stats, 295 | "goal": goal, 296 | "share_goal": share_goal, 297 | "completed_books_this_year": completed_books_this_year, 298 | "perc_completed": perc_completed, 299 | "min_books_search": MIN_NUM_BOOKS_SHOW_SEARCH, 300 | "is_me": is_me, 301 | "user_lists": user_lists, 302 | }, 303 | ) 304 | 305 | 306 | @xframe_options_exempt 307 | def user_page_widget(request, username): 308 | user = get_object_or_404(User, username=username) 309 | books = UserBook.objects.select_related("book").filter(user=user, status="c") 310 | return render(request, "widget.html", {"books": books}) 311 | 312 | 313 | @login_required 314 | def user_favorite(request): 315 | user = request.user 316 | book = request.GET.get("book") 317 | checked = True if request.GET.get("checked") == "true" else False 318 | userbook = UserBook.objects.get(user__username=user, book__bookid=book) 319 | userbook.favorite = checked 320 | userbook.save() 321 | return JsonResponse({"status": "success"}) 322 | 323 | 324 | def _is_valid_csv(file_content, required_fields=REQUIRED_GOODREADS_FIELDS): 325 | reader = csv.DictReader(StringIO(file_content), delimiter=",") 326 | header = next(reader) 327 | return all(field in header for field in required_fields) 328 | 329 | 330 | @login_required 331 | def import_books(request): 332 | is_preview = request.path.endswith("preview") 333 | post = request.POST 334 | files = request.FILES 335 | import_form = ImportBooksForm() 336 | imported_books = [] 337 | 338 | if "delete_import" in post: 339 | num_deleted, _ = ImportedBook.objects.filter(user=request.user).delete() 340 | msg = f"Deleted import ({num_deleted} books)" 341 | messages.success(request, msg) 342 | return redirect("books:import_books") 343 | 344 | elif "save_import_submit" in post: 345 | books_to_add = post.getlist("books_to_add") 346 | read_statuses = post.getlist("read_statuses") 347 | dates = post.getlist("dates") 348 | 349 | new_user_book_count = 0 350 | for bookid, read_status, read_date in zip(books_to_add, read_statuses, dates): 351 | completed_dt = pytz.utc.localize(datetime.strptime(read_date, "%Y-%m-%d")) 352 | book = Book.objects.filter(bookid=bookid).order_by("inserted").last() 353 | 354 | # make sure we don't store books twice 355 | user_book, created = UserBook.objects.get_or_create( 356 | user=request.user, book=book 357 | ) 358 | 359 | # if book is already in user's collection update status and 360 | # completed date 361 | user_book.status = read_status 362 | user_book.completed = completed_dt 363 | user_book.save() 364 | 365 | if created: 366 | new_user_book_count += 1 367 | 368 | # delete the cached items 369 | ImportedBook.objects.filter(user=request.user).delete() 370 | 371 | messages.success(request, f"{new_user_book_count} books inserted") 372 | return redirect("user_page", username=request.user.username) 373 | 374 | elif "import_books_submit" in post: 375 | import_form = ImportBooksForm(post, files) 376 | if import_form.is_valid(): 377 | file_content = files["file"].read().decode("utf-8") 378 | if not _is_valid_csv(file_content): 379 | error = ( 380 | "Sorry, the provided csv file does " 381 | "not match the goodreads format (" 382 | "required fields: " 383 | f"{', '.join(REQUIRED_GOODREADS_FIELDS)})" 384 | ) 385 | messages.error(request, error) 386 | return redirect("books:import_books") 387 | 388 | username = request.user.username 389 | 390 | retrieve_google_books.delay(file_content, username) 391 | 392 | msg = ( 393 | "Thanks, we're processing your goodreads csv file. " 394 | "We'll notify you by email when you can select " 395 | "books for import into your PyBites Books account." 396 | ) 397 | messages.success(request, msg) 398 | return redirect("books:import_books") 399 | 400 | elif is_preview: 401 | imported_books = ImportedBook.objects.filter(user=request.user).order_by( 402 | "title" 403 | ) 404 | num_add_books = imported_books.filter(book_status=TO_ADD).count() 405 | if num_add_books == 0: 406 | error = "No new books to be imported" 407 | messages.error(request, error) 408 | return redirect("books:import_books") 409 | 410 | context = { 411 | "import_form": import_form, 412 | "imported_books": imported_books, 413 | "not_found": NOT_FOUND, 414 | "to_add": TO_ADD, 415 | "all_read_statuses": GOOGLE_TO_GOODREADS_READ_STATUSES.items(), 416 | } 417 | return render(request, "import_books.html", context) 418 | --------------------------------------------------------------------------------