diff --git a/pyproject.toml b/pyproject.toml index 8a3e608..58f7e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,9 +61,9 @@ bandit = "^1.7.4" [tool.pytest.ini_options] minversion = "6.0" -addopts = "-ra -q" +addopts = "-ra -q --reuse-db" testpaths = ["tests"] -DJANGO_SETTINGS_MODULE='vrobbler.settings' +DJANGO_SETTINGS_MODULE='vrobbler.settings-testing' [tool.black] line-length = 79 diff --git a/tests/scrobbles_tests/test_aggregators.py b/tests/scrobbles_tests/test_aggregators.py index ea9251f..3bcbff8 100644 --- a/tests/scrobbles_tests/test_aggregators.py +++ b/tests/scrobbles_tests/test_aggregators.py @@ -1,4 +1,6 @@ from datetime import datetime, timedelta +from unittest import mock +from unittest.mock import patch import pytest import time_machine @@ -6,6 +8,7 @@ from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles +from music.models import Album, Artist from profiles.models import UserProfile from scrobbles.models import Scrobble @@ -47,6 +50,7 @@ def test_week_of_scrobbles_data(client, mopidy_track_request_data): @pytest.mark.django_db +@time_machine.travel(datetime(2022, 3, 4, 1, 24)) def test_top_tracks_by_day(client, mopidy_track_request_data): build_scrobbles(client, mopidy_track_request_data, 7, 1) user = get_user_model().objects.first() @@ -55,6 +59,7 @@ def test_top_tracks_by_day(client, mopidy_track_request_data): @pytest.mark.django_db +@time_machine.travel(datetime(2022, 3, 4, 1, 24)) def test_top_tracks_by_week(client, mopidy_track_request_data): build_scrobbles(client, mopidy_track_request_data, 7, 1) user = get_user_model().objects.first() @@ -63,14 +68,16 @@ def test_top_tracks_by_week(client, mopidy_track_request_data): @pytest.mark.django_db -def test_top_tracks_by_month(client, mopidy_track_request_data): +@time_machine.travel(datetime(2022, 3, 4, 1, 24)) +def test_top_tracks_by_month(client, mopidy_track_request_data, mocker): build_scrobbles(client, mopidy_track_request_data, 7, 1) - user = get_user_model().objects.first() + user = get_user_model().objects.get(id=1) tops = live_charts(user, chart_period="month") assert tops[0].title == "Same in the End" @pytest.mark.django_db +@time_machine.travel(datetime(2022, 3, 4, 1, 24)) def test_top_tracks_by_year(client, mopidy_track_request_data): build_scrobbles(client, mopidy_track_request_data, 7, 1) user = get_user_model().objects.first() @@ -79,7 +86,8 @@ def test_top_tracks_by_year(client, mopidy_track_request_data): @pytest.mark.django_db -def test_top__artists_by_week(client, mopidy_track_request_data): +@time_machine.travel(datetime(2022, 3, 4, 1, 24)) +def test_top_artists_by_week(client, mopidy_track_request_data): build_scrobbles(client, mopidy_track_request_data, 7, 1) user = get_user_model().objects.first() tops = live_charts(user, chart_period="week", media_type="Artist") diff --git a/vrobbler.conf.test b/vrobbler.conf.test index 9e85630..9541b3a 100644 --- a/vrobbler.conf.test +++ b/vrobbler.conf.test @@ -6,3 +6,6 @@ VROBBLER_DEBUG=True VROBBLER_LOG_LEVEL="DEBUG" VROBBLER_MEDIA_ROOT = "/tmp/media/" VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True + +VROBBLER_USE_S3="False" +VROBBLER_DATABASE_URL="sqlite:///testdb.sqlite3" diff --git a/vrobbler/settings-testing.py b/vrobbler/settings-testing.py new file mode 100644 index 0000000..9009b63 --- /dev/null +++ b/vrobbler/settings-testing.py @@ -0,0 +1,350 @@ +import os +import sys +from pathlib import Path + + +import dj_database_url +from dotenv import load_dotenv + +TRUTHY = ("true", "1", "t") + +PROJECT_ROOT = Path(__file__).resolve().parent +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, os.path.join(PROJECT_ROOT, "apps")) + +load_dotenv("vrobbler.conf.test") + +# Build paths inside the project like this: BASE_DIR / 'subdir'. + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv("VROBBLER_SECRET_KEY", "not-a-secret-234lkjasdflj132") + + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv("VROBBLER_DEBUG", "false").lower() in TRUTHY + +TAGGIT_CASE_INSENSITIVE = True + +KEEP_DETAILED_SCROBBLE_LOGS = os.getenv( + "VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False +) + +# Key must be 16, 24 or 32 bytes long and will be converted to a byte stream +ENCRYPTED_FIELD_KEY = os.getenv( + "VROBBLER_ENCRYPTED_FIELD_KEY", "12345678901234567890123456789012" +) + +DJANGO_ENCRYPTED_FIELD_KEY = bytes(ENCRYPTED_FIELD_KEY, "utf-8") + +# Should we cull old in-progress scrobbles that are beyond the wait period for resuming? +DELETE_STALE_SCROBBLES = ( + os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", "true").lower() in TRUTHY +) + +# Used to dump data coming from srobbling sources, helpful for building new inputs +DUMP_REQUEST_DATA = ( + os.getenv("VROBBLER_DUMP_REQUEST_DATA", "false").lower() in TRUTHY +) + +THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2") +THEAUDIODB_API_KEY = os.getenv("VROBBLER_THEAUDIODB_API_KEY", "2") +TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "") +LASTFM_API_KEY = os.getenv("VROBBLER_LASTFM_API_KEY") +LASTFM_SECRET_KEY = os.getenv("VROBBLER_LASTFM_SECRET_KEY") +IGDB_CLIENT_ID = os.getenv("VROBBLER_IGDB_CLIENT_ID") +IGDB_CLIENT_SECRET = os.getenv("VROBBLER_IGDB_CLIENT_SECRET") + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern") + +ALLOWED_HOSTS = ["*"] +CSRF_TRUSTED_ORIGINS = [ + os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000") +] +X_FRAME_OPTIONS = "SAMEORIGIN" + +REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None) +if REDIS_URL: + print(f"Sending tasks to redis@{REDIS_URL.split('@')[-1]}") +else: + print("Eagerly running all tasks") + +CELERY_TASK_ALWAYS_EAGER = ( + os.getenv("VROBBLER_SKIP_CELERY", "false").lower() in TRUTHY +) +CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/" +CELERY_RESULT_BACKEND = "django-db" +CELERY_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern") +CELERY_TASK_TRACK_STARTED = True + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.sites", + "django.contrib.humanize", + "django_filters", + "django_extensions", + "storages", + "taggit", + "rest_framework.authtoken", + "encrypted_field", + "profiles", + "scrobbles", + "videos", + "music", + "podcasts", + "sports", + "books", + "boardgames", + "videogames", + "mathfilters", + "rest_framework", + "allauth", + "allauth.account", + "allauth.socialaccount", + "django_celery_results", +] + +SITE_ID = 1 + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.gzip.GZipMiddleware", +] + +ROOT_URLCONF = "vrobbler.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [str(PROJECT_ROOT.joinpath("templates"))], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "videos.context_processors.video_lists", + "music.context_processors.music_lists", + "scrobbles.context_processors.now_playing", + ], + }, + }, +] + +MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" + +WSGI_APPLICATION = "vrobbler.wsgi.application" + +DATABASES = { + "default": dj_database_url.config( + default=os.getenv("VROBBLER_DATABASE_URL", "sqlite:///db.sqlite3"), + conn_max_age=600, + ), +} + + +db_str = "" +if "sqlite" in DATABASES["default"]["ENGINE"]: + db_str = f"Connected to sqlite@{DATABASES['default']['NAME']}" +if "postgresql" in DATABASES["default"]["ENGINE"]: + db_str = f"Connected to postgres@{DATABASES['default']['HOST']}/{DATABASES['default']['NAME']}" +if db_str: + print(db_str) + + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "unique-snowflake", + } +} +if REDIS_URL: + CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache" + CACHES["default"]["LOCATION"] = REDIS_URL + +SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", +] + +# We have to ignore content negotiation because Jellyfin is a bad actor +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",), + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_CONTENT_NEGOTIATION_CLASS": "vrobbler.negotiation.IgnoreClientContentNegotiation", + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend" + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 200, +} + +LOGIN_REDIRECT_URL = "/" + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York") + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ +# +from storages.backends import s3boto3 + +USE_S3_STORAGE = os.getenv("VROBBLER_USE_S3", "False").lower() in TRUTHY + +if USE_S3_STORAGE: + AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "") + AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "") + AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID") + AWS_S3_SECRET_ACCESS_KEY = os.getenv("AWS_S3_SECRET_ACCESS_KEY") + + S3_ROOT = "/".join([AWS_S3_ENDPOINT_URL, AWS_STORAGE_BUCKET_NAME]) + print(f"Storing media on S3 at {S3_ROOT}") + + DEFAULT_FILE_STORAGE = "vrobbler.storages.MediaStorage" + STATICFILES_STORAGE = "vrobbler.storages.StaticStorage" + STATIC_URL = S3_ROOT + "/static/" + MEDIA_URL = S3_ROOT + "/media/" + +else: + STATIC_ROOT = os.getenv( + "VROBBLER_STATIC_ROOT", os.path.join(PROJECT_ROOT, "static") + ) + MEDIA_ROOT = os.getenv( + "VROBBLER_MEDIA_ROOT", os.path.join(PROJECT_ROOT, "media") + ) + STATIC_URL = os.getenv("VROBBLER_STATIC_URL", "/static/") + MEDIA_URL = os.getenv("VROBBLER_MEDIA_URL", "/media/") + + +JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY +LOG_TYPE = "json" if JSON_LOGGING else "log" + +default_level = "INFO" +if DEBUG: + default_level = "DEBUG" + +LOG_LEVEL = os.getenv("VROBBLER_LOG_LEVEL", default_level) +LOG_FILE_PATH = os.getenv("VROBBLER_LOG_FILE_PATH", "/tmp/") + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "root": { + "handlers": ["console", "file"], + "level": LOG_LEVEL, + "propagate": True, + }, + "formatters": { + "color": { + "()": "colorlog.ColoredFormatter", + # \r returns caret to line beginning, in tests this eats the silly dot that removes + # the beautiful alignment produced below + "format": "\r" + "{log_color}{levelname:8s}{reset} " + "{bold_cyan}{name}{reset}:" + "{fg_bold_red}{lineno}{reset} " + "{thin_yellow}{funcName} " + "{thin_white}{message}" + "{reset}", + "style": "{", + }, + "log": {"format": "%(asctime)s %(levelname)s %(message)s"}, + "json": { + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "format": "%(levelname)s %(name) %(funcName) %(lineno) %(asctime)s %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "color", + "level": LOG_LEVEL, + }, + "null": { + "class": "logging.NullHandler", + "level": LOG_LEVEL, + }, + "sql": { + "class": "logging.handlers.RotatingFileHandler", + "filename": "".join([LOG_FILE_PATH, "vrobbler_sql.", LOG_TYPE]), + "formatter": LOG_TYPE, + "level": LOG_LEVEL, + }, + "file": { + "class": "logging.handlers.RotatingFileHandler", + "filename": "".join([LOG_FILE_PATH, "vrobbler.", LOG_TYPE]), + "formatter": LOG_TYPE, + "level": LOG_LEVEL, + }, + }, + "loggers": { + # Quiet down our console a little + "django": { + "handlers": ["null"], + "propagate": True, + }, + "django.db.backends": {"handlers": ["null"]}, + "django.server": {"handlers": ["null"]}, + "pylast": {"handlers": ["null"], "propagate": False}, + "musicbrainzngs": {"handlers": ["null"], "propagate": False}, + "httpx": {"handlers": ["null"], "propagate": False}, + "vrobbler": { + "handlers": ["console"], + "propagate": False, + }, + }, +} + +LOG_TO_CONSOLE = ( + os.getenv("VROBBLER_LOG_TO_CONSOLE", "false").lower() in TRUTHY +) +if LOG_TO_CONSOLE: + LOGGING["loggers"]["django"]["handlers"] = ["console"] + LOGGING["loggers"]["vrobbler"]["handlers"] = ["console"]