diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a6f5695..2e3a150 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,11 +1,10 @@ - name: Tests on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -16,21 +15,19 @@ jobs: strategy: fail-fast: false - max-parallel: 5 matrix: - python-version: ["3.9", "3.10", "3.11", "3.12-dev"] - django-version: ["3.2", "4.1", "4.2", "main"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13-dev"] + django-version: ["4.1", "4.2", "5.0", "main"] exclude: - # Django 5.0 isn't compatible with python < 3.10 + - python-version: "3.9" + django-version: "5.0" - python-version: "3.9" django-version: "main" - - python-version: "3.11" - django-version: "3.2" + - python-version: "3.12" + django-version: "4.1" - - python-version: "3.12-dev" - django-version: "3.2" - - python-version: "3.12-dev" + - python-version: "3.13-dev" django-version: "4.1" steps: @@ -50,8 +47,7 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} - key: - ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} + key: ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v2- @@ -68,15 +64,14 @@ jobs: - name: Upload Coverage to Codecov uses: codecov/codecov-action@v3 - # The flake8 test in tox.ini won't run with the test job so we need to add it here. - lint: - name: "Lint: ${{ matrix.toxenv }}" + ruff: + name: "Run ruff: ${{ matrix.toxenv }}" runs-on: ubuntu-latest strategy: matrix: toxenv: - - flake8 + - ruff steps: - name: Git clone @@ -85,7 +80,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - name: Get pip cache dir id: pip-cache @@ -95,8 +90,7 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} - key: - ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} + key: ${{ matrix.python-version }}-v2-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v2- @@ -112,11 +106,11 @@ jobs: # https://github.com/8398a7/action-slack/issues/72#issuecomment-649910353 name: Slack notification runs-on: ubuntu-latest - needs: [test, lint] + needs: [test, ruff] # this is required, otherwise it gets skipped if any needed jobs fail. # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idneeds - if: always() # Pick up events even if the job fails or is cancelled. + if: always() # Pick up events even if the job fails or is cancelled. steps: - uses: technote-space/workflow-conclusion-action@v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7b8b7f..adf55b4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.10 + python: python3.11 exclude: | (?x)^( @@ -13,29 +13,28 @@ exclude: | )$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.4.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - repo: https://github.com/psf/black - rev: 22.6.0 - hooks: - - id: black - - repo: https://github.com/pycqa/isort - rev: 5.10.1 - hooks: - - id: isort - - repo: https://github.com/pycqa/flake8 - rev: 4.0.1 - hooks: - - id: flake8 + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + rev: v3.0.3 hooks: - id: prettier types_or: - css - javascript - json + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py39-plus] diff --git a/CHANGELOG.md b/CHANGELOG.md index 34959f4..05a3730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,141 +1,164 @@ # Changelog (Django Ditto) + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## [Unreleased] -- Allow usage of Pillow v10 +### Added + +- Added support for Django 5.0 + +### Removed + +- Dropped support for Django 3.2. +### Changed + +- Allowed use of Pillow v10 and django-imagekit v5.0 +- Switched from using flake8 and black for linting and formatting to ruff ## [3.1.0] - 2023-06-20 ### Added -- Add support for python 3.12. (Nothing changed, other than some assertions in tests.) +- Add support for python 3.12. (Nothing changed, other than some assertions in tests.) ## [3.0.1] - 2023-05-22 ### Fixed + - Fix the `search_fields` in `lastfm`'s `AccountAdmin`. ### Added -- Added general tests for Admin classes +- Added general tests for Admin classes ## [3.0.0] - 2023-05-09 ### Removed + - Dropped support for Python 3.7 and 3.8. (A bit eager but hopefully it's not a problem.) - Dropped support for Django 4.0. (Nothing changed, but removed from tests.) ### Added + - Added support for Django 4.2. (Nothing changed, but added to tests.) ### Changed + - No longer requires pytz as a dependency. - Update dependencies including allowing for django-taggit 4.0 - ## [2.3.0] - 2022-11-28 ### Added + - Add migration for `pinboard.BookmarkTag.slug` that addes `allow_unicode=True`. (It's the default in django-taggit and suddenly it's generating a new migration.) - Add support for python 3.11. (Nothing changed, but added to tests.) ### Changed -- Remove black version requirements +- Remove black version requirements ## [2.2.0] - 2022-08-08 ### Added + - Added support for Django 4.1. - Add the latest master branch Django to the tox testing matrix. - Added `.pre-commit-config.yaml` ### Changed + - Update dependencies, including requiring django-taggit >= 3.0.0. - Update included Bootstrap CSS from 4.6.0 to 4.6.2. - Update development project dependencies ### Fixed -- Errors related to tags when using Django 4.1 (#238) +- Errors related to tags when using Django 4.1 (#238) ## [2.1.1] - 2022-03-25 ### Fixed -- Include `.map` files in `core/static/ditto-core/`. They were missing which causes issues when running `collectstatic`. +- Include `.map` files in `core/static/ditto-core/`. They were missing which causes issues when running `collectstatic`. ## [2.1.0] - 2022-03-24 ### Removed + - The no-longer-used `ditto/core/context_processors.py` file has been removed (deprecated in v0.6.0). ### Added + - Optional `start` and `end` arguments to the `fetch_flickr_photos` management command. (Thanks @garrettc) ### Fixed + - Handle error when importing Flickr data (such as tags) created by a Flickr user who has since been deleted. (#234, thanks @garrettc) - Handle error when importing Flickr photos that don't have all expected image sizes. (#235, thanks @garrettc) - Allow for the `User.realname` property to be blank because the Flickr API doesn't return that field at all for some users (#237) - ## [2.0.0] - 2022-02-14 ### Removed + - **Backwards incompatible:** Drop support for Django 2.2 and 3.1. ### Changed + - **Backwards incompatible:** Requires django-taggit >= v2.0.0 (It changed how `TaggableManager` sets tags: https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst#200-2021-11-14 ) ### Added + - Add support for Django 4.0 (#223) - Add support for Python 3.10 (#225) - Add support for importing the "new" (2019-onwwards) format of downloadable Twitter archive (#229). - ## [1.4.2] - 2021-10-22 ### Changed + - Update python dependences in `devproject/Pipfile` and `docs/Pipfile`. - Update included Bootstrap CSS and JS files to v4.6.0. - Change README and CHANGELOG from `.rst` to `.md` format. ### Fixed -- Remove hard-coded Flickr ID in `fetch_flickr_account_user` management command (thanks @garrettc). +- Remove hard-coded Flickr ID in `fetch_flickr_account_user` management command (thanks @garrettc). ## [1.4.1] - 2021-08-24 ### Fixed + - Replace use of deprecated `django.conf.urls.url()` method. - Fix path to `img/default_avatar.png` static file. - ## [1.4.0] - 2021-04-07 ### Added + - Allow for use of Django 3.2; update devproject to use it. ### Changed -- Change status in `setup.py` from Beta to Production/Stable. +- Change status in `setup.py` from Beta to Production/Stable. ## [1.3.9] - 2020-12-23 ### Fixed -- Add missing update migration for Pinboard. +- Add missing update migration for Pinboard. ## [1.3.0] - 2020-11-20 ### Changed + - Update Flickr Photo models, fetchers and imagegenerators to include the X-Large 3K, X-Large 4K, X-Large 5K and X-Large 6K sizes. A data migration will populate the model fields for any Photos that have this size data already fetched in their raw API data. It does the same for the Medium 800, Large, Large 1600, and Large 2048 sizes too. @@ -145,77 +168,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update python dependencies, including Pillow v8, freezegun v1, and django-debug-toolbar v3. ### Fixed -- Fix ordering of Tweets posted at the same time (as in some threads). +- Fix ordering of Tweets posted at the same time (as in some threads). ## [1.2.0] - 2020-08-22 ### Fixed -- Fix Factory Boy imports and allow for Factory Boy v3 +- Fix Factory Boy imports and allow for Factory Boy v3 ## [1.1.0] - 2020-08-10 ### Changed -- Move all static files from `static/` to `static/ditto-core/`. +- Move all static files from `static/` to `static/ditto-core/`. ## [1.0.0] - 2020-08-10 ### Changed + - About time we went to version 1. - Allow for use of Django 3.1, django-taggit 1.3, pillow 7.2. - Update included Bootstrap CSS from 4.5.0 to 4.5.2. - ## [0.11.2] ### Changed + - Add flake8 to tests - Upgrade Bootstrap CSS and JS from v4.4 to v4.5, and jQuery from 3.4.1 to 3.5.1. ### Fixed -- Fix BOM/encoding error when fetching data from the Pinboard API +- Fix BOM/encoding error when fetching data from the Pinboard API ## [0.11.1] ### Changed + - Update devproject python dependencies - Update Bootstrap CSS and JS from 4.1.1 to 4.4.1, Popper to 1.16, and jQuery from 3.3.1 to 3.4.1. ### Fixed -- Fix character encoding issue with fetched Last.fm data +- Fix character encoding issue with fetched Last.fm data ## [0.11.0] ### Removed -- Dropped support for Django 2.1. Now requires either Django 2.2 or 3.0. +- Dropped support for Django 2.1. Now requires either Django 2.2 or 3.0. ## [0.10.3] ### Changed + - Allow for use of Pillow 6.2 as well as 6.1. - Update devproject dependencies. - ## [0.10.2] ### Fixed -- Fix error when fetching Flickr photos that have location data, as Flickr currently isn't returning `place_id` or `woeid` fields. (0.10.1 was a poor attempt at fixing this.) +- Fix error when fetching Flickr photos that have location data, as Flickr currently isn't returning `place_id` or `woeid` fields. (0.10.1 was a poor attempt at fixing this.) ## [0.10.0] ### Removed + - Drop support for Django 2.0 ### Added + - Add support for Django 2.2 ### Changed + - No new features, but upgrades of requirements. - Switch from django-sortedm2m to django-sorted-m2m, which contains Django 2.2 support. - Upgrade pillow requirement from 4.3 to 6.1. @@ -223,135 +251,142 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgrade Bootstrap in devproject from 4.1 to 4.3. - Upgrade Django used in devproject from 2.1 to 2.2. - ## [0.9.0] ### Added + - Add support for Django 2.1 (no code changes required). ### Changed -- Change to use pipenv, instead of pip, for devproject requirements. +- Change to use pipenv, instead of pip, for devproject requirements. ## [0.8.1] ### Changed + - Upgrade Twython requirement to 3.7.0 from custom version. - Upgrade Django used in devproject from 2.0.4 to 2.0.5. - Upgrade included Bootstrap from v4.1.0 to v4.1.1. - ## [0.8.0] ### Added + - Add optional settings for the date and time formats used in default templates. ### Changed + - Upgrade Bootstrap from v4-beta-3 to v4.1. - Upgraded Twython (and added a migration) to fix formatting of some Tweets. - ## [0.7.6] ### Fixed + - Fix an error when fetching a Flickr user's data if they didn't have 'location' or 'timezone' data set. ## [0.7.5] ### Fixed -- Fix display of images (Twitter avatars and images, Flickr avatars and images) in the Django Admin pages. +- Fix display of images (Twitter avatars and images, Flickr avatars and images) in the Django Admin pages. ## [0.7.4] ### Changed + - When fetching Twitter favorites, fetches the extended version of the tweets and includes entities. - Temporarily use a different specific version of Twython (see README or docs). - ## [0.7.3] ### Changed + - Fetches extended tweet data when fetching recent tweets. - Temporarily requires manual inclusion of a specific version of Twython in your project's pip requirements (see README or docs). ### Fixed -- Handles tweets longer than 255 characters without Postgres complaining (SQLite quietly carried on). +- Handles tweets longer than 255 characters without Postgres complaining (SQLite quietly carried on). ## [0.7.2] ### Fixed -- Add missing migrations for Flickr and Last.fm. +- Add missing migrations for Flickr and Last.fm. ## [0.7.1] ### Changed -- For Last.fm template tags, rely on the `FIRST_DAY_OF_WEEK` Django setting, instead of the now unused `DITTO_WEEK_START` setting. +- For Last.fm template tags, rely on the `FIRST_DAY_OF_WEEK` Django setting, instead of the now unused `DITTO_WEEK_START` setting. ## [0.7.0] ### Removed + - Drop support for Django 1.10. ### Added + - Add support Django 2.0 ### Changed -- Upgrade Bootstrap from v4 beta 1 to v4 beta 3. +- Upgrade Bootstrap from v4 beta 1 to v4 beta 3. ## [0.6.5] ### Changed -- Increase the maximum length of a Twitter User's display name to 50 characters. +- Increase the maximum length of a Twitter User's display name to 50 characters. ## [0.6.4] ### Added -- The Flickr `day_photos` template tag can now fetch photos taken on a particular day, as well as posted on a day. +- The Flickr `day_photos` template tag can now fetch photos taken on a particular day, as well as posted on a day. ## [0.6.3] ### Added -- The Last.fm template tags for the top albums, artists and tracks can now display the top list for a week, as well as day, month and year. +- The Last.fm template tags for the top albums, artists and tracks can now display the top list for a week, as well as day, month and year. ## [0.6.2] ### Added -- Added the `popular_bookmark_tags` template tag to the `pinboard` app. +- Added the `popular_bookmark_tags` template tag to the `pinboard` app. ## [0.6.1] ### Fixed -- Fix bug when importing Flickr photos and there's already a tag with a different `slug` but the same `name`. +- Fix bug when importing Flickr photos and there's already a tag with a different `slug` but the same `name`. ## [0.6.0] ### Deprecated -- The ditto context\_processor is no longer required, and now does nothing. - Replaced its `enabled_apps` with a `get_enabled_apps` template tag. +- The ditto context_processor is no longer required, and now does nothing. + Replaced its `enabled_apps` with a `get_enabled_apps` template tag. ## [0.5.2] ### Fixed -- Fix screenshots URL in README and documentation. +- Fix screenshots URL in README and documentation. ## [0.5.0] ### Added + - Add Bootstrap and jQuery to make navigation bar collapsible ### Changed + - Upgrade Bootstrap to v4-beta #189, #180 - Test it works in Django 1.11 #185 - Label the `core` app as `ditto_core` #186 diff --git a/README.md b/README.md index ec39cb7..36e2121 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@ -Django Ditto -============ +# Django Ditto +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-ditto) [![image](https://github.com/philgyford/django-ditto/actions/workflows/tests.yml/badge.svg)](https://github.com/philgyford/django-ditto/actions/workflows/tests.yml "Tests status") [![codecov](https://codecov.io/gh/philgyford/django-ditto/branch/main/graph/badge.svg?token=T7TMMDS64A)](https://codecov.io/gh/philgyford/django-ditto) [![image](https://readthedocs.org/projects/django-ditto/badge/?version=stable)](https://django-ditto.readthedocs.io/en/stable/?badge=stable "Documentation status") -[![Code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) - -A collection of Django apps for copying things from third-party sites and services. Requires Python 3.9 to 3.12, and Django 3.2, 4.1, or 4.2. +A collection of Django apps for copying things from third-party sites and services. Requires Python 3.9 to 3.12, and Django 4.1, 4.2 or 5.0. [Read the documentation.](http://django-ditto.readthedocs.io/en/latest/) diff --git a/devproject/.python-version b/devproject/.python-version index c84ccce..9ac3804 100644 --- a/devproject/.python-version +++ b/devproject/.python-version @@ -1 +1 @@ -3.10.5 +3.11.5 diff --git a/devproject/devproject/settings.py b/devproject/devproject/settings.py index ae62c91..afbd6b1 100644 --- a/devproject/devproject/settings.py +++ b/devproject/devproject/settings.py @@ -13,7 +13,6 @@ from dotenv import load_dotenv - # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/devproject/requirements.txt b/devproject/requirements.txt index f55bf34..7b9cbb4 100644 --- a/devproject/requirements.txt +++ b/devproject/requirements.txt @@ -1 +1 @@ --e file:./..[dev] +-e file:./..[test] diff --git a/ditto/core/apps.py b/ditto/core/apps.py index 11288fa..82f119a 100644 --- a/ditto/core/apps.py +++ b/ditto/core/apps.py @@ -1,4 +1,3 @@ -# coding: utf-8 from django.apps import AppConfig, apps @@ -11,7 +10,7 @@ class DittoCoreConfig(AppConfig): default_auto_field = "django.db.models.AutoField" -class Apps(object): +class Apps: """Methods for seeing which Ditto apps are installed/enabled. At the moment installed is the same as enabled, but in future we may add conditions that mean an installed app can be disabled. diff --git a/ditto/core/management/commands/__init__.py b/ditto/core/management/commands/__init__.py index ea4dbc2..6280c90 100644 --- a/ditto/core/management/commands/__init__.py +++ b/ditto/core/management/commands/__init__.py @@ -15,7 +15,6 @@ class DittoBaseCommand(BaseCommand): def add_arguments(self, parser): "We may add stuff for handling verbosity here." - pass def output_results(self, results, verbosity=1): """results should be a list of dicts. @@ -46,12 +45,11 @@ def output_results(self, results, verbosity=1): noun = ( self.singular_noun if result["fetched"] == 1 else self.plural_noun ) - self.stdout.write("%sFetched %s %s" % (prefix, result["fetched"], noun)) + self.stdout.write(f"{prefix}Fetched {result['fetched']} {noun}") if result["success"] is False: self.stderr.write( - "%sFailed to fetch %s: %s" - % ( + "{}Failed to fetch {}: {}".format( prefix, self.plural_noun, self.format_messages(result["messages"]), @@ -63,4 +61,4 @@ def format_messages(self, messages): return messages[0] else: # On separate lines, and start a newline first. - return "%s%s" % ("\n", "\n".join(messages)) + return "{}{}".format("\n", "\n".join(messages)) diff --git a/ditto/core/models.py b/ditto/core/models.py index e2524d8..a54b2fb 100644 --- a/ditto/core/models.py +++ b/ditto/core/models.py @@ -1,4 +1,3 @@ -# coding: utf-8 from django.db import models from django.forms.models import model_to_dict @@ -8,6 +7,7 @@ class TimeStampedModelMixin(models.Model): "Should be mixed in to all models." + time_created = models.DateTimeField( auto_now_add=True, help_text="The time this item was created in the database." ) @@ -19,7 +19,7 @@ class Meta: abstract = True -class DiffModelMixin(object): +class DiffModelMixin: """A model mixin that tracks model fields' values and provide some useful api to know what fields have been changed. diff --git a/ditto/core/paginator.py b/ditto/core/paginator.py index 5b8b1f2..bf946c4 100644 --- a/ditto/core/paginator.py +++ b/ditto/core/paginator.py @@ -1,3 +1,4 @@ +# ruff: noqa import math from functools import reduce diff --git a/ditto/core/templatetags/ditto_core.py b/ditto/core/templatetags/ditto_core.py index 20f6134..7e9b26c 100644 --- a/ditto/core/templatetags/ditto_core.py +++ b/ditto/core/templatetags/ditto_core.py @@ -2,9 +2,10 @@ from django.http import QueryDict from django.urls import reverse from django.utils.html import format_html +from django.utils.safestring import mark_safe -from .. import app_settings -from ..apps import ditto_apps +from ditto.core import app_settings +from ditto.core.apps import ditto_apps register = template.Library() @@ -64,11 +65,11 @@ def width_height(w, h, max_w, max_h): width = int(round(w * ratio)) height = int(round(h * ratio)) - return format_html('width="%s" height="%s"' % (width, height)) + return format_html('width="{}" height="{}"', width, height) @register.simple_tag -def display_time(dt, link_to_day=False, granularity=0, case=None): +def display_time(dt, *, link_to_day=False, granularity=0, case=None): """Return the HTML to display the time a Photo, Tweet, etc. dt -- The datetime. @@ -126,9 +127,7 @@ def display_time(dt, link_to_day=False, granularity=0, case=None): # Replace the [date] token with the date format wrapped in tag: dt_fmt = dt_fmt.replace( "[date]", - '{}'.format( - url, d_fmt - ), + f'{d_fmt}', ) else: dt_fmt = dt_fmt.replace("[date]", d_fmt) @@ -144,10 +143,7 @@ def display_time(dt, link_to_day=False, granularity=0, case=None): elif case == "capfirst": visible_time = visible_time[0].upper() + visible_time[1:] - return format_html( - '' - % {"stamp": stamp, "visible": visible_time} - ) + return format_html('', stamp, mark_safe(visible_time)) @register.simple_tag(takes_context=True) diff --git a/ditto/core/urls.py b/ditto/core/urls.py index ee22474..3feee19 100644 --- a/ditto/core/urls.py +++ b/ditto/core/urls.py @@ -18,7 +18,7 @@ # ), re_path( # /2016/04/18/twitter/favorites - r"^(?P\d{4})/(?P\d{2})/(?P\d{2})(?:/(?P[a-z]+))?(?:/(?P[a-z\/]+|))?$", # noqa: E501 + r"^(?P\d{4})/(?P\d{2})/(?P\d{2})(?:/(?P[a-z]+))?(?:/(?P[a-z\/]+|))?$", view=views.DayArchiveView.as_view(), name="day_archive", ), diff --git a/ditto/core/utils/__init__.py b/ditto/core/utils/__init__.py index 727f8ca..0a5b32c 100644 --- a/ditto/core/utils/__init__.py +++ b/ditto/core/utils/__init__.py @@ -1,4 +1,3 @@ -# coding: utf-8 from datetime import datetime, timezone from django.db.models import Count @@ -7,7 +6,7 @@ def truncate_string( - text, strip_html=True, chars=255, truncate="…", at_word_boundary=False + text, *, strip_html=True, chars=255, truncate="…", at_word_boundary=False ): """Truncate a string to a certain length, removing line breaks and mutliple spaces, optionally removing HTML, and appending a 'truncate' string. @@ -35,7 +34,7 @@ def datetime_now(): """Just returns a datetime object for now in UTC, with UTC timezone. Because I was doing this a lot in various places. """ - return datetime.utcnow().replace(tzinfo=timezone.utc) + return datetime.now(tz=timezone.utc) def datetime_from_str(s): @@ -86,7 +85,7 @@ def get_annual_item_counts(qs, field_name="post_year"): return [] # Make a set of years like {2015, 2016, 2018}: - years_with_counts = set(y[field_name] for y in qs) + years_with_counts = {y[field_name] for y in qs} # Make a set of years with no gaps like {2015, 2016, 2017, 2018}: all_years = sorted(set(range(min(years_with_counts), max(years_with_counts) + 1))) diff --git a/ditto/core/utils/downloader.py b/ditto/core/utils/downloader.py index edce0c1..7f6eba3 100644 --- a/ditto/core/utils/downloader.py +++ b/ditto/core/utils/downloader.py @@ -1,3 +1,4 @@ +import contextlib import os import re import shutil @@ -5,11 +6,11 @@ import requests -class DownloadException(Exception): +class DownloadException(Exception): # noqa: N818 pass -class FileDownloader(object): +class FileDownloader: """ For downloading a file from a URL and saving it into /tmp/. @@ -42,7 +43,7 @@ def download(self, url, acceptable_content_types): if r.headers["Content-Type"] in acceptable_content_types: # Where we'll temporarily save the file: filename = self.make_filename(url, r.headers) - filepath = "%s%s" % (self.path, filename) + filepath = f"{self.path}{filename}" # Save the file there: with open(filepath, "wb") as f: r.raw.decode_content = True @@ -50,24 +51,21 @@ def download(self, url, acceptable_content_types): return filepath else: - raise DownloadException( - "Invalid content type (%s) when fetching %s" - % (r.headers["content_type"], url) + msg = "Invalid content type ({}) when fetching {}".format( + r.headers["content_type"], url ) - except KeyError: - raise DownloadException( - "No content_type headers found when fetching %s" % url - ) + raise DownloadException(msg) + except KeyError as err: + msg = f"No content_type headers found when fetching {url}" + raise DownloadException(msg) from err else: - raise DownloadException( - "Got status code %s when fetching %s" % (r.status_code, url) - ) - except requests.exceptions.RequestException as e: - raise DownloadException( - "Something when wrong when fetching %s: %s" % (url, e) - ) - - def make_filename(self, url, headers={}): + msg = f"Got status code {r.status_code} when fetching {url}" + raise DownloadException(msg) + except requests.exceptions.RequestException as err: + msg = f"Something when wrong when fetching {url}: {err}" + raise DownloadException(msg) from err + + def make_filename(self, url, headers=None): """ Find the filename of a downloaded file. Returns a string. @@ -80,6 +78,8 @@ def make_filename(self, url, headers={}): header. This is the case for Videos we download from Flickr. """ + headers = {} if headers is None else headers + # Should work for photos: filename = os.path.basename(url) @@ -90,10 +90,8 @@ def make_filename(self, url, headers={}): # Could be like 'attachment; filename=26897200312.avi' headers["Content-Disposition"] m = re.search(r"filename\=(.*?)$", headers["Content-Disposition"]) - try: + with contextlib.suppress(AttributeError, IndexError): filename = m.group(1) - except (AttributeError, IndexError): - pass except KeyError: pass diff --git a/ditto/core/views.py b/ditto/core/views.py index 31f115e..3b5de04 100644 --- a/ditto/core/views.py +++ b/ditto/core/views.py @@ -16,16 +16,16 @@ from .paginator import DiggPaginator if ditto_apps.is_installed("flickr"): - from ..flickr.models import Photo + from ditto.flickr.models import Photo if ditto_apps.is_installed("lastfm"): - from ..lastfm.models import Scrobble + from ditto.lastfm.models import Scrobble if ditto_apps.is_installed("pinboard"): - from ..pinboard.models import Bookmark + from ditto.pinboard.models import Bookmark if ditto_apps.is_installed("twitter"): - from ..twitter.models import Tweet + from ditto.twitter.models import Tweet class PaginatedListView(ListView): @@ -72,21 +72,18 @@ def paginate_queryset(self, queryset, page_size): page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1 try: page_number = int(page) - except ValueError: + except ValueError as err: if page == "last": page_number = paginator.num_pages else: - raise Http404( - _("Page is not 'last', nor can it be converted to an int.") - ) + msg = _("Page is not 'last', nor can it be converted to an int.") + raise Http404(msg) from err try: page = paginator.page(page_number, softlimit=True) return (paginator, page, page.object_list, page.has_other_pages()) - except InvalidPage as e: - raise Http404( - _("Invalid page (%(page_number)s): %(message)s") - % {"page_number": page_number, "message": str(e)} - ) + except InvalidPage as err: + msg = _("Invalid page ({}): {}").format(page_number, str(err)) + raise Http404(msg) from err class DittoAppsMixin: @@ -113,7 +110,6 @@ class DittoAppsMixin: apps = None def __init__(self, *args, **kwargs): - self.apps = [] enabled_apps = ditto_apps.enabled() @@ -244,10 +240,10 @@ def set_app_and_variety(self, **kwargs): app_slug, variety_slug ) elif variety_slug: - raise Http404( - "'%s' is not a valid variety slug for the '%s' app slug." - % (variety_slug, app_slug) + msg = "'{}' is not a valid variety slug for the '{}' app slug.".format( + variety_slug, app_slug ) + raise Http404(msg) elif app_slug: raise Http404("'%s' is not a valid app slug." % app_slug) @@ -272,11 +268,12 @@ def get_queryset(self): queryset = self.get_queryset_for_app_variety(self.app_name, self.variety_name) if queryset is None: - raise ImproperlyConfigured( - "%(cls)s is missing a QuerySet. " - "%(cls)s.get_queryset() can't find the correct " - "queryset for this app and variety." % {"cls": self.__class__.__name__} + class_name = self.__class__.__name__ + msg = ( + f"{class_name} is missing a QuerySet. {class_name}.get_queryset() " + "can't find the correct queryset for this app and variety." ) + raise ImproperlyConfigured(msg) return queryset @@ -422,7 +419,7 @@ def get_context_data(self, **kwargs): for app_name, variety_name in self.get_app_varieties_to_display(): qs = self.get_queryset_for_app_variety(app_name, variety_name) - if self.include_twitter_replies is False: + if self.include_twitter_replies is False: # noqa: SIM102 # Don't want to include Tweets that are replies on the home page. if app_name == "twitter" and variety_name == "tweet": qs = qs.filter(in_reply_to_screen_name__exact="") @@ -629,9 +626,11 @@ def _date_from_string( format = delim.join((year_format, month_format, day_format)) datestr = delim.join((year, month, day)) try: - return datetime.datetime.strptime(force_str(datestr), format).date() - except ValueError: - raise Http404( - _("Invalid date string '%(datestr)s' given format '%(format)s'") - % {"datestr": datestr, "format": format} + return ( + datetime.datetime.strptime(force_str(datestr), format) + .astimezone(datetime.timezone.utc) + .date() ) + except ValueError as err: + msg = f"Invalid date string '{datestr}' given format '{format}'" + raise Http404(msg) from err diff --git a/ditto/flickr/admin.py b/ditto/flickr/admin.py index f26694b..d3d7152 100644 --- a/ditto/flickr/admin.py +++ b/ditto/flickr/admin.py @@ -3,7 +3,8 @@ from django.forms import TextInput from django.utils.safestring import mark_safe -from ..core.admin import DittoItemModelAdmin +from ditto.core.admin import DittoItemModelAdmin + from .models import Account, Photo, Photoset, User @@ -440,8 +441,7 @@ class PhotoAdmin(DittoItemModelAdmin): def show_thumb(self, instance): return mark_safe( - '' - % ( + ''.format( instance.thumbnail_url, instance.thumbnail_width, instance.thumbnail_height, @@ -452,8 +452,9 @@ def show_thumb(self, instance): def show_image(self, instance): return mark_safe( - '' - % (instance.small_url, instance.small_width, instance.small_height) + ''.format( + instance.small_url, instance.small_width, instance.small_height + ) ) show_image.short_description = "Small image" diff --git a/ditto/flickr/checks.py b/ditto/flickr/checks.py index 2582493..01647ac 100644 --- a/ditto/flickr/checks.py +++ b/ditto/flickr/checks.py @@ -17,14 +17,13 @@ def check_taggit_is_installed(app_configs=None, **kwargs): ) ) - if len(checks) == 0: - if "taggit" not in settings.INSTALLED_APPS: - checks.append( - Error( - "The django-taggit app must be in INSTALLED_APPS", - hint=("Add 'taggit' to INSTALLED_APPS in your settings file."), - id="ditto.flickr.E002", - ) + if len(checks) == 0 and "taggit" not in settings.INSTALLED_APPS: + checks.append( + Error( + "The django-taggit app must be in INSTALLED_APPS", + hint=("Add 'taggit' to INSTALLED_APPS in your settings file."), + id="ditto.flickr.E002", ) + ) return checks diff --git a/ditto/flickr/factories.py b/ditto/flickr/factories.py index 64bb8a6..a684630 100644 --- a/ditto/flickr/factories.py +++ b/ditto/flickr/factories.py @@ -3,7 +3,8 @@ import factory from taggit import models as taggit_models -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from . import models diff --git a/ditto/flickr/fetch/fetchers.py b/ditto/flickr/fetch/fetchers.py index 0f7fe76..8078849 100644 --- a/ditto/flickr/fetch/fetchers.py +++ b/ditto/flickr/fetch/fetchers.py @@ -7,9 +7,10 @@ from django.core.files import File from flickrapi.exceptions import FlickrError -from ...core.utils import datetime_now -from ...core.utils.downloader import DownloadException, filedownloader -from ..models import Account, User +from ditto.core.utils import datetime_now +from ditto.core.utils.downloader import DownloadException, filedownloader +from ditto.flickr.models import Account, User + from . import FetchError from .savers import PhotoSaver, PhotosetSaver, UserSaver @@ -29,7 +30,7 @@ # PhotosetsFetcher -class Fetcher(object): +class Fetcher: """Parent class for children that will call the Flickr API to fetch data. Depending on the child classes, it would be used something like: @@ -77,7 +78,8 @@ def __init__(self, account): else: self.return_value["account"] = "Unsaved Account" else: - raise ValueError("An Account object is required") + msg = "An Account object is required" + raise ValueError(msg) if account.has_credentials(): self.account = account @@ -136,30 +138,28 @@ def _fetch_page(self, **kwargs): def _not_failed(self): """Has everything gone smoothly so far? ie, no failure registered?""" - if "success" not in self.return_value or self.return_value["success"] is True: - return True - else: - return False + return ( + "success" not in self.return_value or self.return_value["success"] is True + ) def _call_api(self, **kwargs): """ Should call self.api.a_function() and set self.results with the results. """ - raise FetchError("Subclasess of Fetcher should define their own _call_api().") + msg = "Subclasess of Fetcher should define their own _call_api()." + raise FetchError(msg) def _fetch_extra(self): """Can be defined in subclasses to fetch extra data to add to self.results before we save the data in the DB.""" - pass def _save_results(self, **kwargs): """ Should go through self.results and create/update things in the DB based on its contents. """ - raise FetchError( - "Subclasses of Fetcher should define their own _save_results()." - ) + msg = "Subclasses of Fetcher should define their own _save_results()." + raise FetchError(msg) class UserIdFetcher(Fetcher): @@ -185,8 +185,9 @@ def _call_api(self): """ try: info = self.api.test.login() - except FlickrError as e: - raise FetchError("Error when calling test.login(): %s'" % e) + except FlickrError as err: + msg = f"Error when calling test.login(): {err}'" + raise FetchError(msg) from err self.results = [{"id": info["user"]["id"]}] @@ -204,7 +205,8 @@ def fetch(self, nsid=None): nsid -- A Flickr ID for a user. """ if nsid is None: - raise FetchError("UserFetcher().fetch() requires a Flickr id (NSID)") + msg = "UserFetcher().fetch() requires a Flickr id (NSID)" + raise FetchError(msg) return super().fetch(nsid=nsid) @@ -212,9 +214,9 @@ def _call_api(self, nsid): "nsid -- A Flickr ID for a user." try: info = self.api.people.getInfo(user_id=nsid) - except FlickrError as e: + except FlickrError as err: # User has deleted their account, so create a dummy result - if e.code == 5: + if err.code == 5: self.results = [ { "raw": "{}", @@ -229,9 +231,8 @@ def _call_api(self, nsid): ] return - raise FetchError( - "Error when getting info about User with Flickr ID '%s': %s" % (nsid, e) - ) + msg = f"Error when getting info about User with Flickr ID '{nsid}': {err}" + raise FetchError(msg) from err # info has 'person' and 'stat' elements. self.results = [info["person"]] @@ -253,9 +254,8 @@ def _fetch_and_save_avatar(self, user): ["image/jpeg", "image/jpg", "image/png", "image/gif"], ) - user.avatar.save( - os.path.basename(avatar_filepath), File(open(avatar_filepath, "rb")) - ) + with open(avatar_filepath, "rb") as f: + user.avatar.save(os.path.basename(avatar_filepath), File(f)) except DownloadException: pass @@ -276,9 +276,8 @@ def _call_api(self): """ Should call self.api.a_function() and set self.results with the results. """ - raise FetchError( - "Subclasess of PhotosFetcher should define their own _call_api()." - ) + msg = "Subclasess of PhotosFetcher should define their own _call_api()." + raise FetchError(msg) def _fetch_extra(self): """Before saving we need to go through the big list of photos we've @@ -286,8 +285,7 @@ def _fetch_extra(self): """ extra_results = [] - for i, photo in enumerate(self.results): - + for _i, photo in enumerate(self.results): self._fetch_user_if_missing(photo["owner"]) extra_results.append( @@ -327,10 +325,9 @@ def _fetch_photo_info(self, photo_id): """ try: results = self.api.photos.getInfo(photo_id=photo_id) - except FlickrError as e: - raise FetchError( - "Error when fetching photo info (photo %s): %s" % (photo_id, e) - ) + except FlickrError as err: + msg = f"Error when fetching photo info (photo {photo_id}): {err}" + raise FetchError(msg) from err # Each tag on the photo is added by a specific Flickr user. # (Usually, but not always, the photo owner.) @@ -348,10 +345,9 @@ def _fetch_photo_sizes(self, photo_id): """ try: results = self.api.photos.getSizes(photo_id=photo_id) - except FlickrError as e: - raise FetchError( - "Error when fetching photo sizes (photo %s): %s" % (photo_id, e) - ) + except FlickrError as err: + msg = f"Error when fetching photo sizes (photo {photo_id}): {err}" + raise FetchError(msg) from err return results["sizes"] def _fetch_photo_exif(self, photo_id): @@ -362,10 +358,9 @@ def _fetch_photo_exif(self, photo_id): """ try: results = self.api.photos.getExif(photo_id=photo_id) - except FlickrError as e: - raise FetchError( - "Error when fetching photo EXIF data (photo %s): %s" % (photo_id, e) - ) + except FlickrError as err: + msg = f"Error when fetching photo EXIF data (photo {photo_id}): {err}" + raise FetchError(msg) from err return results["photo"] def _save_results(self): @@ -389,7 +384,9 @@ def __init__(self, account): # Maximum date of photos to return, if days or start are passed in: # By default, set it before Flickr so we get everything. - self.min_date = datetime.datetime.strptime("2000-01-01", "%Y-%m-%d") + self.min_date = datetime.datetime.strptime("2000-01-01", "%Y-%m-%d").astimezone( + datetime.timezone.utc + ) # Maximum date of photos to return, if end is passed in: self.max_date = None @@ -403,36 +400,41 @@ def fetch(self, days=None, start=None, end=None): """ if days and (start or end): - raise ValueError("You can't use --days with --start or --end") + msg = "You can't use --days with --start or --end" + raise ValueError(msg) if days: try: self.min_date = datetime_now() - datetime.timedelta(days=days) - except TypeError: + except TypeError as err: if days != "all": - raise FetchError("days should be an integer or 'all'.") + msg = "days should be an integer or 'all'." + raise FetchError(msg) from err elif start or end: try: if start: self.min_date = datetime.datetime.strptime( f"{start} 00:00:00", "%Y-%m-%d %H:%M:%S" - ) + ).astimezone(datetime.timezone.utc) if end: self.max_date = datetime.datetime.strptime( f"{end} 23:59:59", "%Y-%m-%d %H:%M:%S" - ) + ).astimezone(datetime.timezone.utc) if (start and end) and (start > end): - raise ValueError("Start date must be before the end date.") + msg = "Start date must be before the end date." + raise ValueError(msg) - except TypeError: - raise FetchError( + except TypeError as err: + msg = ( "Something went wrong with start or end. Please check the date " "format. It should be YYYY-MM-DD" ) + raise FetchError(msg) from err else: - raise FetchError("Either set days or start and/or end.") + msg = "Either set days or start and/or end." + raise FetchError(msg) return super().fetch() @@ -459,11 +461,9 @@ def _call_api(self): try: results = self.api.people.getPhotos(**api_args) - except FlickrError as e: - raise FetchError( - "Error when fetching recent photos (page %s): %s" - % (self.page_number, e) - ) + except FlickrError as err: + msg = f"Error when fetching recent photos (page {self.page_number}: {err}" + raise FetchError(msg) from err if ( self.page_number == 1 @@ -490,10 +490,9 @@ def _call_api(self): per_page=self.items_per_page, page=self.page_number, ) - except FlickrError as e: - raise FetchError( - "Error when fetching photosets (page %s): %s" % (self.page_number, e) - ) + except FlickrError as err: + msg = f"Error when fetching photosets (page {self.page_number}): {err}" + raise FetchError(msg) from err if ( self.page_number == 1 @@ -511,8 +510,7 @@ def _fetch_extra(self): extra_results = [] - for i, photoset in enumerate(self.results): - + for _i, photoset in enumerate(self.results): photos = self._fetch_photos_in_photoset(photoset["id"]) extra_results.append( @@ -552,11 +550,11 @@ def _fetch_photos_in_photoset(self, photoset_id): per_page=self.items_per_page, page=page_number, ) - except FlickrError as e: - raise FetchError( - "Error when fetching photos in photoset %s (page %s): %s" - % (photoset_id, page_number, e) + except FlickrError as err: + msg = "Error when fetching photos in photoset {} (page {}): {}".format( + photoset_id, page_number, err ) + raise FetchError(msg) from err if "photoset" in results and "photo" in results["photoset"]: total_pages = results["photoset"]["pages"] diff --git a/ditto/flickr/fetch/filesfetchers.py b/ditto/flickr/fetch/filesfetchers.py index d780ad4..9176ecf 100644 --- a/ditto/flickr/fetch/filesfetchers.py +++ b/ditto/flickr/fetch/filesfetchers.py @@ -2,15 +2,16 @@ from django.core.files import File -from ...core.utils.downloader import DownloadException, filedownloader -from ..models import Photo +from ditto.core.utils.downloader import DownloadException, filedownloader +from ditto.flickr.models import Photo + from . import FetchError # A single class that fetches original photo/video files for existing # Photo objects. Doesn't use the API. -class OriginalFilesFetcher(object): +class OriginalFilesFetcher: """ Fetch the original photo files for a single Account. @@ -45,7 +46,7 @@ def __init__(self, account): self.account = account - def fetch(self, fetch_all=False): + def fetch(self, *, fetch_all=False): """ Download and save original photos and videos for all Photo objects (or just those that don't already have them). @@ -132,15 +133,17 @@ def _fetch_and_save_file(self, photo, media_type): try: # Saves the file to /tmp/: filepath = filedownloader.download(url, acceptable_content_types) - except DownloadException as e: - raise FetchError(e) + except DownloadException as err: + raise FetchError(err) from err if filepath: # Reopen file and save to the Photo: - reopened_file = open(filepath, "rb") - django_file = File(reopened_file) - - if media_type == "video": - photo.video_original_file.save(os.path.basename(filepath), django_file) - else: - photo.original_file.save(os.path.basename(filepath), django_file) + with open(filepath, "rb") as reopened_file: + django_file = File(reopened_file) + + if media_type == "video": + photo.video_original_file.save( + os.path.basename(filepath), django_file + ) + else: + photo.original_file.save(os.path.basename(filepath), django_file) diff --git a/ditto/flickr/fetch/multifetchers.py b/ditto/flickr/fetch/multifetchers.py index a948034..de50429 100644 --- a/ditto/flickr/fetch/multifetchers.py +++ b/ditto/flickr/fetch/multifetchers.py @@ -1,4 +1,5 @@ -from ..models import Account, User +from ditto.flickr.models import Account, User + from . import FetchError from .fetchers import PhotosetsFetcher, RecentPhotosFetcher from .filesfetchers import OriginalFilesFetcher @@ -16,7 +17,7 @@ # OriginalFilesMultiAccountFetcher -class MultiAccountFetcher(object): +class MultiAccountFetcher: """Parent class for fetching things from Flickr for ALL or ONE Account(s). Its child classes are useful for: @@ -51,32 +52,33 @@ def __init__(self, nsid=None): # Get all active Accounts. self.accounts = list(Account.objects.filter(is_active=True)) if len(self.accounts) == 0: - raise FetchError("No active Accounts were found to fetch.") + msg = "No active Accounts were found to fetch." + raise FetchError(msg) else: # Find the Account associated with nsid. try: user = User.objects.get(nsid=nsid) - except User.DoesNotExist: - raise FetchError("There is no User with the NSID '%s'" % nsid) + except User.DoesNotExist as err: + msg = f"There is no User with the NSID '{nsid}'" + raise FetchError(msg) from err try: account = Account.objects.get(user=user) - except Account.DoesNotExist: - raise FetchError( - "There is no Account associated with the User with NSID '%s'" % nsid - ) + except Account.DoesNotExist as err: + msg = "There is no Account associated with the User with NSID '{nsid}'" + raise FetchError(msg) from err if account.is_active is False: - raise FetchError( + msg = ( "The Account associated with the User with NSID " - "'%s' is marked as inactive." % nsid + f"'{nsid}' is marked as inactive." ) + raise FetchError(msg) self.accounts = [account] return super().__init__() def fetch(self, **kwargs): - raise FetchError( - "Subclasess of MultiAccountFetcher should define their own fetch()." - ) + msg = "Subclasess of MultiAccountFetcher should define their own fetch()." + raise FetchError(msg) class RecentPhotosMultiAccountFetcher(MultiAccountFetcher): @@ -130,7 +132,7 @@ class OriginalFilesMultiAccountFetcher(MultiAccountFetcher): went wrong) for each account. """ - def fetch(self, fetch_all=False): + def fetch(self, *, fetch_all=False): for account in self.accounts: self.return_value.append( OriginalFilesFetcher(account).fetch(fetch_all=fetch_all) diff --git a/ditto/flickr/fetch/savers.py b/ditto/flickr/fetch/savers.py index a333a83..6bf7d64 100644 --- a/ditto/flickr/fetch/savers.py +++ b/ditto/flickr/fetch/savers.py @@ -1,3 +1,4 @@ +import contextlib import json from datetime import datetime, timezone from zoneinfo import ZoneInfo @@ -5,7 +6,8 @@ from django.db.utils import IntegrityError from taggit.models import Tag -from ..models import Photo, Photoset, User +from ditto.flickr.models import Photo, Photoset, User + from . import FetchError # These classes are passed JSON data from the Flickr API and create/update @@ -22,7 +24,7 @@ # PhotosetSave -class SaveUtilsMixin(object): +class SaveUtilsMixin: """Handy utility methods used by the *Saver classes.""" def __init__(self, *args, **kwargs): @@ -42,10 +44,10 @@ def _unixtime_to_datetime(self, api_time): """Change a text unixtime from the API to a datetime with timezone. api_time is a string or int like "1093459273". """ - return datetime.utcfromtimestamp(int(api_time)).replace(tzinfo=timezone.utc) + return datetime.fromtimestamp(int(api_time), tz=timezone.utc) -class UserSaver(SaveUtilsMixin, object): +class UserSaver(SaveUtilsMixin): """For creating/updating an individual User based on data from the API. Use like: @@ -115,7 +117,7 @@ def save_user(self, user, fetch_time): return user_obj -class PhotoSaver(SaveUtilsMixin, object): +class PhotoSaver(SaveUtilsMixin): """For creating/updating an individual Photo based on data from the API. Use like: @@ -291,8 +293,8 @@ def _save_tags(self, photo_obj, tags_data): # The existing tag-photo relationships. tagged_photos = Photo.tags.through.objects.filter(content_object=photo_obj) - local_flickr_ids = set([]) - remote_flickr_ids = set([]) + local_flickr_ids = set() + remote_flickr_ids = set() # Get the Flickr IDs of all the current tag-photo relationships. for tagged_photo in tagged_photos: @@ -302,7 +304,6 @@ def _save_tags(self, photo_obj, tags_data): remote_flickr_ids.add(tag["id"]) if tag["id"] not in local_flickr_ids: - # This tag isn't currently on the photo, so add it. try: tag_obj, tag_created = Tag.objects.get_or_create( @@ -325,11 +326,12 @@ def _save_tags(self, photo_obj, tags_data): # data. try: user = User.objects.get(nsid=tag["author"]) - except User.DoesNotExist: - raise FetchError( + except User.DoesNotExist as err: + msg = ( "Tried to add a Tag authored by a Flickr user " - "with NSID %s who doesn't exist in the DB." % tag["author"] + f"with NSID {tag['author']} who doesn't exist in the DB." ) + raise FetchError(msg) from err pt_obj = Photo.tags.through( flickr_id=tag["id"], @@ -349,7 +351,7 @@ def _save_tags(self, photo_obj, tags_data): tagged_photo.delete() -class PhotosetSaver(SaveUtilsMixin, object): +class PhotosetSaver(SaveUtilsMixin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -389,10 +391,8 @@ def save_photoset(self, photoset): "photos_raw": json.dumps(photoset["photos"]), } - try: + with contextlib.suppress(Photo.DoesNotExist): defaults["primary_photo"] = Photo.objects.get(flickr_id=ps["primary"]) - except Photo.DoesNotExist: - pass photoset_obj, created = Photoset.objects.update_or_create( flickr_id=ps["id"], defaults=defaults @@ -403,10 +403,8 @@ def save_photoset(self, photoset): # photoset object. photos = [] for photo in photoset["photos"]: - try: + with contextlib.suppress(Photo.DoesNotExist): photos.append(Photo.objects.get(flickr_id=photo["id"])) - except Photo.DoesNotExist: - pass # Sets/updates the SortedManyToMany field of the photoset's photos: photoset_obj.photos.set(photos) diff --git a/ditto/flickr/management/commands/__init__.py b/ditto/flickr/management/commands/__init__.py index e80c7e1..a04e346 100644 --- a/ditto/flickr/management/commands/__init__.py +++ b/ditto/flickr/management/commands/__init__.py @@ -1,6 +1,6 @@ from django.core.management.base import CommandError -from ....core.management.commands import DittoBaseCommand +from ditto.core.management.commands import DittoBaseCommand class FetchCommand(DittoBaseCommand): @@ -25,7 +25,6 @@ def add_arguments(self, parser): class FetchPhotosCommand(FetchCommand): - # What we're fetching: singular_noun = "Photo" plural_noun = "Photos" @@ -47,20 +46,20 @@ def add_arguments(self, parser): parser.add_argument("--end", action="store", default=None, help=self.end_help) def handle(self, *args, **options): - # We might be fetching for a specific account or all (None). nsid = options["account"] if options["account"] else None if options["days"] and (options["start"] or options["end"]): - raise CommandError("You can't use --days with --start or --end") + msg = "You can't use --days with --start or --end" + raise CommandError(msg) if options["days"]: - # Will be either 'all' or a number; make the number an int. if options["days"].isdigit(): options["days"] = int(options["days"]) elif options["days"] != "all": - raise CommandError("--days should be an integer or 'all'.") + msg = "--days should be an integer or 'all'." + raise CommandError(msg) results = self.fetch_photos(nsid=nsid, days=options["days"]) self.output_results(results, options.get("verbosity", 1)) @@ -72,12 +71,12 @@ def handle(self, *args, **options): self.output_results(results, options.get("verbosity", 1)) elif options["account"]: - raise CommandError( - "Specify --days, or --start and/or --end as well as --account." - ) + msg = "Specify --days, or --start and/or --end as well as --account." + raise CommandError(msg) else: - raise CommandError("Specify --days, or --start and/or --end.") + msg = "Specify --days, or --start and/or --end." + raise CommandError(msg) def fetch_photos(self, nsid, days=None, start=None, end=None): """Child classes should override this method to call a method that diff --git a/ditto/flickr/management/commands/fetch_flickr_account_user.py b/ditto/flickr/management/commands/fetch_flickr_account_user.py index eb377e6..b0b9572 100644 --- a/ditto/flickr/management/commands/fetch_flickr_account_user.py +++ b/ditto/flickr/management/commands/fetch_flickr_account_user.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand, CommandError -from ...fetch.fetchers import UserFetcher, UserIdFetcher -from ...models import Account, User +from ditto.flickr.fetch.fetchers import UserFetcher, UserIdFetcher +from ditto.flickr.models import Account, User class Command(BaseCommand): @@ -25,7 +25,8 @@ def add_arguments(self, parser): def handle(self, *args, **options): if options["id"] is False: - raise CommandError("Specify an Account ID like --id=1") + msg = "Specify an Account ID like --id=1" + raise CommandError(msg) # First we need the Account object we're fetching for. account = False @@ -57,8 +58,9 @@ def handle(self, *args, **options): else: if options.get("verbosity", 1) > 0: self.stderr.write( - "Failed to fetch a user using Flickr ID '%s': %s" - % (id_result["id"], result["messages"][0]) + "Failed to fetch a user using Flickr ID '{}': {}".format( + id_result["id"], result["messages"][0] + ) ) else: if options.get("verbosity", 1) > 0: diff --git a/ditto/flickr/management/commands/fetch_flickr_originals.py b/ditto/flickr/management/commands/fetch_flickr_originals.py index 875398f..a019b35 100644 --- a/ditto/flickr/management/commands/fetch_flickr_originals.py +++ b/ditto/flickr/management/commands/fetch_flickr_originals.py @@ -1,4 +1,5 @@ -from ...fetch.multifetchers import OriginalFilesMultiAccountFetcher +from ditto.flickr.fetch.multifetchers import OriginalFilesMultiAccountFetcher + from . import FetchCommand @@ -36,8 +37,8 @@ def handle(self, *args, **options): # We might be fetching for a specific account or all (None). nsid = options["account"] if options["account"] else None - results = self.fetch_files(nsid, options["all"]) + results = self.fetch_files(nsid, fetch_all=options["all"]) self.output_results(results, options.get("verbosity", 1)) - def fetch_files(self, nsid, fetch_all=False): + def fetch_files(self, nsid, *, fetch_all=False): return OriginalFilesMultiAccountFetcher(nsid=nsid).fetch(fetch_all=fetch_all) diff --git a/ditto/flickr/management/commands/fetch_flickr_photos.py b/ditto/flickr/management/commands/fetch_flickr_photos.py index cffb3a4..8b41ea1 100644 --- a/ditto/flickr/management/commands/fetch_flickr_photos.py +++ b/ditto/flickr/management/commands/fetch_flickr_photos.py @@ -1,4 +1,5 @@ -from ...fetch.multifetchers import RecentPhotosMultiAccountFetcher +from ditto.flickr.fetch.multifetchers import RecentPhotosMultiAccountFetcher + from . import FetchPhotosCommand diff --git a/ditto/flickr/management/commands/fetch_flickr_photosets.py b/ditto/flickr/management/commands/fetch_flickr_photosets.py index 417ba5a..2f59a76 100644 --- a/ditto/flickr/management/commands/fetch_flickr_photosets.py +++ b/ditto/flickr/management/commands/fetch_flickr_photosets.py @@ -1,4 +1,5 @@ -from ...fetch.multifetchers import PhotosetsMultiAccountFetcher +from ditto.flickr.fetch.multifetchers import PhotosetsMultiAccountFetcher + from . import FetchCommand diff --git a/ditto/flickr/managers.py b/ditto/flickr/managers.py index 4537565..e2db63b 100644 --- a/ditto/flickr/managers.py +++ b/ditto/flickr/managers.py @@ -1,7 +1,7 @@ from django.db import models from taggit.managers import _TaggableManager -from ..core.managers import PublicItemManager +from ditto.core.managers import PublicItemManager class PhotosManager(models.Manager): diff --git a/ditto/flickr/models.py b/ditto/flickr/models.py index ad06ac9..8139d87 100644 --- a/ditto/flickr/models.py +++ b/ditto/flickr/models.py @@ -1,19 +1,15 @@ -# coding: utf-8 +# Because at this stage, don't want to move the upload_to methods around: +# ruff: noqa: DJ012 from django.db import models - -try: - from django.urls import reverse -except ImportError: - # For Django 1.8 - from django.urls import reverse - from django.templatetags.static import static +from django.urls import reverse from imagekit.cachefiles import ImageCacheFile from sortedm2m.fields import SortedManyToManyField from taggit.managers import TaggableManager from taggit.models import TaggedItemBase -from ..core.models import DiffModelMixin, DittoItemModel, TimeStampedModelMixin +from ditto.core.models import DiffModelMixin, DittoItemModel, TimeStampedModelMixin + from . import app_settings, imagegenerators, managers @@ -27,15 +23,15 @@ class Account(TimeStampedModelMixin, models.Model): default=True, help_text="If false, new Photos won't be fetched." ) + class Meta: + ordering = ["user__realname"] + def __str__(self): if self.user: return str(self.user) else: return "%d" % self.pk - class Meta: - ordering = ["user__realname"] - def get_absolute_url(self): if self.user: return reverse("flickr:user_detail", kwargs={"nsid": self.user.nsid}) @@ -44,10 +40,7 @@ def get_absolute_url(self): def has_credentials(self): "Does this at least have something in its API fields? True or False" - if self.api_key and self.api_secret: - return True - else: - return False + return self.api_key and self.api_secret class TaggedPhoto(TaggedItemBase): @@ -94,7 +87,6 @@ class Meta: class Photo(DittoItemModel, ExtraPhotoManagers): - ditto_item_name = "flickr_photo" # The keys in this dict are what we use internally, for method names and @@ -527,7 +519,7 @@ def get_next_public_by_post_time(self): .order_by("post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass def get_previous_public_by_post_time(self): @@ -541,7 +533,7 @@ def get_previous_public_by_post_time(self): .order_by("-post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass # Shortcuts: @@ -562,7 +554,7 @@ def account(self): @property def safety_level_str(self): "Returns the text version of the safety_level, eg 'Restricted'." - levels = dict((x, y) for x, y in self.SAFETY_LEVELS) + levels = {x: y for x, y in self.SAFETY_LEVELS} try: return levels[self.safety_level] @@ -662,7 +654,7 @@ def _local_image_url(self, size): image_generator = generator(source=self.original_file) result = ImageCacheFile(image_generator) return result.url - except Exception: + except Exception: # noqa: BLE001 # We have an original file but something's wrong with it. # Might be 0 bytes or something. return static("ditto-core/img/original_error.jpg") @@ -676,7 +668,7 @@ def _remote_image_url(self, size): size -- One of the keys from self.PHOTO_SIZES. """ if size == "original": - return "https://farm%s.static.flickr.com/%s/%s_%s_%s.%s" % ( + return "https://farm{}.static.flickr.com/{}/{}_{}_{}.{}".format( self.farm, self.server, self.flickr_id, @@ -688,8 +680,8 @@ def _remote_image_url(self, size): size_ext = "" # Medium size doesn't have a letter suffix. if self.PHOTO_SIZES[size]["suffix"]: - size_ext = "_%s" % self.PHOTO_SIZES[size]["suffix"] - return "https://farm%s.static.flickr.com/%s/%s_%s%s.jpg" % ( + size_ext = "_{}".format(self.PHOTO_SIZES[size]["suffix"]) + return "https://farm{}.static.flickr.com/{}/{}_{}{}.jpg".format( self.farm, self.server, self.flickr_id, @@ -718,12 +710,9 @@ def _remote_video_url(self, size): if self.media == "photo": return None else: - if size == "video_original": - secret = self.original_secret - else: - secret = self.secret + secret = self.original_secret if size == "video_original" else self.secret url_size = self.VIDEO_SIZES[size]["url_size"] - return "%splay/%s/%s/" % (self.permalink, url_size, secret) + return f"{self.permalink}play/{url_size}/{secret}/" def __getattr__(self, name): """ @@ -805,16 +794,12 @@ class Photoset(TimeStampedModelMixin, DiffModelMixin, models.Model): # Returns ALL photos, public AND private. photos = SortedManyToManyField("Photo", related_name="photosets") - def public_photos(self): - "Returns only public photos." - return self.photos.filter(is_private=False) + class Meta: + ordering = ["-flickr_created_time"] def __str__(self): return self.title - class Meta: - ordering = ["-flickr_created_time"] - def get_absolute_url(self): return reverse( "flickr:photoset_detail", @@ -823,10 +808,11 @@ def get_absolute_url(self): @property def permalink(self): - return "https://www.flickr.com/photos/%s/albums/%s" % ( - self.user.nsid, - self.flickr_id, - ) + return f"https://www.flickr.com/photos/{self.user.nsid}/albums/{self.flickr_id}" + + def public_photos(self): + "Returns only public photos." + return self.photos.filter(is_private=False) class User(TimeStampedModelMixin, DiffModelMixin, models.Model): @@ -898,12 +884,12 @@ def avatar_upload_path(self, filename): # All Users that have Accounts: objects_with_accounts = managers.WithAccountsManager() - def __str__(self): - return self.realname if self.realname else self.username - class Meta: ordering = ["realname", "username"] + def __str__(self): + return self.realname if self.realname else self.username + def get_absolute_url(self): return reverse("flickr:user_detail", kwargs={"nsid": self.nsid}) @@ -926,7 +912,7 @@ def avatar_url(self): def original_icon_url(self): """URL of the avatar/profile pic at Flickr.""" if self.iconserver: - return "https://farm%s.staticflickr.com/%s/buddyicons/%s.jpg" % ( + return "https://farm{}.staticflickr.com/{}/buddyicons/{}.jpg".format( self.iconfarm, self.iconserver, self.nsid, diff --git a/ditto/flickr/templates/flickr/photo_detail.html b/ditto/flickr/templates/flickr/photo_detail.html index 2bbca4b..6f7d6ad 100644 --- a/ditto/flickr/templates/flickr/photo_detail.html +++ b/ditto/flickr/templates/flickr/photo_detail.html @@ -206,10 +206,10 @@

Albums

  • - Last updated on Flickr at {% display_time photo.last_update_time True %} + Last updated on Flickr at {% display_time photo.last_update_time link_to_day=True %}
  • - As of {% display_time photo.fetch_time True %} + As of {% display_time photo.fetch_time link_to_day=True %}
diff --git a/ditto/flickr/templatetags/ditto_flickr.py b/ditto/flickr/templatetags/ditto_flickr.py index ce52ee4..4fe8554 100644 --- a/ditto/flickr/templatetags/ditto_flickr.py +++ b/ditto/flickr/templatetags/ditto_flickr.py @@ -1,12 +1,11 @@ -from datetime import datetime +from datetime import datetime, timezone from datetime import time as datetime_time -from datetime import timezone from django import template from django.utils.html import format_html -from ...core.utils import get_annual_item_counts -from ..models import Photo, Photoset +from ditto.core.utils import get_annual_item_counts +from ditto.flickr.models import Photo, Photoset register = template.Library() @@ -81,13 +80,14 @@ def photo_license(n): """Returns the text value of the Photo's license, indicated by the number n. Will probably be an HTML link to more info. """ - licenses = dict((x, y) for x, y in Photo.LICENSES) + licenses = {x: y for x, y in Photo.LICENSES} if n in licenses: if n in Photo.LICENSE_URLS and Photo.LICENSE_URLS[n] != "": return format_html( - '%(name)s' - % {"url": Photo.LICENSE_URLS[n], "name": licenses[n]} + '{}'.format( + Photo.LICENSE_URLS[n], licenses[n] + ) ) else: return licenses[n] @@ -118,9 +118,6 @@ def annual_photo_counts(nsid=None, count_by="post_time"): if nsid is not None: qs = qs.filter(user__nsid=nsid) - if count_by == "taken_time": - field_name = "taken_year" - else: - field_name = "post_year" + field_name = "taken_year" if count_by == "taken_time" else "post_year" return get_annual_item_counts(qs, field_name) diff --git a/ditto/flickr/views.py b/ditto/flickr/views.py index 164341e..778c54d 100644 --- a/ditto/flickr/views.py +++ b/ditto/flickr/views.py @@ -4,11 +4,12 @@ from django.views.generic.detail import SingleObjectMixin from taggit.models import Tag -from ..core.views import PaginatedListView +from ditto.core.views import PaginatedListView + from .models import Account, Photo, Photoset, User -class PhotosOrderMixin(object): +class PhotosOrderMixin: """ For pages which list Photos and can change the order they're viewed in. Can have 'order' in the GET string, with values of 'uploaded' or 'taken'. @@ -41,14 +42,11 @@ def get_queryset(self): # wasn't ordering by taken_time without this. ordering = self.get_ordering() if ordering: - if ordering == "-taken_time": # Exclude where we don't know the taken time. queryset = queryset.filter(taken_unknown=False) - import six - - if isinstance(ordering, six.string_types): + if isinstance(ordering, str): ordering = (ordering,) queryset = queryset.order_by(*ordering) @@ -173,6 +171,7 @@ def get_context_data(self, **kwargs): class TagDetailView(PhotosOrderMixin, SingleObjectMixin, PaginatedListView): "All Photos with a certain tag from all Accounts" + template_name = "flickr/tag_detail.html" allow_empty = False queryset = Photo.public_objects.prefetch_related("user") @@ -196,6 +195,7 @@ def get_queryset(self): class UserTagDetailView(PhotosOrderMixin, SingleUserMixin, PaginatedListView): "All Photos with a certain Tag from one User" + template_name = "flickr/user_tag_detail.html" allow_empty = False queryset = Photo.public_objects.prefetch_related("user") @@ -208,8 +208,8 @@ def get_tag_object(self): """Custom method for fetching the Tag.""" try: obj = Tag.objects.get(slug=self.kwargs["tag_slug"]) - except Tag.DoesNotExist: - raise Http404(_("No Tags found matching the query")) + except Tag.DoesNotExist as err: + raise Http404(_("No Tags found matching the query")) from err return obj def get_context_data(self, **kwargs): @@ -263,6 +263,6 @@ def get_photoset_object(self): obj = Photoset.objects.get( user=self.object, flickr_id=self.kwargs["flickr_id"] ) - except Photoset.DoesNotExist: - raise Http404(_("No Photosets found matching the query")) + except Photoset.DoesNotExist as err: + raise Http404(_("No Photosets found matching the query")) from err return obj diff --git a/ditto/lastfm/admin.py b/ditto/lastfm/admin.py index cf79558..8933780 100644 --- a/ditto/lastfm/admin.py +++ b/ditto/lastfm/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from ..core.admin import DittoItemModelAdmin +from ditto.core.admin import DittoItemModelAdmin + from .models import Account, Album, Artist, Scrobble, Track diff --git a/ditto/lastfm/factories.py b/ditto/lastfm/factories.py index 813e62e..ac78914 100644 --- a/ditto/lastfm/factories.py +++ b/ditto/lastfm/factories.py @@ -1,6 +1,7 @@ import factory -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from . import models diff --git a/ditto/lastfm/fetch.py b/ditto/lastfm/fetch.py index 262efce..526062a 100644 --- a/ditto/lastfm/fetch.py +++ b/ditto/lastfm/fetch.py @@ -7,8 +7,8 @@ import requests from ditto import TITLE, VERSION +from ditto.core.utils import datetime_now -from ..core.utils import datetime_now from .models import Account, Album, Artist, Scrobble, Track from .utils import slugify_name @@ -19,7 +19,7 @@ class FetchError(Exception): pass -class ScrobblesFetcher(object): +class ScrobblesFetcher: """ Fetches scrobbles from the API for one Account. @@ -36,7 +36,6 @@ class ScrobblesFetcher(object): items_per_page = 200 def __init__(self, account): - # Will be an Account object, passed into init() self.account = None @@ -55,7 +54,8 @@ def __init__(self, account): if isinstance(account, Account): self.return_value["account"] = str(account) else: - raise ValueError("An Account object is required") + msg = "An Account object is required" + raise ValueError(msg) if account.has_credentials(): self.account = account @@ -95,8 +95,9 @@ def fetch(self, fetch_type="recent", days=None): if fetch_type == "days": try: test = days + 1 # noqa: F841 - except TypeError: - raise ValueError("days argument should be an integer") + except TypeError as err: + msg = "days argument should be an integer" + raise ValueError(msg) from err self.min_datetime = datetime_now() - timedelta(days=days) @@ -145,10 +146,9 @@ def _fetch_page(self): def _not_failed(self): """Has everything gone smoothly so far? ie, no failure registered?""" - if "success" not in self.return_value or self.return_value["success"] is True: - return True - else: - return False + return ( + "success" not in self.return_value or self.return_value["success"] is True + ) def _api_method(self): "The name of the API method." @@ -180,28 +180,27 @@ def _send_request(self): """ query_string = urllib.parse.urlencode(self._api_args()) - url = "{}?{}".format(LASTFM_API_ENDPOINT, query_string) + url = f"{LASTFM_API_ENDPOINT}?{query_string}" try: response = requests.get( - url, headers={"User-Agent": "Mozilla/5.0 (%s v%s)" % (TITLE, VERSION)} + url, + headers={"User-Agent": f"Mozilla/5.0 ({TITLE} v{VERSION})"}, ) response.raise_for_status() # Raises an exception on HTTP error. - except requests.exceptions.RequestException as e: - raise FetchError( - "Error when fetching Scrobbles (page %s): %s" - % (self.page_number, str(e)) - ) + except requests.exceptions.RequestException as err: + msg = f"Error when fetching Scrobbles (page {self.page_number}): {err}" + raise FetchError(msg) from err response.encoding = "utf-8" results = json.loads(response.text) if "error" in results: - raise FetchError( - "Error %s when fetching Scrobbles (page %s): %s" - % (results["error"], self.page_number, results["message"]) + msg = "Error {} when fetching Scrobbles (page {}): {}".format( + results["error"], self.page_number, results["message"] ) + raise FetchError(msg) # Set total number of pages first time round: attr = results["recenttracks"]["@attr"] @@ -257,8 +256,8 @@ def _save_scrobble(self, scrobble, fetch_time): ) # Unixtime to datetime object: - scrobble_time = datetime.utcfromtimestamp(int(scrobble["date"]["uts"])).replace( - tzinfo=timezone.utc + scrobble_time = datetime.fromtimestamp( + int(scrobble["date"]["uts"]), tz=timezone.utc ) scrobble_obj, created = Scrobble.objects.update_or_create( @@ -304,7 +303,7 @@ def _get_slugs(self, scrobble_url): return artist_slug, track_slug -class ScrobblesMultiAccountFetcher(object): +class ScrobblesMultiAccountFetcher: """ For fetching Scrobbles for ALL or ONE account(s). @@ -334,20 +333,20 @@ def __init__(self, username=None): # Get all active Accounts. self.accounts = list(Account.objects.filter(is_active=True)) if len(self.accounts) == 0: - raise FetchError("No active Accounts were found to fetch.") + msg = "No active Accounts were found to fetch." + raise FetchError(msg) else: # Find the Account associated with username. try: account = Account.objects.get(username=username) - except Account.DoesNotExist: - raise FetchError( - "There is no Account with the username '%s'" % username - ) + except Account.DoesNotExist as err: + msg = f"There is no Account with the username '{username}'" + raise FetchError(msg) from err if account.is_active is False: - raise FetchError( - "The Account with the username '%s' is marked as inactive." - % username + msg = ( + "The Account with the username '{username}' is marked as inactive." ) + raise FetchError(msg) self.accounts = [account] def fetch(self, **kwargs): diff --git a/ditto/lastfm/management/commands/fetch_lastfm_scrobbles.py b/ditto/lastfm/management/commands/fetch_lastfm_scrobbles.py index f2fe7b9..beca421 100644 --- a/ditto/lastfm/management/commands/fetch_lastfm_scrobbles.py +++ b/ditto/lastfm/management/commands/fetch_lastfm_scrobbles.py @@ -1,11 +1,10 @@ from django.core.management.base import CommandError -from ....core.management.commands import DittoBaseCommand -from ...fetch import ScrobblesMultiAccountFetcher +from ditto.core.management.commands import DittoBaseCommand +from ditto.lastfm.fetch import ScrobblesMultiAccountFetcher class Command(DittoBaseCommand): - # What we're fetching: singular_noun = "Scrobble" plural_noun = "Scrobbles" @@ -47,7 +46,8 @@ def handle(self, *args, **options): fetch_type = "all" else: - raise CommandError("--days should be an integer or 'all'.") + msg = "--days should be an integer or 'all'." + raise CommandError(msg) fetcher = ScrobblesMultiAccountFetcher(username=username) diff --git a/ditto/lastfm/managers.py b/ditto/lastfm/managers.py index 587bb9f..70a80e9 100644 --- a/ditto/lastfm/managers.py +++ b/ditto/lastfm/managers.py @@ -67,13 +67,16 @@ def with_scrobble_counts(self, **kwargs): track = kwargs.get("track", None) if album and not self.is_filterable_by_album: - raise ValueError("This is not filterable by album") + msg = "This is not filterable by album" + raise ValueError(msg) if artist and not self.is_filterable_by_artist: - raise ValueError("This is not filterable by artist") + msg = "This is not filterable by artist" + raise ValueError(msg) if track and not self.is_filterable_by_track: - raise ValueError("This is not filterable by track") + msg = "This is not filterable by track" + raise ValueError(msg) if account is not None and account.__class__.__name__ != "Account": raise TypeError( @@ -144,11 +147,7 @@ class TracksManager(WithScrobbleCountsManager): def with_scrobble_counts(self, **kwargs): "Pre-fetch all the Tracks' Artists." - qs = ( - super(TracksManager, self) - .with_scrobble_counts(**kwargs) - .prefetch_related("artist") - ) + qs = super().with_scrobble_counts(**kwargs).prefetch_related("artist") return qs @@ -163,11 +162,7 @@ class AlbumsManager(WithScrobbleCountsManager): def with_scrobble_counts(self, **kwargs): "Pre-fetch all the Albums' Artists." - qs = ( - super(AlbumsManager, self) - .with_scrobble_counts(**kwargs) - .prefetch_related("artist") - ) + qs = super().with_scrobble_counts(**kwargs).prefetch_related("artist") return qs diff --git a/ditto/lastfm/models.py b/ditto/lastfm/models.py index 2662c59..cb8144e 100644 --- a/ditto/lastfm/models.py +++ b/ditto/lastfm/models.py @@ -1,14 +1,9 @@ -# coding: utf-8 from django.db import models +from django.urls import reverse -try: - from django.urls import reverse -except ImportError: - # For Django 1.8 - from django.urls import reverse +from ditto.core.models import DittoItemModel, TimeStampedModelMixin +from ditto.core.utils import truncate_string -from ..core.models import DittoItemModel, TimeStampedModelMixin -from ..core.utils import truncate_string from . import managers # For generating permalinks. @@ -38,25 +33,22 @@ class Account(TimeStampedModelMixin, models.Model): default=True, help_text="If false, new scrobbles won't be fetched." ) - def __str__(self): - return self.realname - class Meta: ordering = ["username"] + def __str__(self): + return self.realname + def get_absolute_url(self): return reverse("lastfm:user_detail", kwargs={"username": self.username}) @property def permalink(self): - return "%s/user/%s" % (LASTFM_URL_ROOT, self.username) + return f"{LASTFM_URL_ROOT}/user/{self.username}" def has_credentials(self): "Does this at least have something in its API field? True or False" - if self.api_key: - return True - else: - return False + return bool(self.api_key) def get_recent_scrobbles(self, limit=10): return self.scrobbles.prefetch_related("artist", "track").order_by( @@ -103,12 +95,12 @@ class Album(TimeStampedModelMixin, models.Model): objects = managers.AlbumsManager() - def __str__(self): - return self.name - class Meta: ordering = ["name"] + def __str__(self): + return self.name + def get_absolute_url(self): "The Album's URL locally." return reverse( @@ -119,7 +111,7 @@ def get_absolute_url(self): @property def permalink(self): "The Album's URL at Last.fm." - return "%s/music/%s/%s" % ( + return "{}/music/{}/{}".format( LASTFM_URL_ROOT, self.artist.original_slug, self.original_slug, @@ -128,7 +120,7 @@ def permalink(self): @property def musicbrainz_url(self): if self.mbid: - return "%s/release/%s" % (MUSICBRAINZ_URL_ROOT, self.mbid) + return f"{MUSICBRAINZ_URL_ROOT}/release/{self.mbid}" else: return None @@ -178,12 +170,12 @@ class Artist(TimeStampedModelMixin, models.Model): objects = managers.ArtistsManager() - def __str__(self): - return self.name - class Meta: ordering = ["name"] + def __str__(self): + return self.name + def get_absolute_url(self): "The Artist's URL locally." return reverse("lastfm:artist_detail", kwargs={"artist_slug": self.slug}) @@ -191,12 +183,12 @@ def get_absolute_url(self): @property def permalink(self): "The Artist's URL at Last.fm." - return "%s/music/%s" % (LASTFM_URL_ROOT, self.original_slug) + return f"{LASTFM_URL_ROOT}/music/{self.original_slug}" @property def musicbrainz_url(self): if self.mbid: - return "%s/artist/%s" % (MUSICBRAINZ_URL_ROOT, self.mbid) + return f"{MUSICBRAINZ_URL_ROOT}/artist/{self.mbid}" else: return None @@ -281,15 +273,15 @@ class Scrobble(DittoItemModel, models.Model): null=True, ) - def __str__(self): - return "%s (%s)" % (self.title, self.post_time) - class Meta: ordering = ["-post_time"] + def __str__(self): + return f"{self.title} ({self.post_time})" + def save(self, *args, **kwargs): self.title = truncate_string( - "{} – {}".format(self.track.artist.name, self.track.name), + f"{self.track.artist.name} – {self.track.name}", chars=255, truncate="…", at_word_boundary=True, @@ -331,12 +323,12 @@ class Track(TimeStampedModelMixin, models.Model): objects = managers.TracksManager() - def __str__(self): - return self.name - class Meta: ordering = ["name"] + def __str__(self): + return self.name + def get_absolute_url(self): "The track's URL locally." return reverse( @@ -347,7 +339,7 @@ def get_absolute_url(self): @property def permalink(self): "The Track's URL at Last.fm." - return "%s/music/%s/_/%s" % ( + return "{}/music/{}/_/{}".format( LASTFM_URL_ROOT, self.artist.original_slug, self.original_slug, @@ -356,7 +348,7 @@ def permalink(self): @property def musicbrainz_url(self): if self.mbid: - return "%s/recording/%s" % (MUSICBRAINZ_URL_ROOT, self.mbid) + return f"{MUSICBRAINZ_URL_ROOT}/recording/{self.mbid}" else: return None diff --git a/ditto/lastfm/templatetags/ditto_lastfm.py b/ditto/lastfm/templatetags/ditto_lastfm.py index 32738e0..8090b57 100644 --- a/ditto/lastfm/templatetags/ditto_lastfm.py +++ b/ditto/lastfm/templatetags/ditto_lastfm.py @@ -4,8 +4,8 @@ from django import template from django.conf import settings -from ...core.utils import get_annual_item_counts -from ..models import Account, Album, Artist, Scrobble, Track +from ditto.core.utils import get_annual_item_counts +from ditto.lastfm.models import Account, Album, Artist, Scrobble, Track register = template.Library() @@ -27,7 +27,8 @@ def check_top_kwargs(**kwargs): ) if limit != "all" and isinstance(limit, int) is False: - raise ValueError("`limit` must be an integer or 'all'") + msg = "`limit` must be an integer or 'all'" + raise ValueError(msg) if ( date is not None @@ -116,9 +117,7 @@ def top_albums(account=None, artist=None, limit=10, date=None, period="day"): period -- A String: 'day', 'week', 'month', or 'year'. """ - check_top_kwargs( - **{"account": account, "limit": limit, "date": date, "period": period} - ) + check_top_kwargs(account=account, limit=limit, date=date, period=period) if artist is not None and not isinstance(artist, Artist): raise TypeError("artist must be an Artist instance, " "not a %s" % type(artist)) @@ -161,9 +160,7 @@ def top_artists(account=None, limit=10, date=None, period="day"): date -- A datetime or date, for getting Artists from a single time period. period -- A String: 'day', 'week', 'month', or 'year'. """ - check_top_kwargs( - **{"account": account, "limit": limit, "date": date, "period": period} - ) + check_top_kwargs(account=account, limit=limit, date=date, period=period) qs_kwargs = {} @@ -208,9 +205,7 @@ def top_tracks( period -- A String: 'day', 'week', 'month', or 'year'. """ - check_top_kwargs( - **{"account": account, "limit": limit, "date": date, "period": period} - ) + check_top_kwargs(account=account, limit=limit, date=date, period=period) if album is not None and type(album) is not Album: raise TypeError("album must be an Album instance, " "not a %s" % type(album)) @@ -257,7 +252,8 @@ def recent_scrobbles(account=None, limit=10): ) if isinstance(limit, int) is False: - raise ValueError("`limit` must be an integer") + msg = "`limit` must be an integer" + raise ValueError(msg) if type(account) is Account: return account.get_recent_scrobbles(limit) diff --git a/ditto/lastfm/urls.py b/ditto/lastfm/urls.py index 8b916b4..079b868 100644 --- a/ditto/lastfm/urls.py +++ b/ditto/lastfm/urls.py @@ -6,7 +6,7 @@ # The pattern for matching an Album/Artist/Track slug: -slug_chars = r"[\w.,:;=@&+%()$!°’~-]+" # noqa: W605 +slug_chars = r"[\w.,:;=@&+%()$!°’~-]+" urlpatterns = [ @@ -38,12 +38,12 @@ name="artist_albums", ), re_path( - r"^music/(?P%s)/(?P%s)/$" % (slug_chars, slug_chars), + rf"^music/(?P{slug_chars})/(?P{slug_chars})/$", view=views.AlbumDetailView.as_view(), name="album_detail", ), re_path( - r"^music/(?P%s)/_/(?P%s)/$" % (slug_chars, slug_chars), + rf"^music/(?P{slug_chars})/_/(?P{slug_chars})/$", view=views.TrackDetailView.as_view(), name="track_detail", ), diff --git a/ditto/lastfm/views.py b/ditto/lastfm/views.py index 1499018..2e7052f 100644 --- a/ditto/lastfm/views.py +++ b/ditto/lastfm/views.py @@ -5,12 +5,13 @@ from django.views.generic import DetailView, TemplateView from django.views.generic.detail import SingleObjectMixin -from ..core.utils import datetime_now -from ..core.views import PaginatedListView +from ditto.core.utils import datetime_now +from ditto.core.views import PaginatedListView + from .models import Account, Album, Artist, Scrobble, Track -class AccountsMixin(object): +class AccountsMixin: """ View Mixin for adding an `account_list` to context, with all Accounts in. And the total counts of Scrobbles, Albums, Artists and Tracks. @@ -30,17 +31,19 @@ def get_context_data(self, **kwargs): class HomeView(AccountsMixin, TemplateView): "Uses template tags to display charts, recent Scrobbles, etc." + template_name = "lastfm/home.html" class ScrobbleListView(AccountsMixin, PaginatedListView): "A multi-page list of Scrobbles, most recent first." + template_name = "lastfm/scrobble_list.html" model = Scrobble def get_queryset(self): "Pre-fetch Artists and Tracks to reduce number of queries." - qs = super(ScrobbleListView, self).get_queryset() + qs = super().get_queryset() return qs.prefetch_related("artist", "track") @@ -124,24 +127,28 @@ def get_days(self): class AlbumListView(AccountsMixin, ChartPaginatedListView): "A multi-page chart of most-scrobbled Tracks." + template_name = "lastfm/album_list.html" model = Album class ArtistListView(AccountsMixin, ChartPaginatedListView): "A multi-page chart of most-scrobbled Tracks." + template_name = "lastfm/artist_list.html" model = Artist class TrackListView(AccountsMixin, ChartPaginatedListView): "A multi-page chart of most-scrobbled Tracks." + template_name = "lastfm/track_list.html" model = Track class AlbumDetailView(DetailView): "A single Album by a particular Artist." + model = Album def get_object(self, queryset=None): @@ -159,9 +166,8 @@ def get_object(self, queryset=None): album_slug = self.kwargs.get("album_slug") if artist_slug is None or album_slug is None: - raise AttributeError( - "AlbumDetailView must be called with " "artist_slug and album_slug" - ) + msg = "AlbumDetailView must be called with " "artist_slug and album_slug" + raise AttributeError(msg) artist = Artist.objects.get(slug=artist_slug) queryset = queryset.filter(artist=artist, slug=album_slug) @@ -169,22 +175,24 @@ def get_object(self, queryset=None): try: # Get the single item from the filtered queryset obj = queryset.get() - except queryset.model.DoesNotExist: + except queryset.model.DoesNotExist as err: raise Http404( _("No %(verbose_name)s found matching the query") % {"verbose_name": queryset.model._meta.verbose_name} - ) + ) from err return obj class ArtistDetailView(DetailView): "One Artist. Uses a template tag to display a chart of their Tracks." + model = Artist slug_url_kwarg = "artist_slug" class ArtistAlbumsView(DetailView): "One Artist. Uses a template tag to display a chart of their Albums." + model = Artist slug_url_kwarg = "artist_slug" template_name = "lastfm/artist_albums.html" @@ -192,6 +200,7 @@ class ArtistAlbumsView(DetailView): class TrackDetailView(DetailView): "One Track by a particular Artist." + model = Track def get_object(self, queryset=None): @@ -209,9 +218,8 @@ def get_object(self, queryset=None): track_slug = self.kwargs.get("track_slug") if artist_slug is None or track_slug is None: - raise AttributeError( - "TrackDetailView must be called with " "artist_slug and track_slug" - ) + msg = "TrackDetailView must be called with " "artist_slug and track_slug" + raise AttributeError(msg) artist = Artist.objects.get(slug=artist_slug) queryset = queryset.filter(artist=artist, slug=track_slug) @@ -219,11 +227,11 @@ def get_object(self, queryset=None): try: # Get the single item from the filtered queryset obj = queryset.get() - except queryset.model.DoesNotExist: + except queryset.model.DoesNotExist as err: raise Http404( _("No %(verbose_name)s found matching the query") % {"verbose_name": queryset.model._meta.verbose_name} - ) + ) from err return obj @@ -259,12 +267,14 @@ def get_context_data(self, **kwargs): class UserDetailView(SingleAccountMixin, DetailView): "Overview of the user; top 10s of everything." + template_name = "lastfm/user_detail.html" model = Account class UserAlbumListView(SingleAccountMixin, ChartPaginatedListView): "Chart of Albums scrobbled by one user." + template_name = "lastfm/user_album_list.html" model = Album @@ -279,6 +289,7 @@ def get_queryset(self): class UserArtistListView(SingleAccountMixin, ChartPaginatedListView): "Chart of Artists scrobbled by one user." + template_name = "lastfm/user_artist_list.html" model = Artist @@ -293,6 +304,7 @@ def get_queryset(self): class UserScrobbleListView(SingleAccountMixin, PaginatedListView): "All scrobbles by one user." + template_name = "lastfm/user_scrobble_list.html" model = Scrobble @@ -312,6 +324,7 @@ def get_context_data(self, **kwargs): class UserTrackListView(SingleAccountMixin, ChartPaginatedListView): "Chart of Tracks scrobbled by one user." + template_name = "lastfm/user_track_list.html" model = Track diff --git a/ditto/pinboard/admin.py b/ditto/pinboard/admin.py index 8ab0c79..963988b 100644 --- a/ditto/pinboard/admin.py +++ b/ditto/pinboard/admin.py @@ -4,7 +4,8 @@ from taggit.forms import TagWidget from taggit.managers import TaggableManager -from ..core.admin import DittoItemModelAdmin +from ditto.core.admin import DittoItemModelAdmin + from .models import Account, Bookmark diff --git a/ditto/pinboard/checks.py b/ditto/pinboard/checks.py index 84a1506..b0c23ca 100644 --- a/ditto/pinboard/checks.py +++ b/ditto/pinboard/checks.py @@ -17,14 +17,13 @@ def check_taggit_is_installed(app_configs=None, **kwargs): ) ) - if len(checks) == 0: - if "taggit" not in settings.INSTALLED_APPS: - checks.append( - Error( - "The django-taggit app must be in INSTALLED_APPS", - hint=("Add 'taggit' to INSTALLED_APPS " "in your settings file."), - id="ditto.pinboard.E002", - ) + if len(checks) == 0 and "taggit" not in settings.INSTALLED_APPS: + checks.append( + Error( + "The django-taggit app must be in INSTALLED_APPS", + hint=("Add 'taggit' to INSTALLED_APPS " "in your settings file."), + id="ditto.pinboard.E002", ) + ) return checks diff --git a/ditto/pinboard/factories.py b/ditto/pinboard/factories.py index 3d80af1..f1abfc8 100644 --- a/ditto/pinboard/factories.py +++ b/ditto/pinboard/factories.py @@ -2,7 +2,8 @@ import factory -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from . import models diff --git a/ditto/pinboard/fetch.py b/ditto/pinboard/fetch.py index 54ec32a..dce9d16 100644 --- a/ditto/pinboard/fetch.py +++ b/ditto/pinboard/fetch.py @@ -1,11 +1,11 @@ -# coding: utf-8 import json import urllib from datetime import datetime, timezone import requests -from ..core.utils import datetime_now +from ditto.core.utils import datetime_now + from .models import Account, Bookmark PINBOARD_API_ENDPOINT = "https://api.pinboard.in/v1/" @@ -18,17 +18,16 @@ class FetchError(Exception): pass -class BookmarksFetcher(object): +class BookmarksFetcher: """The parent class containing common methods. Use one of the child classes to fetch a particular set of Bookmarks. """ def fetch(self): - raise FetchError( - "Call a child class like AllBookmarksFetcher or RecentBookmarksFetcher" - ) + msg = "Call a child class like AllBookmarksFetcher or RecentBookmarksFetcher" + raise FetchError(msg) - def _fetch(self, fetch_type, params={}, username=None): + def _fetch(self, fetch_type, params=None, username=None): """The main method for making all types of Bookmark requests, and saving the data. @@ -38,6 +37,7 @@ def _fetch(self, fetch_type, params={}, username=None): These will be used directly with the Pinboard API. username -- the username of the one Account to fetch (or None for all). """ + params = {} if params is None else params # Each element will be a dict, like: # {'account':'philgyford', 'success':True, 'fetched':12} result = [] @@ -78,7 +78,8 @@ def _get_accounts(self, username): if username is None: accounts = Account.objects.filter(is_active=True) if len(accounts) == 0: - raise FetchError("No active accounts were found to fetch.") + msg = "No active accounts were found to fetch." + raise FetchError(msg) else: account = Account.objects.get(username=username) if account.is_active: @@ -116,7 +117,7 @@ def _send_request(self, fetch_type, params, account): params["auth_token"] = account.api_token query_string = urllib.parse.urlencode(params) - final_url = "{}?{}".format(url, query_string) + final_url = f"{url}?{query_string}" error_message = "" @@ -167,10 +168,7 @@ def _parse_response(self, fetch_type, json_text): response = json.loads(json_text) # The JSON has different formats depending on what we fetched: - if fetch_type == "all": - posts = response - else: - posts = response["posts"] + posts = response if fetch_type == "all" else response["posts"] for bookmark in posts: # Before we do anything to it, we give the bookmark a 'json' @@ -181,8 +179,8 @@ def _parse_response(self, fetch_type, json_text): bookmark["time"], "%Y-%m-%dT%H:%M:%SZ" ).replace(tzinfo=timezone.utc) # 'yes'/'no' to booleans: - bookmark["shared"] = True if bookmark["shared"] == "yes" else False - bookmark["toread"] = True if bookmark["toread"] == "yes" else False + bookmark["shared"] = bookmark["shared"] == "yes" + bookmark["toread"] = bookmark["toread"] == "yes" # String into an array of strings: bookmark["tags"] = bookmark["tags"].split() @@ -251,9 +249,9 @@ def fetch(self, post_date, username=None): FetchError if the date format is invalid. """ try: - dt = datetime.strptime(post_date, "%Y-%m-%d") - except ValueError: - raise FetchError("Invalid date format ('%s')" % post_date) + dt = datetime.strptime(post_date, "%Y-%m-%d").astimezone(timezone.utc) + except ValueError as err: + raise FetchError("Invalid date format ('%s')" % post_date) from err else: return self._fetch(fetch_type="date", params={"dt": dt}, username=username) diff --git a/ditto/pinboard/management/commands/fetch_pinboard_bookmarks.py b/ditto/pinboard/management/commands/fetch_pinboard_bookmarks.py index 53aeac8..887a7bf 100644 --- a/ditto/pinboard/management/commands/fetch_pinboard_bookmarks.py +++ b/ditto/pinboard/management/commands/fetch_pinboard_bookmarks.py @@ -1,8 +1,7 @@ -# coding: utf-8 from django.core.management.base import CommandError -from ....core.management.commands import DittoBaseCommand -from ...fetch import ( +from ditto.core.management.commands import DittoBaseCommand +from ditto.pinboard.fetch import ( AllBookmarksFetcher, DateBookmarksFetcher, RecentBookmarksFetcher, @@ -66,7 +65,6 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - # We might be fetching for a specific account or all (None). account = options["account"] if options["account"] else None @@ -87,10 +85,10 @@ def handle(self, *args, **options): results = UrlBookmarksFetcher().fetch(url=options["url"], username=account) elif options["account"]: - raise CommandError( - "Specify --all, --recent, --date= or --url= as well as --account." - ) + msg = "Specify --all, --recent, --date= or --url= as well as --account." + raise CommandError(msg) else: - raise CommandError("Specify --all, --recent, --date= or --url=") + msg = "Specify --all, --recent, --date= or --url=" + raise CommandError(msg) self.output_results(results, options.get("verbosity", 1)) diff --git a/ditto/pinboard/models.py b/ditto/pinboard/models.py index 22bc7b0..0040da6 100644 --- a/ditto/pinboard/models.py +++ b/ditto/pinboard/models.py @@ -1,4 +1,3 @@ -# coding: utf-8 import hashlib from django.core.validators import URLValidator @@ -6,7 +5,8 @@ from taggit.managers import TaggableManager from taggit.models import GenericTaggedItemBase, TagBase -from ..core.models import DittoItemModel, TimeStampedModelMixin +from ditto.core.models import DittoItemModel, TimeStampedModelMixin + from .managers import PublicToreadManager, ToreadManager, _BookmarkTaggableManager @@ -42,12 +42,12 @@ class Account(TimeStampedModelMixin, models.Model): help_text="If false, new Bookmarks won't be fetched.", ) - def __str__(self): - return self.username - class Meta: ordering = ["username"] + def __str__(self): + return self.username + def get_absolute_url(self): from django.urls import reverse @@ -141,7 +141,6 @@ class TaggedBookmark(TimeStampedModelMixin, GenericTaggedItemBase): class Bookmark(DittoItemModel, ExtraBookmarkManagers): - ditto_item_name = "pinboard_bookmark" # Properties inherited from DittoItemModel: @@ -224,7 +223,7 @@ def get_next_public_by_post_time(self): .order_by("post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass def get_previous_public_by_post_time(self): @@ -238,7 +237,7 @@ def get_previous_public_by_post_time(self): .order_by("-post_time")[:1] .get() ) - except Exception: + except Exception: # noqa: BLE001 pass # Shortcuts: diff --git a/ditto/pinboard/templates/pinboard/includes/bookmark.html b/ditto/pinboard/templates/pinboard/includes/bookmark.html index 856a529..9462076 100644 --- a/ditto/pinboard/templates/pinboard/includes/bookmark.html +++ b/ditto/pinboard/templates/pinboard/includes/bookmark.html @@ -40,9 +40,9 @@