diff --git a/.gitignore b/.gitignore index 68bc17f..2dc53ca 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a37dd54 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-json + - id: check-yaml + - id: debug-statements + - id: detect-private-key +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.6.9 + hooks: + # Run the linter. + - id: ruff + types_or: [ python, pyi ] + args: [ --fix ] + # Run the formatter. + - id: ruff-format + types_or: [ python, pyi ] diff --git a/README.md b/README.md index 4455474..fb68903 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Our simple yet effective reading tracking app: [PyBites Books](https://pybitesbo ## Setup +![](https://img.shields.io/badge/Python-3.9.0-blue.svg) +![](https://img.shields.io/badge/Django-4.1.13-blue.svg) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) + 1. Create a [virtual env](https://pybit.es/the-beauty-of-virtualenv.html) and activate it (`source venv/bin/activate`) 2. Install the dependencies: `pip install -r requirements.txt` 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`. @@ -15,6 +19,17 @@ Our simple yet effective reading tracking app: [PyBites Books](https://pybitesbo 5. Sync the DB: `python manage.py migrate`. 6. And finally run the app server: `python manage.py runserver`. +## Run pre-commit + +Install the git hook scripts +```bash + pre-commit install +``` +Run against all the files: +```bash + pre-commit run --all-files +``` + ## Local Via docker-compose 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. diff --git a/api/apps.py b/api/apps.py index d87006d..14b89a8 100644 --- a/api/apps.py +++ b/api/apps.py @@ -2,4 +2,4 @@ class ApiConfig(AppConfig): - name = 'api' + name = "api" diff --git a/api/urls.py b/api/urls.py index 023005a..64d6912 100644 --- a/api/urls.py +++ b/api/urls.py @@ -2,13 +2,13 @@ from . import views -app_name = 'api' +app_name = "api" urlpatterns = [ - path('users', views.user_books, name='user_books'), - path('users/', views.user_books, name='user_books'), - path('random', views.random_book, name='random_book'), - path('random/', views.random_book, name='random_book'), - path('books/', views.get_bookid, name='get_bookid'), - path('lists/', views.get_book_list, name='get_book_list'), - path('stats/', views.get_book_stats, name='get_book_stats'), + path("users", views.user_books, name="user_books"), + path("users/", views.user_books, name="user_books"), + path("random", views.random_book, name="random_book"), + path("random/", views.random_book, name="random_book"), + path("books/", views.get_bookid, name="get_bookid"), + path("lists/", views.get_book_list, name="get_book_list"), + path("stats/", views.get_book_stats, name="get_book_stats"), ] diff --git a/api/views.py b/api/views.py index 081235a..6357d02 100644 --- a/api/views.py +++ b/api/views.py @@ -11,7 +11,7 @@ def get_users(): user_books = defaultdict(list) - books = UserBook.objects.select_related('user').all() + books = UserBook.objects.select_related("user").all() for book in books: user_books[book.user.username].append(book) return user_books @@ -20,48 +20,51 @@ def get_users(): def get_user_last_book(username): user = get_object_or_404(User, username=username) - books = UserBook.objects.select_related('book') - books = books.filter(user=user).order_by('-inserted') + books = UserBook.objects.select_related("book") + books = books.filter(user=user).order_by("-inserted") if not books: raise Http404 book = books[0] - data = dict(bookid=book.book.bookid, - title=book.book.title, - url=book.book.url, - authors=book.book.authors, - published=book.book.published, - isbn=book.book.isbn, - pages=book.book.pages, - language=book.book.language, - description=book.book.description, - imagesize=book.book.imagesize) + data = dict( + bookid=book.book.bookid, + title=book.book.title, + url=book.book.url, + authors=book.book.authors, + published=book.book.published, + isbn=book.book.isbn, + pages=book.book.pages, + language=book.book.language, + description=book.book.description, + imagesize=book.book.imagesize, + ) return data def get_user_books(username): data = defaultdict(list) user = get_object_or_404(User, username=username) - books = UserBook.objects.select_related('book').filter(user=user) + books = UserBook.objects.select_related("book").filter(user=user) for book in books: - row = dict(bookid=book.book.bookid, - title=book.book.title, - url=book.book.url, - authors=book.book.authors, - favorite=book.favorite, - published=book.book.published, - isbn=book.book.isbn, - pages=book.book.pages, - language=book.book.language, - description=book.book.description, - imagesize=book.book.imagesize) + row = dict( + bookid=book.book.bookid, + title=book.book.title, + url=book.book.url, + authors=book.book.authors, + favorite=book.favorite, + published=book.book.published, + isbn=book.book.isbn, + pages=book.book.pages, + language=book.book.language, + description=book.book.description, + imagesize=book.book.imagesize, + ) data[book.status].append(row) return data def user_books(request, username=None): - if username is None: data = get_users() else: @@ -69,11 +72,11 @@ def user_books(request, username=None): json_data = json.dumps(data, indent=4, default=str, sort_keys=False) - return HttpResponse(json_data, content_type='application/json') + return HttpResponse(json_data, content_type="application/json") def get_random_book(grep=None): - books = UserBook.objects.select_related('book').all() + books = UserBook.objects.select_related("book").all() if grep is not None: books = books.filter(book__title__icontains=grep.lower()) @@ -84,16 +87,18 @@ def get_random_book(grep=None): count = books.count() book = books[randint(0, count - 1)] - data = dict(bookid=book.book.bookid, - title=book.book.title, - url=book.book.url, - authors=book.book.authors, - published=book.book.published, - isbn=book.book.isbn, - pages=book.book.pages, - language=book.book.language, - description=book.book.description, - imagesize=book.book.imagesize) + data = dict( + bookid=book.book.bookid, + title=book.book.title, + url=book.book.url, + authors=book.book.authors, + published=book.book.published, + isbn=book.book.isbn, + pages=book.book.pages, + language=book.book.language, + description=book.book.description, + imagesize=book.book.imagesize, + ) return data @@ -104,7 +109,7 @@ def random_book(request, grep=None): json_data = json.dumps(data, indent=4, default=str, sort_keys=False) - return HttpResponse(json_data, content_type='application/json') + return HttpResponse(json_data, content_type="application/json") def get_bookid(request, bookid): @@ -113,29 +118,31 @@ def get_bookid(request, bookid): raise Http404 book = books[0] - data = dict(bookid=book.bookid, - title=book.title, - url=book.url, - authors=book.authors, - publisher=book.publisher, - published=book.published, - isbn=book.isbn, - pages=book.pages, - language=book.language, - description=book.description, - imagesize=book.imagesize) + data = dict( + bookid=book.bookid, + title=book.title, + url=book.url, + authors=book.authors, + publisher=book.publisher, + published=book.published, + isbn=book.isbn, + pages=book.pages, + language=book.language, + description=book.description, + imagesize=book.imagesize, + ) json_data = json.dumps(data, indent=4, default=str, sort_keys=False) - return HttpResponse(json_data, content_type='application/json') + return HttpResponse(json_data, content_type="application/json") def get_book_list(request, name): - books = UserBook.objects.select_related( - "book" - ).filter( - booklists__name=name - ).order_by("book__title") + books = ( + UserBook.objects.select_related("book") + .filter(booklists__name=name) + .order_by("book__title") + ) if not books: raise Http404 @@ -148,30 +155,30 @@ def get_book_list(request, name): url=book.book.url, authors=book.book.authors, pages=book.book.pages, - description=book.book.description) + description=book.book.description, + ) data.append(book_obj) return HttpResponse( - json.dumps( - data, indent=4, default=str, sort_keys=False), - content_type='application/json' + json.dumps(data, indent=4, default=str, sort_keys=False), + content_type="application/json", ) def get_book_stats(request, username): - user_books = UserBook.objects.select_related( - 'book' - ).filter(user__username=username) + user_books = UserBook.objects.select_related("book").filter(user__username=username) data = [] for user_book in user_books: - row = dict(bookid=user_book.book.bookid, - title=user_book.book.title, - url=user_book.book.url, - status=user_book.status, - favorite=user_book.favorite, - completed=user_book.completed) + row = dict( + bookid=user_book.book.bookid, + title=user_book.book.title, + url=user_book.book.url, + status=user_book.status, + favorite=user_book.favorite, + completed=user_book.completed, + ) data.append(row) json_data = json.dumps(data, indent=4, default=str, sort_keys=False) - return HttpResponse(json_data, content_type='application/json') + return HttpResponse(json_data, content_type="application/json") diff --git a/books/admin.py b/books/admin.py index 3d4aa1f..ca618c7 100644 --- a/books/admin.py +++ b/books/admin.py @@ -2,9 +2,16 @@ from django.contrib import admin from django.utils.safestring import mark_safe -from .models import (Category, Book, Search, UserBook, - BookNote, Badge, BookConversion, - ImportedBook) +from .models import ( + Category, + Book, + Search, + UserBook, + BookNote, + Badge, + BookConversion, + ImportedBook, +) class CategoryAdmin(admin.ModelAdmin): @@ -23,7 +30,11 @@ class SearchAdmin(admin.ModelAdmin): class UserBookAdmin(admin.ModelAdmin): list_display = ("user", "book", "status", "favorite", "completed", "inserted") - search_fields = ("user__username", "book__title", "book__bookid",) + search_fields = ( + "user__username", + "book__title", + "book__bookid", + ) list_filter = ("status", "favorite") @@ -39,7 +50,8 @@ def short_desc(self, obj): return f"{obj.description[:limit]} ..." else: return obj.description - short_desc.short_description = 'short description' + + short_desc.short_description = "short description" class BadgeAdmin(admin.ModelAdmin): @@ -55,12 +67,19 @@ def book_link(self, obj): f"{obj.googlebooks_id}" ) - book_link.short_description = 'Google / PyBites book link' + + book_link.short_description = "Google / PyBites book link" class ImportedBookAdmin(admin.ModelAdmin): - list_display = ("title", "book", "reading_status", "date_completed", - "book_status", "user") + list_display = ( + "title", + "book", + "reading_status", + "date_completed", + "book_status", + "user", + ) search_fields = ("title",) diff --git a/books/apps.py b/books/apps.py index f716137..aeb195e 100644 --- a/books/apps.py +++ b/books/apps.py @@ -2,4 +2,4 @@ class BooksConfig(AppConfig): - name = 'books' + name = "books" diff --git a/books/forms.py b/books/forms.py index a0c8b90..ab189da 100644 --- a/books/forms.py +++ b/books/forms.py @@ -5,7 +5,7 @@ class DateInput(forms.DateInput): - input_type = 'text' + input_type = "text" class ImportBooksForm(forms.Form): @@ -13,10 +13,9 @@ class ImportBooksForm(forms.Form): class UserBookForm(ModelForm): - class Meta: model = UserBook - fields = ['status', 'completed', 'booklists'] + fields = ["status", "completed", "booklists"] widgets = { - 'completed': DateInput(), + "completed": DateInput(), } diff --git a/books/goodreads.py b/books/goodreads.py index 4f8dd34..d1bbc2f 100644 --- a/books/goodreads.py +++ b/books/goodreads.py @@ -7,9 +7,12 @@ from django.contrib.auth.models import User import pytz -from .googlebooks import (get_book_info_from_cache, - get_book_info_from_api, - search_books, DEFAULT_LANGUAGE) +from .googlebooks import ( + get_book_info_from_cache, + get_book_info_from_api, + search_books, + DEFAULT_LANGUAGE, +) from .models import UserBook, BookConversion, ImportedBook GOOGLE_TO_GOODREADS_READ_STATUSES = { @@ -33,9 +36,7 @@ def _cache_book_for_row(row, username, sleep_seconds): # the view but this instance is useful if user uploads # a new csv file with only a few new titles) try: - imported_book = ImportedBook.objects.get( - title=title, - user=user) + imported_book = ImportedBook.objects.get(title=title, user=user) return imported_book except ImportedBook.DoesNotExist: pass @@ -43,24 +44,21 @@ def _cache_book_for_row(row, username, sleep_seconds): author = row["Author"] reading_status = row["Exclusive Shelf"] date_completed = datetime.strptime( - row["Date Read"] or row["Date Added"], '%Y/%m/%d') + row["Date Read"] or row["Date Added"], "%Y/%m/%d" + ) goodreads_id = row["Book Id"] book_status = BookImportStatus.TO_BE_ADDED book = None - book_mapping, _ = BookConversion.objects.get_or_create( - goodreads_id=goodreads_id) + book_mapping, _ = BookConversion.objects.get_or_create(goodreads_id=goodreads_id) if not book_mapping.googlebooks_id: # only query API for new book mappings term = f"{title} {author}" # make sure we don't hit Google Books API rate limits sleep(sleep_seconds) - google_book_response = search_books( - term, - lang=DEFAULT_LANGUAGE - ) + google_book_response = search_books(term, lang=DEFAULT_LANGUAGE) try: bookid = google_book_response["items"][0]["id"] book_mapping.googlebooks_id = bookid @@ -86,8 +84,7 @@ def _cache_book_for_row(row, username, sleep_seconds): book_status = BookImportStatus.COULD_NOT_FIND if book is not None: - user_books = UserBook.objects.filter( - user=user, book=book) + user_books = UserBook.objects.filter(user=user, book=book) if user_books.count() > 0: book_status = BookImportStatus.ALREADY_ADDED @@ -97,17 +94,15 @@ def _cache_book_for_row(row, username, sleep_seconds): reading_status=reading_status, date_completed=pytz.utc.localize(date_completed), book_status=book_status.name, - user=user) + user=user, + ) return imported_book -def convert_goodreads_to_google_books( - file_content, username, sleep_seconds=0 -): +def convert_goodreads_to_google_books(file_content, username, sleep_seconds=0): # remove read().decode('utf-8') as it's not serializable - reader = csv.DictReader( - StringIO(file_content), delimiter=',') + reader = csv.DictReader(StringIO(file_content), delimiter=",") imported_books = [] for row in reader: diff --git a/books/googlebooks.py b/books/googlebooks.py index e2b5301..be2958c 100644 --- a/books/googlebooks.py +++ b/books/googlebooks.py @@ -3,15 +3,15 @@ from .models import Category, Book, Search -BASE_URL = 'https://www.googleapis.com/books/v1/volumes' -SEARCH_URL = BASE_URL + '?q={}' -BOOK_URL = BASE_URL + '/{}' -NOT_FOUND = 'Not found' +BASE_URL = "https://www.googleapis.com/books/v1/volumes" +SEARCH_URL = BASE_URL + "?q={}" +BOOK_URL = BASE_URL + "/{}" +NOT_FOUND = "Not found" DEFAULT_LANGUAGE = "en" def get_book_info(book_id): - ''' cache book info in db ''' + """cache book info in db""" book = get_book_info_from_cache(book_id) if book is not None: return book @@ -27,34 +27,35 @@ def get_book_info_from_api(book_id): query = BOOK_URL.format(book_id) resp = requests.get(query).json() - volinfo = resp['volumeInfo'] + volinfo = resp["volumeInfo"] bookid = book_id - title = volinfo['title'] - authors = ', '.join(volinfo.get('authors', NOT_FOUND)) - publisher = volinfo.get('publisher', NOT_FOUND).strip('"') - published = volinfo.get('publishedDate', NOT_FOUND) + title = volinfo["title"] + authors = ", ".join(volinfo.get("authors", NOT_FOUND)) + publisher = volinfo.get("publisher", NOT_FOUND).strip('"') + published = volinfo.get("publishedDate", NOT_FOUND) - identifiers = volinfo.get('industryIdentifiers') - isbn = identifiers[-1]['identifier'] if identifiers else NOT_FOUND + identifiers = volinfo.get("industryIdentifiers") + isbn = identifiers[-1]["identifier"] if identifiers else NOT_FOUND - pages = volinfo.get('pageCount', 0) - language = volinfo.get('language', DEFAULT_LANGUAGE) - description = volinfo.get('description', 'No description') + pages = volinfo.get("pageCount", 0) + language = volinfo.get("language", DEFAULT_LANGUAGE) + description = volinfo.get("description", "No description") - categories = volinfo.get('categories', []) + categories = volinfo.get("categories", []) category_objects = [] for category in categories: cat, _ = Category.objects.get_or_create(name=category) category_objects.append(cat) - if 'imageLinks' in volinfo and 'small' in volinfo['imageLinks']: - image_size = parse.parse_qs(parse.urlparse(volinfo['imageLinks']['small']).query)['zoom'][0] + if "imageLinks" in volinfo and "small" in volinfo["imageLinks"]: + image_size = parse.parse_qs( + parse.urlparse(volinfo["imageLinks"]["small"]).query + )["zoom"][0] else: - image_size = '1' + image_size = "1" - book, created = Book.objects.get_or_create( - bookid=bookid) + book, created = Book.objects.get_or_create(bookid=bookid) # make sure we don't created duplicates if created: @@ -78,7 +79,7 @@ def get_book_info_from_api(book_id): def search_books(term, request=None, lang=None): - ''' autocomplete = keep this one api live / no cache ''' + """autocomplete = keep this one api live / no cache""" search = Search(term=term) if request and request.user.is_authenticated: search.user = request.user @@ -92,12 +93,12 @@ def search_books(term, request=None, lang=None): return requests.get(query).json() -if __name__ == '__main__': - term = 'python for finance' - for item in search_books(term)['items']: +if __name__ == "__main__": + term = "python for finance" + for item in search_books(term)["items"]: try: - id_ = item['id'] - title = item['volumeInfo']['title'] + id_ = item["id"] + title = item["volumeInfo"]["title"] except KeyError: continue print(id_, title) diff --git a/books/models.py b/books/models.py index 76143bb..4095a5b 100644 --- a/books/models.py +++ b/books/models.py @@ -5,11 +5,11 @@ from lists.models import UserList -READING = 'r' -COMPLETED = 'c' -TO_READ = 't' -QUOTE = 'q' -NOTE = 'n' +READING = "r" +COMPLETED = "c" +TO_READ = "t" +QUOTE = "q" +NOTE = "n" class Category(models.Model): @@ -35,24 +35,26 @@ class Book(models.Model): imagesize = models.CharField(max_length=2, default="1") inserted = models.DateTimeField(auto_now_add=True) edited = models.DateTimeField(auto_now=True) - categories = models.ManyToManyField(Category, related_name='categories') + categories = models.ManyToManyField(Category, related_name="categories") @property def title_and_authors(self): - return f'{self.title} ({self.authors})' + return f"{self.title} ({self.authors})" @property def url(self): - return f'{settings.DOMAIN}/books/{self.bookid}' + return f"{settings.DOMAIN}/books/{self.bookid}" def __str__(self): - return f'{self.id} {self.bookid} {self.title}' + return f"{self.id} {self.bookid} {self.title}" def __repr__(self): - return (f"{self.__class__.__name__}('{self.id}', " - f"'{self.bookid}', '{self.title}', '{self.authors}', " - f"'{self.publisher}', '{self.published}', '{self.isbn}', " - f"'{self.pages}', '{self.language}', '{self.description}')") + return ( + f"{self.__class__.__name__}('{self.id}', " + f"'{self.bookid}', '{self.title}', '{self.authors}', " + f"'{self.publisher}', '{self.published}', '{self.isbn}', " + f"'{self.pages}', '{self.language}', '{self.description}')" + ) class Search(models.Model): @@ -69,17 +71,16 @@ class Meta: class UserBook(models.Model): READ_STATUSES = ( - (READING, 'I am reading this book'), - (COMPLETED, 'I have completed this book'), - (TO_READ, 'I want to read this book'), # t of 'todo' + (READING, "I am reading this book"), + (COMPLETED, "I have completed this book"), + (TO_READ, "I want to read this book"), # t of 'todo' ) user = models.ForeignKey(User, on_delete=models.CASCADE) book = models.ForeignKey(Book, on_delete=models.CASCADE) - status = models.CharField(max_length=1, choices=READ_STATUSES, - default=COMPLETED) + status = models.CharField(max_length=1, choices=READ_STATUSES, default=COMPLETED) favorite = models.BooleanField(default=False) completed = models.DateTimeField(default=timezone.now) - booklists = models.ManyToManyField(UserList, related_name='booklists') + booklists = models.ManyToManyField(UserList, related_name="booklists") inserted = models.DateTimeField(auto_now_add=True) # != completed updated = models.DateTimeField(auto_now=True) @@ -88,21 +89,23 @@ def done_reading(self): return self.status == COMPLETED def __str__(self): - return f'{self.user} {self.book} {self.status} {self.completed}' + return f"{self.user} {self.book} {self.status} {self.completed}" class Meta: # -favorite - False sorts before True so need to reverse - ordering = ['-favorite', '-completed', '-id'] + ordering = ["-favorite", "-completed", "-id"] class BookNote(models.Model): NOTE_TYPES = ( - (QUOTE, 'Quote'), - (NOTE, 'Note'), + (QUOTE, "Quote"), + (NOTE, "Note"), ) user = models.ForeignKey(User, on_delete=models.CASCADE) book = models.ForeignKey(Book, on_delete=models.CASCADE, blank=True, null=True) - userbook = models.ForeignKey(UserBook, on_delete=models.CASCADE, blank=True, null=True) + userbook = models.ForeignKey( + UserBook, on_delete=models.CASCADE, blank=True, null=True + ) type_note = models.CharField(max_length=1, choices=NOTE_TYPES, default=NOTE) description = models.TextField() public = models.BooleanField(default=False) @@ -121,7 +124,7 @@ def type_note_label(self): return None def __str__(self): - return f'{self.user} {self.userbook} {self.type_note} {self.description} {self.public}' + return f"{self.user} {self.userbook} {self.type_note} {self.description} {self.public}" class Badge(models.Model): @@ -129,28 +132,29 @@ class Badge(models.Model): title = models.CharField(max_length=50) def __str__(self): - return f'{self.books} -> {self.title}' + return f"{self.books} -> {self.title}" class BookConversion(models.Model): """Cache table to store goodreads -> Google Books mapping""" + goodreads_id = models.CharField(max_length=20) googlebooks_id = models.CharField(max_length=20, null=True, blank=True) inserted = models.DateTimeField(auto_now_add=True) def __str__(self): - return f'{self.goodreads_id} -> {self.googlebooks_id}' + return f"{self.goodreads_id} -> {self.googlebooks_id}" class ImportedBook(models.Model): """Cache table for preview goodreads import data""" + title = models.TextField() - book = models.ForeignKey(Book, on_delete=models.CASCADE, - null=True, blank=True) + book = models.ForeignKey(Book, on_delete=models.CASCADE, null=True, blank=True) reading_status = models.CharField(max_length=20) date_completed = models.DateTimeField() book_status = models.CharField(max_length=20) user = models.ForeignKey(User, on_delete=models.CASCADE) def __str__(self): - return f'{self.user} -> {self.title}' + return f"{self.user} -> {self.title}" diff --git a/books/tasks.py b/books/tasks.py index a61d45f..058f809 100644 --- a/books/tasks.py +++ b/books/tasks.py @@ -21,17 +21,14 @@ @shared_task def retrieve_google_books(file_content, username): """Convert goodreads to google books, sleeping - one second in between requests to not hit Google - API rate limits. Sent user email when done + one second in between requests to not hit Google + API rate limits. Sent user email when done """ - books = convert_goodreads_to_google_books( - file_content, username, sleep_seconds=1) + books = convert_goodreads_to_google_books(file_content, username, sleep_seconds=1) num_converted = len(books) msg = MESSAGE_TEMPLATE.format( - username=username, - num_converted=num_converted, - url=PREVIEW_PAGE + username=username, num_converted=num_converted, url=PREVIEW_PAGE ) email = User.objects.get(username=username).email send_email(email, SUBJECT, msg) diff --git a/books/urls.py b/books/urls.py index 9222595..2d4aeeb 100644 --- a/books/urls.py +++ b/books/urls.py @@ -2,10 +2,14 @@ from books import views as book_views -app_name = 'books' +app_name = "books" urlpatterns = [ - path('import_books/preview', book_views.import_books, name='import_books'), - path('import_books', book_views.import_books, name='import_books'), - path('', book_views.book_page, name='book_page'), - path('categories/', book_views.books_per_category, name='books_per_category'), + path("import_books/preview", book_views.import_books, name="import_books"), + path("import_books", book_views.import_books, name="import_books"), + path("", book_views.book_page, name="book_page"), + path( + "categories/", + book_views.books_per_category, + name="books_per_category", + ), ] diff --git a/books/views.py b/books/views.py index c4fba27..dc06043 100644 --- a/books/views.py +++ b/books/views.py @@ -13,12 +13,19 @@ from django.http import JsonResponse import pytz -from .goodreads import (BookImportStatus, - GOOGLE_TO_GOODREADS_READ_STATUSES) +from .goodreads import BookImportStatus, GOOGLE_TO_GOODREADS_READ_STATUSES from .googlebooks import get_book_info from .forms import UserBookForm, ImportBooksForm -from .models import (Category, Book, UserBook, BookNote, ImportedBook, - READING, COMPLETED, TO_READ) +from .models import ( + Category, + Book, + UserBook, + BookNote, + ImportedBook, + READING, + COMPLETED, + TO_READ, +) from .tasks import retrieve_google_books from goal.models import Goal from lists.models import UserList @@ -27,12 +34,16 @@ TO_ADD = BookImportStatus.TO_BE_ADDED.name NOT_FOUND = BookImportStatus.COULD_NOT_FIND.name REQUIRED_GOODREADS_FIELDS = ( - "Title", "Author", "Exclusive Shelf", - "Date Read", "Date Added", "Book Id" + "Title", + "Author", + "Exclusive Shelf", + "Date Read", + "Date Added", + "Book Id", +) +UserStats = namedtuple( + "UserStats", ["num_books_added", "num_books_done", "num_pages_read"] ) -UserStats = namedtuple('UserStats', ["num_books_added", - "num_books_done", - "num_pages_read"]) def book_page(request, bookid): @@ -41,8 +52,8 @@ def book_page(request, bookid): try: book = get_book_info(bookid) except KeyError: - messages.error(request, f'Could not retrieve book {bookid}') - return redirect('index') + messages.error(request, f"Could not retrieve book {bookid}") + return redirect("index") userbook = None if request.user.is_authenticated: @@ -52,116 +63,113 @@ def book_page(request, bookid): pass # a form was submitted - book_edit = post.get('addOrEditBook') - note_submit = post.get('noteSubmit') + book_edit = post.get("addOrEditBook") + note_submit = post.get("noteSubmit") # book form if book_edit: - status = post.get('status') - completed = post.get('completed') or None + status = post.get("status") + completed = post.get("completed") or None userlists = post.getlist("userlists[]", []) booklists = UserList.objects.filter(name__in=userlists) if completed: - completed = timezone.datetime.strptime(completed, '%Y-%m-%d') + completed = timezone.datetime.strptime(completed, "%Y-%m-%d") # this works without pk because Userbook has max 1 entry for user+book - userbook, created = UserBook.objects.get_or_create(book=book, - user=request.user) + userbook, created = UserBook.objects.get_or_create(book=book, user=request.user) userbook.booklists.set(booklists) action = None if created: - action = 'added' + action = "added" elif userbook.user != request.user: - messages.error(request, 'You can only edit your own books') - return redirect('book_page') + messages.error(request, "You can only edit your own books") + return redirect("book_page") - if post.get('deleteBook'): - action = 'deleted' + if post.get("deleteBook"): + action = "deleted" userbook.delete() userbook = None else: - action = 'updated' + action = "updated" userbook.status = status userbook.completed = completed userbook.save() - messages.success(request, f'Successfully {action} book') + messages.success(request, f"Successfully {action} book") # note form (need a valid userbook object!) elif userbook and note_submit: - type_note = post.get('type_note') - description = post.get('description') - public = post.get('public') and True or False + type_note = post.get("type_note") + description = post.get("description") + public = post.get("public") and True or False - noteid = post.get('noteid') + noteid = post.get("noteid") action = None # delete/ update if noteid: try: usernote = BookNote.objects.get(pk=noteid, user=request.user) except BookNote.DoesNotExist: - messages.error(request, 'Note does not exist for this user') - return redirect('book_page') + messages.error(request, "Note does not exist for this user") + return redirect("book_page") if usernote: - if post.get('deleteNote'): - action = 'deleted' + if post.get("deleteNote"): + action = "deleted" usernote.delete() usernote = None else: - action = 'updated' + action = "updated" usernote.type_note = type_note usernote.description = description usernote.public = public usernote.save() # add else: - action = 'added' - usernote = BookNote(user=request.user, - book=book, - userbook=userbook, - type_note=type_note, - description=description, - public=public) + action = "added" + usernote = BookNote( + user=request.user, + book=book, + userbook=userbook, + type_note=type_note, + description=description, + public=public, + ) usernote.save() - messages.success(request, f'Successfully {action} note') + messages.success(request, f"Successfully {action} note") # prepare book form (note form = multiple = best manual) if userbook: # make sure to bounce back previously entered form values book_form = UserBookForm( - initial=dict(status=userbook.status, - completed=userbook.completed)) + initial=dict(status=userbook.status, completed=userbook.completed) + ) else: book_form = UserBookForm() # all notes (do last as new note might have been added) - book_notes = BookNote.objects.select_related('user') + book_notes = BookNote.objects.select_related("user") if request.user.is_authenticated: - filter_criteria = ( - Q(book=book) & (Q(user=request.user) | Q(public=True)) - ) + filter_criteria = Q(book=book) & (Q(user=request.user) | Q(public=True)) notes = book_notes.filter(filter_criteria) else: notes = book_notes.filter(book=book, public=True) - notes = notes.order_by('-edited').all() + notes = notes.order_by("-edited").all() # userbooks has some dup, make sure we deduplicate # them for template display book_users = sorted( { - ub.user for ub in - UserBook.objects.select_related( - 'user' - ).filter( + ub.user + for ub in UserBook.objects.select_related("user").filter( book=book, status=COMPLETED ) }, - key=lambda user: user.username.lower() + key=lambda user: user.username.lower(), ) user_lists = [] @@ -173,29 +181,35 @@ def book_page(request, bookid): userbook_lists = {ul.name for ul in userbook.booklists.all()} book_on_lists = [ - ul.userlist.name for ul in - UserBook.booklists.through.objects.select_related( - 'userbook__book' + ul.userlist.name + for ul in UserBook.booklists.through.objects.select_related( + "userbook__book" ).filter(userbook__book=book) ] - return render(request, 'book.html', {'book': book, - 'notes': notes, - 'userbook': userbook, - 'userbook_lists': userbook_lists, - 'book_form': book_form, - 'book_users': book_users, - 'user_lists': user_lists, - 'book_on_lists': book_on_lists}) + return render( + request, + "book.html", + { + "book": book, + "notes": notes, + "userbook": userbook, + "userbook_lists": userbook_lists, + "book_form": book_form, + "book_users": book_users, + "user_lists": user_lists, + "book_on_lists": book_on_lists, + }, + ) def books_per_category(request, category_name): category = get_object_or_404(Category, name=category_name) - user_books = UserBook.objects.select_related( - "book", "user" - ).filter( - book__categories__id=category.id - ).all() + user_books = ( + UserBook.objects.select_related("book", "user") + .filter(book__categories__id=category.id) + .all() + ) users_by_bookid = defaultdict(set) for ub in user_books: @@ -209,30 +223,26 @@ def books_per_category(request, category_name): # only show books added by users (vs. just navigated) # there are some dups in db unfortunately - books = sorted({ub.book for ub in user_books}, - key=lambda book: book.title.lower()) + books = sorted({ub.book for ub in user_books}, key=lambda book: book.title.lower()) context = { "category": category, "books": books, "users_by_bookid": users_by_bookid_sorted, } - return render(request, 'category.html', context) + return render(request, "category.html", context) def get_user_goal(user): try: - goal = Goal.objects.get(year=date.today().year, - user=user, - number_books__gt=0) + goal = Goal.objects.get(year=date.today().year, user=user, number_books__gt=0) except Goal.DoesNotExist: goal = None return goal def group_userbooks_by_status(books): - userbooks = OrderedDict( - [(READING, []), (COMPLETED, []), (TO_READ, [])]) + userbooks = OrderedDict([(READING, []), (COMPLETED, []), (TO_READ, [])]) for book in books: userbooks[book.status].append(book) return userbooks @@ -241,13 +251,14 @@ def group_userbooks_by_status(books): def get_num_pages_read(books): return sum( int(book.book.pages) if str(book.book.pages).isdigit() else 0 - for book in books if book.done_reading) + for book in books + if book.done_reading + ) def user_page(request, username): user = get_object_or_404(User, username=username) - user_books = UserBook.objects.select_related('book').filter( - user=user) + user_books = UserBook.objects.select_related("book").filter(user=user) completed_books_this_year, perc_completed = [], 0 goal = get_user_goal(user) @@ -259,54 +270,59 @@ def user_page(request, username): if goal.number_books > 0: perc_completed = int( - completed_books_this_year.count() / goal.number_books * 100) + completed_books_this_year.count() / goal.number_books * 100 + ) is_me = request.user.is_authenticated and request.user == user share_goal = goal and (goal.share or is_me) grouped_user_books = group_userbooks_by_status(user_books) - user_stats = UserStats(num_books_added=len(user_books), - num_books_done=len(grouped_user_books[COMPLETED]), - num_pages_read=get_num_pages_read(user_books)) + user_stats = UserStats( + num_books_added=len(user_books), + num_books_done=len(grouped_user_books[COMPLETED]), + num_pages_read=get_num_pages_read(user_books), + ) user_lists = UserList.objects.filter(user=user) - return render(request, 'user.html', - {'grouped_user_books': grouped_user_books, - 'username': username, - 'user_stats': user_stats, - 'goal': goal, - 'share_goal': share_goal, - 'completed_books_this_year': completed_books_this_year, - 'perc_completed': perc_completed, - 'min_books_search': MIN_NUM_BOOKS_SHOW_SEARCH, - 'is_me': is_me, - 'user_lists': user_lists}) + return render( + request, + "user.html", + { + "grouped_user_books": grouped_user_books, + "username": username, + "user_stats": user_stats, + "goal": goal, + "share_goal": share_goal, + "completed_books_this_year": completed_books_this_year, + "perc_completed": perc_completed, + "min_books_search": MIN_NUM_BOOKS_SHOW_SEARCH, + "is_me": is_me, + "user_lists": user_lists, + }, + ) @xframe_options_exempt def user_page_widget(request, username): user = get_object_or_404(User, username=username) - books = UserBook.objects.select_related('book').filter( - user=user, status='c') - return render(request, 'widget.html', {'books': books}) + books = UserBook.objects.select_related("book").filter(user=user, status="c") + return render(request, "widget.html", {"books": books}) @login_required def user_favorite(request): user = request.user - book = request.GET.get('book') - checked = True if request.GET.get('checked') == "true" else False + book = request.GET.get("book") + checked = True if request.GET.get("checked") == "true" else False userbook = UserBook.objects.get(user__username=user, book__bookid=book) userbook.favorite = checked userbook.save() return JsonResponse({"status": "success"}) -def _is_valid_csv(file_content, - required_fields=REQUIRED_GOODREADS_FIELDS): - reader = csv.DictReader( - StringIO(file_content), delimiter=',') +def _is_valid_csv(file_content, required_fields=REQUIRED_GOODREADS_FIELDS): + reader = csv.DictReader(StringIO(file_content), delimiter=",") header = next(reader) return all(field in header for field in required_fields) @@ -320,11 +336,10 @@ def import_books(request): imported_books = [] if "delete_import" in post: - num_deleted, _ = ImportedBook.objects.filter( - user=request.user).delete() + num_deleted, _ = ImportedBook.objects.filter(user=request.user).delete() msg = f"Deleted import ({num_deleted} books)" messages.success(request, msg) - return redirect('books:import_books') + return redirect("books:import_books") elif "save_import_submit" in post: books_to_add = post.getlist("books_to_add") @@ -332,18 +347,14 @@ def import_books(request): dates = post.getlist("dates") new_user_book_count = 0 - for bookid, read_status, read_date in zip( - books_to_add, read_statuses, dates - ): - completed_dt = pytz.utc.localize( - datetime.strptime(read_date, '%Y-%m-%d')) - book = Book.objects.filter( - bookid=bookid).order_by("inserted").last() + for bookid, read_status, read_date in zip(books_to_add, read_statuses, dates): + completed_dt = pytz.utc.localize(datetime.strptime(read_date, "%Y-%m-%d")) + book = Book.objects.filter(bookid=bookid).order_by("inserted").last() # make sure we don't store books twice user_book, created = UserBook.objects.get_or_create( - user=request.user, - book=book) + user=request.user, book=book + ) # if book is already in user's collection update status and # completed date @@ -358,14 +369,12 @@ def import_books(request): ImportedBook.objects.filter(user=request.user).delete() messages.success(request, f"{new_user_book_count} books inserted") - return redirect('user_page', username=request.user.username) + return redirect("user_page", username=request.user.username) elif "import_books_submit" in post: import_form = ImportBooksForm(post, files) if import_form.is_valid(): - file_content = ( - files['file'].read().decode('utf-8') - ) + file_content = files["file"].read().decode("utf-8") if not _is_valid_csv(file_content): error = ( "Sorry, the provided csv file does " @@ -374,28 +383,29 @@ def import_books(request): f"{', '.join(REQUIRED_GOODREADS_FIELDS)})" ) messages.error(request, error) - return redirect('books:import_books') + return redirect("books:import_books") username = request.user.username - retrieve_google_books.delay( - file_content, username) + retrieve_google_books.delay(file_content, username) - msg = ("Thanks, we're processing your goodreads csv file. " - "We'll notify you by email when you can select " - "books for import into your PyBites Books account.") + msg = ( + "Thanks, we're processing your goodreads csv file. " + "We'll notify you by email when you can select " + "books for import into your PyBites Books account." + ) messages.success(request, msg) - return redirect('books:import_books') + return redirect("books:import_books") elif is_preview: - imported_books = ImportedBook.objects.filter( - user=request.user).order_by('title') - num_add_books = imported_books.filter( - book_status=TO_ADD).count() + imported_books = ImportedBook.objects.filter(user=request.user).order_by( + "title" + ) + num_add_books = imported_books.filter(book_status=TO_ADD).count() if num_add_books == 0: error = "No new books to be imported" messages.error(request, error) - return redirect('books:import_books') + return redirect("books:import_books") context = { "import_form": import_form, @@ -404,4 +414,4 @@ def import_books(request): "to_add": TO_ADD, "all_read_statuses": GOOGLE_TO_GOODREADS_READ_STATUSES.items(), } - return render(request, 'import_books.html', context) + return render(request, "import_books.html", context) diff --git a/goal/apps.py b/goal/apps.py index eed70c0..a3c1a9d 100644 --- a/goal/apps.py +++ b/goal/apps.py @@ -2,4 +2,4 @@ class GoalConfig(AppConfig): - name = 'goal' + name = "goal" diff --git a/goal/models.py b/goal/models.py index f9c4f57..cbf85ff 100644 --- a/goal/models.py +++ b/goal/models.py @@ -15,5 +15,7 @@ class Goal(models.Model): share = models.BooleanField(default=False) def __str__(self): - return (f'{self.user} => {self.number_books} ' - f'(in {self.year} / shared: {self.share})') + return ( + f"{self.user} => {self.number_books} " + f"(in {self.year} / shared: {self.share})" + ) diff --git a/goal/views.py b/goal/views.py index 6fc9a00..128a5cb 100644 --- a/goal/views.py +++ b/goal/views.py @@ -14,28 +14,27 @@ def set_goal(request): # take the current year, so switches to new challenge # when the new year starts - goal, _ = Goal.objects.get_or_create(user=user, - year=date.today().year) + goal, _ = Goal.objects.get_or_create(user=user, year=date.today().year) - if 'deleteGoal' in post: + if "deleteGoal" in post: goal.delete() - messages.success(request, 'You deleted your goal') + messages.success(request, "You deleted your goal") - elif 'updateGoal' in post: + elif "updateGoal" in post: try: - num_books = int(post.get('numBooks', 0)) + num_books = int(post.get("numBooks", 0)) except ValueError: - error = 'Please provide a numeric value' + error = "Please provide a numeric value" messages.error(request, error) else: old_number = goal.number_books goal.number_books = num_books - goal.share = post.get('share', False) + goal.share = post.get("share", False) goal.save() - action = 'added' if old_number == 0 else 'updated' - msg = f'Successfully {action} goal for {goal.year}' + action = "added" if old_number == 0 else "updated" + msg = f"Successfully {action} goal for {goal.year}" messages.success(request, msg) - return render(request, 'goal.html', {'goal': goal}) + return render(request, "goal.html", {"goal": goal}) diff --git a/lists/admin.py b/lists/admin.py index fbc00ed..f96779f 100644 --- a/lists/admin.py +++ b/lists/admin.py @@ -4,7 +4,7 @@ class UserListAdmin(admin.ModelAdmin): - list_display = ('name', 'user') + list_display = ("name", "user") admin.site.register(UserList, UserListAdmin) diff --git a/lists/apps.py b/lists/apps.py index efe43f0..acbf4ea 100644 --- a/lists/apps.py +++ b/lists/apps.py @@ -2,4 +2,4 @@ class ListsConfig(AppConfig): - name = 'lists' + name = "lists" diff --git a/lists/mixins.py b/lists/mixins.py index bcc28b6..8263636 100644 --- a/lists/mixins.py +++ b/lists/mixins.py @@ -8,11 +8,12 @@ class OwnerRequiredMixin: Making sure that only owners can update their objects. See https://stackoverflow.com/a/18176411 """ + def dispatch(self, request, *args, **kwargs): if not self.request.user.is_authenticated: - return HttpResponseRedirect(reverse('login')) + return HttpResponseRedirect(reverse("login")) obj = self.get_object() if obj.user != self.request.user: messages.error(self.request, "You are not the owner of this list") - return HttpResponseRedirect(reverse('lists-view')) + return HttpResponseRedirect(reverse("lists-view")) return super().dispatch(request, *args, **kwargs) diff --git a/lists/models.py b/lists/models.py index b29afaa..42cbd91 100644 --- a/lists/models.py +++ b/lists/models.py @@ -11,4 +11,4 @@ def __str__(self): return self.name class Meta: - ordering = ['name'] + ordering = ["name"] diff --git a/lists/urls.py b/lists/urls.py index 02d9f12..6346b1b 100644 --- a/lists/urls.py +++ b/lists/urls.py @@ -4,9 +4,9 @@ urlpatterns = [ - path('', views.UserListListView.as_view(), name='lists-view'), - path('', views.UserListDetailView.as_view(), name='lists-detail'), - path('add/', views.UserListCreateView.as_view(), name='lists-add'), - path('/', views.UserListUpdateView.as_view(), name='lists-update'), - path('/delete/', views.UserListDeleteView.as_view(), name='lists-delete'), + path("", views.UserListListView.as_view(), name="lists-view"), + path("", views.UserListDetailView.as_view(), name="lists-detail"), + path("add/", views.UserListCreateView.as_view(), name="lists-add"), + path("/", views.UserListUpdateView.as_view(), name="lists-update"), + path("/delete/", views.UserListDeleteView.as_view(), name="lists-delete"), ] diff --git a/lists/views.py b/lists/views.py index c32991f..83fab74 100644 --- a/lists/views.py +++ b/lists/views.py @@ -14,7 +14,7 @@ from .mixins import OwnerRequiredMixin MAX_NUM_USER_LISTS, MAX_NUM_ADMIN_LISTS = 10, 100 -ADMIN_USERS = set(config('ADMIN_USERS', cast=Csv())) +ADMIN_USERS = set(config("ADMIN_USERS", cast=Csv())) def get_max_books(request): @@ -31,25 +31,24 @@ def get_context_data(self, **kwargs): max_num_user_lists, num_lists_left = 0, 0 if self.request.user.is_authenticated: max_num_user_lists = get_max_books(self.request) - num_user_lists = UserList.objects.filter( - user=self.request.user).count() + num_user_lists = UserList.objects.filter(user=self.request.user).count() num_lists_left = max_num_user_lists - num_user_lists - context['num_lists_left'] = num_lists_left - context['max_num_user_lists'] = max_num_user_lists + context["num_lists_left"] = num_lists_left + context["max_num_user_lists"] = max_num_user_lists return context class UserListDetailView(DetailView): model = UserList - slug_field = 'name' - slug_url_kwarg = 'name' + slug_field = "name" + slug_url_kwarg = "name" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) obj = self.get_object() - user_books = UserBook.objects.select_related( - "book", "user" - ).order_by("book__title") + user_books = UserBook.objects.select_related("book", "user").order_by( + "book__title" + ) # TODO: deduplicate # users_by_bookid needs all user_books @@ -59,14 +58,10 @@ def get_context_data(self, **kwargs): users_by_bookid[bookid].add(ub.user) # ... hence this filter should go after that - user_books = user_books.filter( - booklists__id=obj.id) + user_books = user_books.filter(booklists__id=obj.id) users_by_bookid_sorted = { - bookid: sorted( - users, - key=lambda user: user.username.lower() - ) + bookid: sorted(users, key=lambda user: user.username.lower()) for bookid, users in users_by_bookid.items() } @@ -82,36 +77,32 @@ def get_context_data(self, **kwargs): books_by_category[category].append(ub.book) books_done.add(ub.book) - books_by_category_sorted = sorted( - books_by_category.items() - ) + books_by_category_sorted = sorted(books_by_category.items()) - context['user_books'] = user_books - context['users_by_bookid'] = users_by_bookid_sorted - context['books_by_category'] = books_by_category_sorted + context["user_books"] = user_books + context["users_by_bookid"] = users_by_bookid_sorted + context["books_by_category"] = books_by_category_sorted - context['min_num_books_show_search'] = MIN_NUM_BOOKS_SHOW_SEARCH + context["min_num_books_show_search"] = MIN_NUM_BOOKS_SHOW_SEARCH is_auth = self.request.user.is_authenticated - context['is_me'] = is_auth and self.request.user == obj.user + context["is_me"] = is_auth and self.request.user == obj.user return context class UserListCreateView(LoginRequiredMixin, CreateView): model = UserList - fields = ['name'] - success_url = reverse_lazy('lists-view') + fields = ["name"] + success_url = reverse_lazy("lists-view") def form_valid(self, form): form.instance.name = slugify(form.instance.name) user_lists = UserList.objects if user_lists.filter(name=form.instance.name).count() > 0: - form.add_error('name', 'This list already exists') + form.add_error("name", "This list already exists") return self.form_invalid(form) max_num_user_lists = get_max_books(self.request) if user_lists.filter(user=self.request.user).count() > max_num_user_lists: - form.add_error( - 'name', - f'You can have {max_num_user_lists} lists at most') + form.add_error("name", f"You can have {max_num_user_lists} lists at most") return self.form_invalid(form) form.instance.user = self.request.user return super().form_valid(form) @@ -119,8 +110,8 @@ def form_valid(self, form): class UserListUpdateView(OwnerRequiredMixin, UpdateView): model = UserList - fields = ['name'] - success_url = reverse_lazy('lists-view') + fields = ["name"] + success_url = reverse_lazy("lists-view") def form_valid(self, form): form.instance.name = slugify(form.instance.name) @@ -128,11 +119,11 @@ def form_valid(self, form): # this if is there in case user saves existing value without change if old_value != form.instance.name: if UserList.objects.filter(name=form.instance.name).count() > 0: - form.add_error('name', 'This list already exists') + form.add_error("name", "This list already exists") return self.form_invalid(form) return super().form_valid(form) class UserListDeleteView(OwnerRequiredMixin, DeleteView): model = UserList - success_url = reverse_lazy('lists-view') + success_url = reverse_lazy("lists-view") diff --git a/myreadinglist/celery.py b/myreadinglist/celery.py index fd75234..78abc93 100644 --- a/myreadinglist/celery.py +++ b/myreadinglist/celery.py @@ -2,8 +2,8 @@ from celery import Celery -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myreadinglist.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myreadinglist.settings") -app = Celery('myreadinglist') -app.config_from_object('django.conf:settings', namespace='CELERY') +app = Celery("myreadinglist") +app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() diff --git a/myreadinglist/mail.py b/myreadinglist/mail.py index b71f824..07f8e3d 100644 --- a/myreadinglist/mail.py +++ b/myreadinglist/mail.py @@ -4,10 +4,10 @@ import sendgrid from sendgrid.helpers.mail import To, From, Mail -FROM_EMAIL = config('FROM_EMAIL') -PYBITES = 'PyBites' +FROM_EMAIL = config("FROM_EMAIL") +PYBITES = "PyBites" -sg = sendgrid.SendGridAPIClient(api_key=config('SENDGRID_API_KEY')) +sg = sendgrid.SendGridAPIClient(api_key=config("SENDGRID_API_KEY")) def send_email(to_email, subject, body, from_email=FROM_EMAIL, html=True): @@ -16,34 +16,34 @@ def send_email(to_email, subject, body, from_email=FROM_EMAIL, html=True): # if local no emails if settings.LOCAL: - body = body.replace('
', '\n') - print('local env - no email, only print send_email args:') - print(f'to_email: {to_email.email}') - print(f'subject: {subject}') - print(f'body: {body}') - print(f'from_email: {from_email.email}') - print(f'html: {html}') + body = body.replace("
", "\n") + print("local env - no email, only print send_email args:") + print(f"to_email: {to_email.email}") + print(f"subject: {subject}") + print(f"body: {body}") + print(f"from_email: {from_email.email}") + print(f"html: {html}") print() return # newlines get wrapped in email, use html - body = body.replace('\n', '
') + body = body.replace("\n", "
") message = Mail( from_email=from_email, to_emails=to_email, subject=subject, plain_text_content=body if not html else None, - html_content=body if html else None + html_content=body if html else None, ) response = sg.send(message) - if str(response.status_code)[0] != '2': + if str(response.status_code)[0] != "2": # TODO logging - print(f'ERROR sending message, status_code {response.status_code}') + print(f"ERROR sending message, status_code {response.status_code}") return response -if __name__ == '__main__': - send_email('test-email@gmail.com', 'my subject', 'my message') +if __name__ == "__main__": + send_email("test-email@gmail.com", "my subject", "my message") diff --git a/myreadinglist/management/commands/stats.py b/myreadinglist/management/commands/stats.py index 29a976c..29c1334 100644 --- a/myreadinglist/management/commands/stats.py +++ b/myreadinglist/management/commands/stats.py @@ -13,11 +13,11 @@ from books.models import UserBook from goal.models import Goal, current_year -PYBITES_EMAIL_GROUP = config('PYBITES_EMAIL_GROUP', cast=Csv()) +PYBITES_EMAIL_GROUP = config("PYBITES_EMAIL_GROUP", cast=Csv()) FRIDAY = 4 ONE_WEEK_AGO = timezone.now() - timezone.timedelta(days=7) -COMPLETED = 'c' -SUBJECT = 'Weekly PyBites Books stats' +COMPLETED = "c" +SUBJECT = "Weekly PyBites Books stats" MSG = """ Usage stats: - {num_total_users} total users ({num_new_users} new users joined last week). @@ -40,45 +40,41 @@ class Command(BaseCommand): - help = 'email app stats' + help = "email app stats" def add_arguments(self, parser): parser.add_argument( - '--now', - action='store_true', - dest='now', - help='flag to show stats now = bypass day of the week check', + "--now", + action="store_true", + dest="now", + help="flag to show stats now = bypass day of the week check", ) def handle(self, *args, **options): - run_now = options['now'] + run_now = options["now"] # seems heroku does not support weekly cronjobs if not run_now and date.today().weekday() != FRIDAY: return all_users = User.objects.all() - new_users = all_users.filter( - date_joined__gte=ONE_WEEK_AGO) + new_users = all_users.filter(date_joined__gte=ONE_WEEK_AGO) num_new_users = new_users.count() - num_books_clicked = UserBook.objects.filter( - inserted__gte=ONE_WEEK_AGO - ).count() + num_books_clicked = UserBook.objects.filter(inserted__gte=ONE_WEEK_AGO).count() - books_read_last_week = UserBook.objects.select_related( - 'book', 'user' - ).filter( - Q(completed__gte=ONE_WEEK_AGO) & Q(status=COMPLETED) - ).order_by(Lower('user__username')) + books_read_last_week = ( + UserBook.objects.select_related("book", "user") + .filter(Q(completed__gte=ONE_WEEK_AGO) & Q(status=COMPLETED)) + .order_by(Lower("user__username")) + ) num_books_completed = books_read_last_week.count() num_books_completed_pages = sum( int(ub.book.pages) for ub in books_read_last_week ) - new_user_profiles = '
'.join( - (f"- {uu.username} > " - f"{PROFILE_PAGE.format(username=uu.username)}") + new_user_profiles = "
".join( + (f"- {uu.username} > " f"{PROFILE_PAGE.format(username=uu.username)}") for uu in new_users ) @@ -90,28 +86,26 @@ def handle(self, *args, **options): for username, user_books in books_completed_per_user.items(): books_completed.append(f"
* {username}:") books_completed.append( - "".join( - f'
- {book.title} > {book.url}' - for book in user_books - ) + "".join(f"
- {book.title} > {book.url}" for book in user_books) ) - goals = Goal.objects.filter( - year=THIS_YEAR, number_books__gt=0 - ).order_by("-number_books") - goals_out = '
'.join( - f'{goal.user.username} > {goal.number_books}' - for goal in goals + goals = Goal.objects.filter(year=THIS_YEAR, number_books__gt=0).order_by( + "-number_books" + ) + goals_out = "
".join( + f"{goal.user.username} > {goal.number_books}" for goal in goals ) - msg = MSG.format(num_total_users=all_users.count(), - num_new_users=num_new_users, - new_user_profiles=new_user_profiles, - num_books_clicked=num_books_clicked, - num_books_completed=num_books_completed, - num_books_completed_pages=num_books_completed_pages, - books_completed="".join(books_completed), - goals=goals_out) + msg = MSG.format( + num_total_users=all_users.count(), + num_new_users=num_new_users, + new_user_profiles=new_user_profiles, + num_books_clicked=num_books_clicked, + num_books_completed=num_books_completed, + num_books_completed_pages=num_books_completed_pages, + books_completed="".join(books_completed), + goals=goals_out, + ) for to_email in PYBITES_EMAIL_GROUP: send_email(to_email, SUBJECT, msg) diff --git a/myreadinglist/management/commands/update_categories.py b/myreadinglist/management/commands/update_categories.py index 7a9f5c7..28551f6 100644 --- a/myreadinglist/management/commands/update_categories.py +++ b/myreadinglist/management/commands/update_categories.py @@ -3,21 +3,25 @@ from django.core.management.base import BaseCommand from books.googlebooks import get_book_info_from_api -from books.models import Book, Category +from books.models import Book class Command(BaseCommand): - help = 'Add categories to existing books' + help = "Add categories to existing books" def handle(self, *args, **options): books = Book.objects.all() for book in books: - self.stdout.write(f"Adding categories for book {book.bookid} ({book.title})") + self.stdout.write( + f"Adding categories for book {book.bookid} ({book.title})" + ) if book.categories.count() > 0: self.stderr.write("book already has categories, skipping") continue try: get_book_info_from_api(book.bookid) except KeyError: - self.stderr.write("Cannot get book info from Google Books API, skipping") + self.stderr.write( + "Cannot get book info from Google Books API, skipping" + ) sleep(0.5) # not sure about API rates diff --git a/myreadinglist/settings.py b/myreadinglist/settings.py index 5609bc9..da30b56 100644 --- a/myreadinglist/settings.py +++ b/myreadinglist/settings.py @@ -24,97 +24,94 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ -SECRET_KEY = config('SECRET_KEY') -DEBUG = config('DEBUG', default=False, cast=bool) -ENV = config('ENV', default='heroku') -LOCAL = ENV.lower() == 'local' -ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv()) - -EMAIL_HOST = 'smtp.sendgrid.net' -EMAIL_HOST_USER = 'apikey' -EMAIL_HOST_PASSWORD = config('SENDGRID_API_KEY') +SECRET_KEY = config("SECRET_KEY") +DEBUG = config("DEBUG", default=False, cast=bool) +ENV = config("ENV", default="heroku") +LOCAL = ENV.lower() == "local" +ALLOWED_HOSTS = config("ALLOWED_HOSTS", cast=Csv()) + +EMAIL_HOST = "smtp.sendgrid.net" +EMAIL_HOST_USER = "apikey" +EMAIL_HOST_PASSWORD = config("SENDGRID_API_KEY") EMAIL_PORT = 587 EMAIL_USE_TLS = True -DEFAULT_FROM_EMAIL = config('FROM_EMAIL') +DEFAULT_FROM_EMAIL = config("FROM_EMAIL") if DEBUG: - EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' - INTERNAL_IPS = ['127.0.0.1'] + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + INTERNAL_IPS = ["127.0.0.1"] else: - EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" sentry_sdk.init( - dsn=os.environ.get('SENTRY_DSN'), - integrations=[DjangoIntegration()] + dsn=os.environ.get("SENTRY_DSN"), integrations=[DjangoIntegration()] ) PROD_DOMAIN = "https://pybitesbooks.com" -DOMAIN = config('DOMAIN', default=PROD_DOMAIN) +DOMAIN = config("DOMAIN", default=PROD_DOMAIN) # Application definition DJANGO_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.humanize', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", ] EXTERNAL_APPS = [ - 'django_registration', - 'debug_toolbar', + "django_registration", + "debug_toolbar", ] OWN_APPS = [ - 'myreadinglist', - 'books', - 'pomodoro', - 'goal', - 'lists', + "myreadinglist", + "books", + "pomodoro", + "goal", + "lists", ] INSTALLED_APPS = DJANGO_APPS + EXTERNAL_APPS + OWN_APPS MIDDLEWARE = [ - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'debug_toolbar.middleware.DebugToolbarMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "whitenoise.middleware.WhiteNoiseMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'myreadinglist.urls' +ROOT_URLCONF = "myreadinglist.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'myreadinglist/templates'), - os.path.join(BASE_DIR, 'myreadinglist/templates/registration')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + os.path.join(BASE_DIR, "myreadinglist/templates"), + os.path.join(BASE_DIR, "myreadinglist/templates/registration"), + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'myreadinglist.wsgi.application' +WSGI_APPLICATION = "myreadinglist.wsgi.application" # Database # https://docs.djangoproject.com/en/2.0/ref/settings/#databases -DATABASES = { - 'default': dj_database_url.config( - default=config('DATABASE_URL') - ) -} +DATABASES = {"default": dj_database_url.config(default=config("DATABASE_URL"))} # Password validation @@ -122,16 +119,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -139,9 +136,9 @@ # Internationalization # https://docs.djangoproject.com/en/2.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -153,39 +150,37 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) -STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(PROJECT_ROOT, 'staticfiles') -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(PROJECT_ROOT, "staticfiles") +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" -STATICFILES_DIRS = ( - os.path.join(PROJECT_ROOT, 'static'), -) +STATICFILES_DIRS = (os.path.join(PROJECT_ROOT, "static"),) LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", }, }, - 'loggers': { - 'django': { - 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'ERROR'), + "loggers": { + "django": { + "handlers": ["console"], + "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), }, }, } -LOGIN_URL = 'login' -LOGOUT_URL = 'logout' -LOGIN_REDIRECT_URL = 'index' -LOGOUT_REDIRECT_URL = 'index' +LOGIN_URL = "login" +LOGOUT_URL = "logout" +LOGIN_REDIRECT_URL = "index" +LOGOUT_REDIRECT_URL = "index" ACCOUNT_ACTIVATION_DAYS = 7 -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Celery settings -CELERY_BROKER_URL = config('CELERY_BROKER_URL') +CELERY_BROKER_URL = config("CELERY_BROKER_URL") # needed for big goodreads imports DATA_UPLOAD_MAX_NUMBER_FIELDS = None diff --git a/myreadinglist/test_settings.py b/myreadinglist/test_settings.py index fb9a203..d68e444 100644 --- a/myreadinglist/test_settings.py +++ b/myreadinglist/test_settings.py @@ -1,7 +1,4 @@ -from decouple import config -import dj_database_url - from myreadinglist.settings import * # noqa F403 -STATICFILES_STORAGE = '' +STATICFILES_STORAGE = "" DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} diff --git a/myreadinglist/urls.py b/myreadinglist/urls.py index 080c36e..6330ed8 100644 --- a/myreadinglist/urls.py +++ b/myreadinglist/urls.py @@ -8,29 +8,31 @@ from books import views as book_views urlpatterns = [ - path('', views.index, name='index'), - path('query_books/', views.query_books, name='query_books'), - path('api/', include('api.urls', namespace='api')), - path('slack/', include('slack.urls', namespace='slack')), - path('books/', include('books.urls', namespace='books')), - path('users/favorite/', book_views.user_favorite, name='favorite'), - path('users/', book_views.user_page, name='user_page'), - path('widget/', book_views.user_page_widget, - name='user_page_widget'), + path("", views.index, name="index"), + path("query_books/", views.query_books, name="query_books"), + path("api/", include("api.urls", namespace="api")), + path("slack/", include("slack.urls", namespace="slack")), + path("books/", include("books.urls", namespace="books")), + path("users/favorite/", book_views.user_favorite, name="favorite"), + path("users/", book_views.user_page, name="user_page"), + path("widget/", book_views.user_page_widget, name="user_page_widget"), # no dup emails - https://stackoverflow.com/a/19383392 - path('accounts/register/', - RegistrationView.as_view(form_class=RegistrationFormUniqueEmail), - name='registration_register'), - path('accounts/', include('django_registration.backends.activation.urls')), - path('accounts/', include('django.contrib.auth.urls')), - path('5hours/', include('pomodoro.urls')), - path('goal/', include('goal.urls')), - path('lists/', include('lists.urls')), - path('super-reader/', admin.site.urls), + path( + "accounts/register/", + RegistrationView.as_view(form_class=RegistrationFormUniqueEmail), + name="registration_register", + ), + path("accounts/", include("django_registration.backends.activation.urls")), + path("accounts/", include("django.contrib.auth.urls")), + path("5hours/", include("pomodoro.urls")), + path("goal/", include("goal.urls")), + path("lists/", include("lists.urls")), + path("super-reader/", admin.site.urls), ] if settings.DEBUG: import debug_toolbar + urlpatterns = [ - path('__debug__/', include(debug_toolbar.urls)), + path("__debug__/", include(debug_toolbar.urls)), ] + urlpatterns diff --git a/myreadinglist/views.py b/myreadinglist/views.py index e4ef354..54e74e5 100644 --- a/myreadinglist/views.py +++ b/myreadinglist/views.py @@ -1,51 +1,51 @@ from django.conf import settings -from django.db.models import Count from django.http import HttpResponse from django.shortcuts import render from books.googlebooks import search_books -from books.models import Book, UserBook, COMPLETED +from books.models import UserBook, COMPLETED -DEFAULT_THUMB = f'{settings.DOMAIN}/static/img/book-badge.png' -BOOK_ENTRY = ('' - '' - '' - '{title} ({authors})' - '\n') +DEFAULT_THUMB = f"{settings.DOMAIN}/static/img/book-badge.png" +BOOK_ENTRY = ( + '' + '' + '' + '{title} ({authors})' + "\n" +) def _parse_response(items): for item in items: try: - id_ = item['id'] - volinfo = item['volumeInfo'] - title = volinfo['title'] - authors = volinfo['authors'][0] + id_ = item["id"] + volinfo = item["volumeInfo"] + title = volinfo["title"] + authors = volinfo["authors"][0] except KeyError: continue - img = volinfo.get('imageLinks') - thumb = img and img.get('smallThumbnail') + img = volinfo.get("imageLinks") + thumb = img and img.get("smallThumbnail") thumb = thumb and thumb or DEFAULT_THUMB - book_entry = BOOK_ENTRY.format(id=id_, - title=title, - authors=authors, - thumb=thumb) + book_entry = BOOK_ENTRY.format( + id=id_, title=title, authors=authors, thumb=thumb + ) yield book_entry def query_books(request): - no_result = HttpResponse('fail') + no_result = HttpResponse("fail") try: - term = request.GET.get('q') + term = request.GET.get("q") except Exception: return no_result - term = request.GET.get('q', '') + term = request.GET.get("q", "") books = search_books(term, request) - items = books.get('items') + items = books.get("items") if not items: return no_result @@ -55,10 +55,10 @@ def query_books(request): def index(request): - user_books = UserBook.objects.select_related( - 'book', 'user' - ).filter( - status=COMPLETED - ).order_by("-inserted")[:100] + user_books = ( + UserBook.objects.select_related("book", "user") + .filter(status=COMPLETED) + .order_by("-inserted")[:100] + ) context = {"user_books": user_books} - return render(request, 'index.html', context) + return render(request, "index.html", context) diff --git a/pomodoro/apps.py b/pomodoro/apps.py index a15982a..d663f64 100644 --- a/pomodoro/apps.py +++ b/pomodoro/apps.py @@ -2,4 +2,4 @@ class PomodoroConfig(AppConfig): - name = 'pomodoro' + name = "pomodoro" diff --git a/pomodoro/models.py b/pomodoro/models.py index 932334e..ff5616b 100644 --- a/pomodoro/models.py +++ b/pomodoro/models.py @@ -33,8 +33,7 @@ def week(self): return this_week(self.end) def __str__(self): - return (f'{self.user} - {self.minutes} ' - f'({self.start}-{self.end})') + return f"{self.user} - {self.minutes} " f"({self.start}-{self.end})" class Meta: - ordering = ['-end'] + ordering = ["-end"] diff --git a/pomodoro/urls.py b/pomodoro/urls.py index 6c6c397..b730fa4 100644 --- a/pomodoro/urls.py +++ b/pomodoro/urls.py @@ -2,7 +2,7 @@ from . import views -app_name = 'pomodoro' +app_name = "pomodoro" urlpatterns = [ - path('', views.track_pomodoro, name='track_pomodoro'), + path("", views.track_pomodoro, name="track_pomodoro"), ] diff --git a/pomodoro/views.py b/pomodoro/views.py index 3d8788d..80b53d0 100644 --- a/pomodoro/views.py +++ b/pomodoro/views.py @@ -5,10 +5,7 @@ from django.shortcuts import render from django.utils import timezone -from pomodoro.models import (Pomodoro, - DEFAULT_POMO_GOAL, - DEFAULT_POMO_MIN, - this_week) +from pomodoro.models import Pomodoro, DEFAULT_POMO_GOAL, DEFAULT_POMO_MIN @login_required @@ -16,9 +13,9 @@ def track_pomodoro(request): post = request.POST user = request.user - if post.get('add'): + if post.get("add"): Pomodoro.objects.create(user=user, end=timezone.now()) - msg = 'Great job, another pomodoro done!' + msg = "Great job, another pomodoro done!" messages.success(request, msg) pomodori = Pomodoro.objects.filter(user=request.user) @@ -26,8 +23,8 @@ def track_pomodoro(request): week_stats = Counter(pomo.week for pomo in pomodori) context = { - 'week_stats': sorted(week_stats.items(), reverse=True), - 'week_goal': DEFAULT_POMO_GOAL, - 'pomo_minutes': DEFAULT_POMO_MIN, + "week_stats": sorted(week_stats.items(), reverse=True), + "week_goal": DEFAULT_POMO_GOAL, + "pomo_minutes": DEFAULT_POMO_MIN, } - return render(request, 'pomodoro/pomodoro.html', context) + return render(request, "pomodoro/pomodoro.html", context) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..56362a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[tool.ruff] +# In addition to the standard set of exclusions, omit all tests, plus a specific file. +extend-exclude = [ + "*/migrations/*", + "*/templates/*", + "*/static/*", + "*.html", + "*.css", + "*.js", + "__init__.py", +] +force-exclude = true diff --git a/requirements.txt b/requirements.txt index eba5eab..b04b76a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -72,6 +72,7 @@ pip-tools==6.10.0 # via -r requirements.in pluggy==0.13.1 # via pytest +pre_commit==4.0.0 prompt-toolkit==3.0.20 # via click-repl psycopg2-binary==2.9.5 diff --git a/slack/apps.py b/slack/apps.py index e5cc811..edf7ebf 100644 --- a/slack/apps.py +++ b/slack/apps.py @@ -2,4 +2,4 @@ class SlackConfig(AppConfig): - name = 'slack' + name = "slack" diff --git a/slack/urls.py b/slack/urls.py index 73f076f..0fcd855 100644 --- a/slack/urls.py +++ b/slack/urls.py @@ -2,7 +2,7 @@ from . import views -app_name = 'slack' +app_name = "slack" urlpatterns = [ - path('', views.get_book, name='get_book'), + path("", views.get_book, name="get_book"), ] diff --git a/slack/views.py b/slack/views.py index 0794852..9573e50 100644 --- a/slack/views.py +++ b/slack/views.py @@ -5,28 +5,30 @@ from django.http import Http404, HttpResponse from django.views.decorators.csrf import csrf_exempt -from api.views import (get_users, - get_user_last_book, - get_random_book) +from api.views import get_users, get_user_last_book, get_random_book -HOME = 'https://pybitesbooks.com' +HOME = "https://pybitesbooks.com" BOOK_THUMB = "https://books.google.com/books?id={bookid}&printsec=frontcover&img=1&zoom={imagesize}&source=gbs_gdata" # noqa -SLACK_TOKEN = config('SLACK_VERIFICATION_TOKEN', default='') -HELP_TEXT = ('```' - '/book help -> print this help message\n' - '/book -> get a random book added to PyBites Books\n' # noqa E501 - '/book grep -> get a random book filtered on "grep" (if added)\n' # noqa E501 - '/book user -> get usernames and their most recent book read\n' - '/book user username -> get the last book "username" added\n' - '```') -COMMANDS = dict(rand=get_random_book, - grep=get_random_book, - user=get_users, - username=get_user_last_book) +SLACK_TOKEN = config("SLACK_VERIFICATION_TOKEN", default="") +HELP_TEXT = ( + "```" + "/book help -> print this help message\n" + "/book -> get a random book added to PyBites Books\n" # noqa E501 + '/book grep -> get a random book filtered on "grep" (if added)\n' # noqa E501 + "/book user -> get usernames and their most recent book read\n" + '/book user username -> get the last book "username" added\n' + "```" +) +COMMANDS = dict( + rand=get_random_book, + grep=get_random_book, + user=get_users, + username=get_user_last_book, +) def _validate_token(request): - token = request.get('token') + token = request.get("token") if token is None or token != SLACK_TOKEN: raise Http404 @@ -37,31 +39,32 @@ def _create_user_output(user_books): if books: last_book = sorted(books, key=lambda x: x.completed)[-1] else: - last_book = 'no books read yet' + last_book = "no books read yet" users.append((user, last_book)) - col1, col2 = 'User', f'Last read -> {HOME}' - msg = [f'{col1:<19}: {col2}'] + col1, col2 = "User", f"Last read -> {HOME}" + msg = [f"{col1:<19}: {col2}"] - for user, last_book in sorted(users, - key=lambda x: x[1].completed, - reverse=True): + for user, last_book in sorted(users, key=lambda x: x[1].completed, reverse=True): title = last_book.book.title - title = len(title) > 32 and title[:32] + ' ...' or f'{title:<36}' - msg.append(f'{user:<19}: {title} ({naturalday(last_book.completed)})') - return '```' + '\n'.join(msg) + '```' + title = len(title) > 32 and title[:32] + " ..." or f"{title:<36}" + msg.append(f"{user:<19}: {title} ({naturalday(last_book.completed)})") + return "```" + "\n".join(msg) + "```" def _get_attachment(msg, book=None): if book is None: - return {"text": msg, - "color": "#3AA3E3"} + return {"text": msg, "color": "#3AA3E3"} else: - return {"title": book['title'], - "title_link": book['url'], - "image_url": BOOK_THUMB.format(bookid=book['bookid'], imagesize=book['imagesize']), - "text": msg, - "color": "#3AA3E3"} + return { + "title": book["title"], + "title_link": book["url"], + "image_url": BOOK_THUMB.format( + bookid=book["bookid"], imagesize=book["imagesize"] + ), + "text": msg, + "color": "#3AA3E3", + } @csrf_exempt @@ -71,40 +74,41 @@ def get_book(request): headline, msg = None, None - text = request.get('text') + text = request.get("text") text = text.split() single_word_cmd = text and len(text) == 1 and text[0] book = None - if single_word_cmd == 'help': - headline = 'Command syntax:' + if single_word_cmd == "help": + headline = "Command syntax:" msg = HELP_TEXT - elif single_word_cmd == 'user': - headline = 'PyBites Readers:' - user_books = COMMANDS['user']() + elif single_word_cmd == "user": + headline = "PyBites Readers:" + user_books = COMMANDS["user"]() msg = _create_user_output(user_books) else: # 0 or multiple words if len(text) == 0: - book = COMMANDS['rand']() - headline = 'Here is a random title for your reading list:' + book = COMMANDS["rand"]() + headline = "Here is a random title for your reading list:" - elif len(text) == 2 and text[0] == 'user': + elif len(text) == 2 and text[0] == "user": username = text[-1] - book = COMMANDS['username'](username) - headline = f'Last book _{username}_ added:' + book = COMMANDS["username"](username) + headline = f"Last book _{username}_ added:" else: - grep = ' '.join(text) - book = COMMANDS['grep'](grep) + grep = " ".join(text) + book = COMMANDS["grep"](grep) headline = f'Here is a "{grep}" title for your reading list:' msg = f"Author: _{book['authors']}_ (pages: {book['pages']})" - data = {"response_type": "in_channel", - "text": headline, - "attachments": [_get_attachment(msg, book)]} + data = { + "response_type": "in_channel", + "text": headline, + "attachments": [_get_attachment(msg, book)], + } - return HttpResponse(json.dumps(data), - content_type='application/json') + return HttpResponse(json.dumps(data), content_type="application/json") diff --git a/tests/conftest.py b/tests/conftest.py index 8571237..379c0d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,7 @@ def books(django_db_setup, django_db_blocker, categories): isbn="978143021948463", pages=632, language="en", - description="Peter Seibel interviews 15 of the most ..." + description="Peter Seibel interviews 15 of the most ...", ) book.save() book.categories.add(*categories[:2]) @@ -60,7 +60,7 @@ def books(django_db_setup, django_db_blocker, categories): isbn="978141658637144", pages=448, language="en", - description="Anthony Robbins calls it the ..." + description="Anthony Robbins calls it the ...", ) book.save() book.categories.add(*categories[2:6]) @@ -75,7 +75,7 @@ def books(django_db_setup, django_db_blocker, categories): isbn="978140194507741", pages=412, language="en", - description="Imagine—what if you had access to a simple ..." + description="Imagine—what if you had access to a simple ...", ) book.save() book.categories.add(*categories[6:9]) @@ -90,7 +90,7 @@ def books(django_db_setup, django_db_blocker, categories): isbn="9780975500354", pages=281, language="en", - description="NEW EDITION: Is it possible for a person of ..." + description="NEW EDITION: Is it possible for a person of ...", ) book.save() book.categories.add(categories[9]) @@ -112,7 +112,7 @@ def two_books(django_db_setup, django_db_blocker, categories): isbn="9780765394866", pages=448, language="en", - description="This engaging, collectible, ..." + description="This engaging, collectible, ...", ) book.save() book.categories.add(*categories[10:]) @@ -127,7 +127,7 @@ def two_books(django_db_setup, django_db_blocker, categories): isbn="9780575097445", pages=656, language="en", - description="Elantris was built on magic and it thrived ..." + description="Elantris was built on magic and it thrived ...", ) book.save() # this one did not yield categories from Google Books API @@ -140,9 +140,8 @@ def two_books(django_db_setup, django_db_blocker, categories): @pytest.fixture(scope="module") def user(django_db_setup, django_db_blocker): with django_db_blocker.unblock(): - username, password = "user1", 'bar' - return User.objects.create_user( - username=username, password=password) + username, password = "user1", "bar" + return User.objects.create_user(username=username, password=password) @pytest.fixture @@ -154,10 +153,9 @@ def login(django_db_setup, django_db_blocker, client, user): @pytest.fixture(scope="module") def user_books(django_db_setup, django_db_blocker, books, user): with django_db_blocker.unblock(): - statuses = cycle('r c'.split()) + statuses = cycle("r c".split()) user_books = [ - UserBook(user=user, book=book, status=next(statuses)) - for book in books + UserBook(user=user, book=book, status=next(statuses)) for book in books ] UserBook.objects.bulk_create(user_books) return UserBook.objects.all() @@ -166,10 +164,9 @@ def user_books(django_db_setup, django_db_blocker, books, user): @pytest.fixture(scope="module") def user_fav_books(django_db_setup, django_db_blocker, two_books, user): with django_db_blocker.unblock(): - statuses = cycle('r c'.split()) + statuses = cycle("r c".split()) user_books = [ - UserBook(user=user, book=book, status=next(statuses), - favorite=True) + UserBook(user=user, book=book, status=next(statuses), favorite=True) for book in two_books ] UserBook.objects.bulk_create(user_books) diff --git a/tests/test_books.py b/tests/test_books.py index 0c278f5..88d962c 100644 --- a/tests/test_books.py +++ b/tests/test_books.py @@ -6,23 +6,27 @@ def test_homepage_shows_user_completed_books(client, user_books): - response = client.get('/') + response = client.get("/") html = response.content.decode() - book1 = ('') - book2 = ('' - '') + book1 = ( + '' + ) + book2 = ( + '' + "" + ) assert book1 in html assert book2 in html def test_book_page_logged_out(client, books): - response = client.get('/books/nneBa6-mWfgC') + response = client.get("/books/nneBa6-mWfgC") html = response.content.decode() assert "Peter Seibel" in html assert "Apress" in html @@ -33,7 +37,7 @@ def test_book_page_logged_out(client, books): def test_book_page_logged_in(login, books): - response = login.get('/books/nneBa6-mWfgC') + response = login.get("/books/nneBa6-mWfgC") html = response.content.decode() assert re.search(r"2<.* read .*>729<.* pages", html) -@pytest.mark.parametrize("snippet", [ - 'nneBa6-mWfgC >', - '__CvAFrcWY0C >', - '3V_6DwAAQBAJ >', - 'bK1ktwAACAAJ >', - 'jaM7DwAAQBAJ checked>', - 'UCJMRAAACAAJ checked>', -]) +@pytest.mark.parametrize( + "snippet", + [ + "nneBa6-mWfgC >", + "__CvAFrcWY0C >", + "3V_6DwAAQBAJ >", + "bK1ktwAACAAJ >", + "jaM7DwAAQBAJ checked>", + "UCJMRAAACAAJ checked>", + ], +) def test_user_profile_page_stars(client, user, user_fav_books, snippet): - response = client.get(f'/users/{user.username}') + response = client.get(f"/users/{user.username}") html = response.content.decode() - assert (f'