diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f8c4595 --- /dev/null +++ b/.env.example @@ -0,0 +1,68 @@ +# Django Configuration +# Set to False in production +DEBUG=True + +# Django Secret Key +# Generate a new secret key for production: python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' +DJANGO_SECRET_KEY=your-secret-key-here + +# Email Configuration +# SMTP Host (examples below) +EMAIL_HOST=smtp.gmail.com + +# SMTP Port (common ports: 587 for TLS, 465 for SSL, 25 for unencrypted) +EMAIL_PORT=587 + +# Email credentials +EMAIL_HOST_USER=your-email@gmail.com +EMAIL_HOST_PASSWORD=your-app-password-here + +# Connection security +# Use TLS (True for most providers like Gmail, Outlook) +EMAIL_USE_TLS=True +# Use SSL (False for most providers, True for some like older configurations) +EMAIL_USE_SSL=False + +# Connection timeout in seconds +EMAIL_TIMEOUT=10 + +# Common SMTP Provider Examples: +# +# Gmail: +# EMAIL_HOST=smtp.gmail.com +# EMAIL_PORT=587 +# EMAIL_USE_TLS=True +# EMAIL_USE_SSL=False +# Note: Requires App Password with 2FA enabled +# +# Outlook/Hotmail: +# EMAIL_HOST=smtp-mail.outlook.com +# EMAIL_PORT=587 +# EMAIL_USE_TLS=True +# EMAIL_USE_SSL=False +# +# Yahoo: +# EMAIL_HOST=smtp.mail.yahoo.com +# EMAIL_PORT=587 +# EMAIL_USE_TLS=True +# EMAIL_USE_SSL=False +# +# SendGrid: +# EMAIL_HOST=smtp.sendgrid.net +# EMAIL_PORT=587 +# EMAIL_USE_TLS=True +# EMAIL_USE_SSL=False +# EMAIL_HOST_USER=apikey +# EMAIL_HOST_PASSWORD=your-sendgrid-api-key +# +# Mailgun: +# EMAIL_HOST=smtp.mailgun.org +# EMAIL_PORT=587 +# EMAIL_USE_TLS=True +# EMAIL_USE_SSL=False +# +# Amazon SES: +# EMAIL_HOST=email-smtp.us-east-1.amazonaws.com +# EMAIL_PORT=587 +# EMAIL_USE_TLS=True +# EMAIL_USE_SSL=False diff --git a/.vscode/settings.json b/.vscode/settings.json index fbc0c0b..83ab92b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,22 @@ { "cSpell.words": [ + "adminpass", + "apikey", + "appauthor", + "appname", "ASGI", + "docstrings", + "dotenv", + "Hellsén", "isort", + "Joakim", + "lovinator", + "Mailgun", "pydocstyle", - "ttvdrops" + "regularuser", + "sendgrid", + "testpass", + "ttvdrops", + "wrongpassword" ] } \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index c19ffa9..5b65046 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,24 +1,105 @@ from __future__ import annotations +import os +import sys from pathlib import Path +from typing import Any -# Build paths inside the project like this: BASE_DIR / 'subdir'. +from dotenv import load_dotenv +from platformdirs import user_data_dir + +load_dotenv(verbose=True) + +DEBUG: bool = os.getenv(key="DEBUG", default="True").lower() == "true" + + +def get_data_dir() -> Path: + r"""Get the directory where the application data will be stored. + + This directory is created if it does not exist. + + Returns: + Path: The directory where the application data will be stored. + + For example, on Windows, it might be: + `C:\Users\lovinator\AppData\Roaming\TheLovinator\TTVDrops` + + In this directory, the SQLite database file will be stored as `db.sqlite3`. + """ + data_dir: str = user_data_dir( + appname="TTVDrops", + appauthor="TheLovinator", + roaming=True, + ensure_exists=True, + ) + return Path(data_dir) + + +DATA_DIR: Path = get_data_dir() + +ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")] +AUTH_USER_MODEL = "core.User" BASE_DIR: Path = Path(__file__).resolve().parent.parent +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +ROOT_URLCONF = "config.urls" +SECRET_KEY: str = os.getenv("DJANGO_SECRET_KEY", default="") + +DEFAULT_FROM_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None) +EMAIL_HOST: str = os.getenv(key="EMAIL_HOST", default="smtp.gmail.com") +EMAIL_HOST_PASSWORD: str | None = os.getenv(key="EMAIL_HOST_PASSWORD", default=None) +EMAIL_HOST_USER: str | None = os.getenv(key="EMAIL_HOST_USER", default=None) +EMAIL_PORT: int = int(os.getenv(key="EMAIL_PORT", default="587")) +EMAIL_SUBJECT_PREFIX = "[TTVDrops] " +EMAIL_TIMEOUT: int = int(os.getenv(key="EMAIL_TIMEOUT", default="10")) +EMAIL_USE_LOCALTIME = True +EMAIL_USE_TLS: bool = os.getenv(key="EMAIL_USE_TLS", default="True").lower() == "true" +EMAIL_USE_SSL: bool = os.getenv(key="EMAIL_USE_SSL", default="False").lower() == "true" +SERVER_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None) + +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/" + +MEDIA_ROOT: Path = DATA_DIR / "media" +MEDIA_ROOT.mkdir(exist_ok=True) +MEDIA_URL = "/media/" -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ +STATIC_ROOT: Path = BASE_DIR / "staticfiles" +STATIC_ROOT.mkdir(exist_ok=True) +STATIC_URL = "static/" +STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"] -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-5b+c(lxr3-8o356!qd3_u&x0$)j))56at=&_go+@4gmai-oe2v" +TIME_ZONE = "UTC" +WSGI_APPLICATION = "config.wsgi.application" -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +if DEBUG: + INTERNAL_IPS: list[str] = ["127.0.0.1", "localhost"] -ALLOWED_HOSTS = [] +if not DEBUG: + ALLOWED_HOSTS: list[str] = ["ttvdrops.lovinator.space"] - -# Application definition +LOGGING: dict[str, Any] = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + }, + }, + "loggers": { + "": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + "django.utils.autoreload": { + "handlers": ["console"], + "level": "INFO", + "propagate": True, + }, + }, +} INSTALLED_APPS: list[str] = [ "django.contrib.admin", @@ -27,6 +108,8 @@ INSTALLED_APPS: list[str] = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.sites", + "core", ] MIDDLEWARE: list[str] = [ @@ -36,43 +119,46 @@ MIDDLEWARE: list[str] = [ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "config.urls" -TEMPLATES = [ +TEMPLATES: list[dict[str, str | list[Path] | bool | dict[str, list[str] | list[tuple[str, list[str]]]]]] = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ - "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", + "django.template.context_processors.i18n", + "django.template.context_processors.debug", + "django.template.context_processors.request", "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = "config.wsgi.application" - -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - -DATABASES = { +DATABASES: dict[str, dict[str, str | Path | dict[str, str | int]]] = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", - } + "NAME": DATA_DIR / "db.sqlite3", + "OPTIONS": { + "transaction_mode": "IMMEDIATE", + "timeout": 5, + "init_command": """ + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA mmap_size=134217728; + PRAGMA journal_size_limit=27103364; + PRAGMA cache_size=2000; + """, + }, + }, } -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS: list[dict[str, str]] = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", @@ -88,25 +174,15 @@ AUTH_PASSWORD_VALIDATORS: list[dict[str, str]] = [ }, ] +TESTING: bool = "test" in sys.argv or "PYTEST_VERSION" in os.environ -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ - -STATIC_URL = "static/" - -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +if not TESTING: + DEBUG_TOOLBAR_CONFIG: dict[str, str] = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"} + INSTALLED_APPS = [ # pyright: ignore[reportConstantRedefinition] + *INSTALLED_APPS, + "debug_toolbar", + ] + MIDDLEWARE = [ # pyright: ignore[reportConstantRedefinition] + "debug_toolbar.middleware.DebugToolbarMiddleware", + *MIDDLEWARE, + ] diff --git a/config/urls.py b/config/urls.py index 362fc4e..b60bd7f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +from debug_toolbar.toolbar import debug_toolbar_urls # pyright: ignore[reportMissingTypeStubs] +from django.conf import settings from django.contrib import admin from django.urls import path @@ -11,3 +13,6 @@ if TYPE_CHECKING: urlpatterns: list[URLResolver] = [ path(route="admin/", view=admin.site.urls), ] + +if not settings.TESTING: + urlpatterns = [*urlpatterns, *debug_toolbar_urls()] diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..11c5312 --- /dev/null +++ b/conftest.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +# Pytest configuration for Django testing +import os + +import django +from django.conf import settings +from django.contrib.auth.models import update_last_login +from django.contrib.auth.signals import user_logged_in + + +def pytest_configure() -> None: + """Configure Django settings for pytest.""" + if not settings.configured: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + django.setup() + + # Use faster password hasher for tests + settings.PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", + ] + + # Disconnect update_last_login signal to avoid unnecessary DB writes + user_logged_in.disconnect(update_last_login) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..f72d1f8 --- /dev/null +++ b/core/admin.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from core.models import User + +# Register your custom User model with the admin +admin.site.register(User, UserAdmin) diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..df59afa --- /dev/null +++ b/core/apps.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + """Configuration class for the 'core' Django application. + + Attributes: + default_auto_field (str): Specifies the type of auto-created primary key field to use for models in this app. + name (str): The full Python path to the application. + verbose_name (str): A human-readable name for the application. + """ + + default_auto_field: str = "django.db.models.BigAutoField" + name = "core" + verbose_name: str = "Core Application" diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..594b3af --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.4 on 2025-07-08 00:33 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + 'db_table': 'auth_user', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..2f8d2fa --- /dev/null +++ b/core/models.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from django.contrib.auth.models import AbstractUser + + +class User(AbstractUser): + """Custom User model extending Django's AbstractUser. + + This allows for future customization of the User model + while maintaining all the default Django User functionality. + """ + + # Add any custom fields here in the future + # For example: + # bio = models.TextField(max_length=500, blank=True) + # avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) + + class Meta: + db_table = "auth_user" # Keep the same table name as Django's default User + verbose_name = "User" + verbose_name_plural = "Users" diff --git a/core/tests/__init__.py b/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/test_admin.py b/core/tests/test_admin.py new file mode 100644 index 0000000..3eb70ee --- /dev/null +++ b/core/tests/test_admin.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import AbstractUser +from django.db.models.base import Model +from django.test import RequestFactory + +if TYPE_CHECKING: + from django.contrib.admin import ModelAdmin + from django.contrib.auth.models import AbstractUser + from django.db.models.base import Model + +User: type[AbstractUser] = get_user_model() + + +@pytest.mark.django_db +class TestUserAdmin: + """Test cases for User admin configuration.""" + + def setup_method(self) -> None: + """Set up test data for each test method.""" + self.factory = RequestFactory() + self.superuser: AbstractUser = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="test_admin_password_123", + ) + + def test_user_model_registered_in_admin(self) -> None: + """Test that User model is registered in Django admin.""" + registry: dict[type[Model], ModelAdmin[Any]] = admin.site._registry # noqa: SLF001 + assert User in registry + + def test_user_admin_class(self) -> None: + """Test that User is registered with UserAdmin.""" + registry: dict[type[Model], ModelAdmin[Any]] = admin.site._registry # noqa: SLF001 + admin_class: admin.ModelAdmin[Any] = registry[User] + assert isinstance(admin_class, UserAdmin) + + def test_user_admin_can_create_user(self) -> None: + """Test that admin can create users through the interface.""" + # Test that the admin form can handle user creation + registry: dict[type[Model], ModelAdmin[Any]] = admin.site._registry # noqa: SLF001 + user_admin: admin.ModelAdmin[Any] = registry[User] + + # Check that the admin has the expected methods + assert hasattr(user_admin, "get_form") + assert hasattr(user_admin, "save_model") + + def test_user_admin_list_display(self) -> None: + """Test admin list display configuration.""" + registry: dict[type[Model], ModelAdmin[Any]] = admin.site._registry # noqa: SLF001 + user_admin: admin.ModelAdmin[Any] = registry[User] + + # UserAdmin should have default list_display + expected_fields: tuple[str, ...] = ( + "username", + "email", + "first_name", + "last_name", + "is_staff", + ) + assert user_admin.list_display == expected_fields + + def test_user_admin_search_fields(self) -> None: + """Test admin search fields configuration.""" + registry: dict[type[Model], ModelAdmin[Any]] = admin.site._registry # noqa: SLF001 + user_admin: admin.ModelAdmin[Any] = registry[User] + + # UserAdmin should have default search fields + expected_search_fields: tuple[str, ...] = ("username", "first_name", "last_name", "email") + assert user_admin.search_fields == expected_search_fields + + def test_user_admin_fieldsets(self) -> None: + """Test admin fieldsets configuration.""" + registry: dict[type[Model], ModelAdmin[Any]] = admin.site._registry # noqa: SLF001 + user_admin: admin.ModelAdmin[Any] = registry[User] + + # UserAdmin should have fieldsets defined + assert hasattr(user_admin, "fieldsets") + assert user_admin.fieldsets is not None diff --git a/core/tests/test_models.py b/core/tests/test_models.py new file mode 100644 index 0000000..970f465 --- /dev/null +++ b/core/tests/test_models.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + +User: type[AbstractUser] = get_user_model() + + +@pytest.mark.django_db +class TestUserModel: + """Test cases for the custom User model.""" + + def test_create_user(self) -> None: + """Test creating a regular user.""" + user: AbstractUser = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + + assert user.username == "testuser" + assert user.email == "test@example.com" + assert user.check_password("testpass123") + assert not user.is_staff + assert not user.is_superuser + assert user.is_active + + def test_create_superuser(self) -> None: + """Test creating a superuser.""" + superuser: AbstractUser = User.objects.create_superuser(username="admin", email="admin@example.com", password="adminpass123") + + assert superuser.username == "admin" + assert superuser.email == "admin@example.com" + assert superuser.check_password("adminpass123") + assert superuser.is_staff + assert superuser.is_superuser + assert superuser.is_active + + def test_user_str_representation(self) -> None: + """Test the string representation of the user.""" + user: AbstractUser = User.objects.create_user(username="testuser", email="test@example.com") + + assert str(user) == "testuser" + + def test_user_email_field(self) -> None: + """Test that email field works correctly.""" + user: AbstractUser = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + + assert user.email == "test@example.com" + + # Test email update + user.email = "newemail@example.com" + user.save() + user.refresh_from_db() + + assert user.email == "newemail@example.com" + + def test_user_unique_username(self) -> None: + """Test that username must be unique.""" + User.objects.create_user(username="testuser", email="test1@example.com", password="testpass123") + + # Attempting to create another user with the same username should raise an error + with pytest.raises(IntegrityError): + User.objects.create_user(username="testuser", email="test2@example.com", password="testpass123") + + def test_user_password_hashing(self) -> None: + """Test that passwords are properly hashed.""" + password = "testpass123" + user: AbstractUser = User.objects.create_user(username="testuser", email="test@example.com", password=password) + + # Password should be hashed, not stored in plain text + assert user.password != password + assert user.check_password(password) + assert not user.check_password("wrongpassword") + + def test_user_without_email(self) -> None: + """Test creating a user without email.""" + user: AbstractUser = User.objects.create_user(username="testuser", password="testpass123") + + assert user.username == "testuser" + assert not user.email + assert user.check_password("testpass123") + + def test_user_model_meta_options(self) -> None: + """Test the model meta options.""" + assert User._meta.db_table == "auth_user" # noqa: SLF001 + assert User._meta.verbose_name == "User" # noqa: SLF001 + assert User._meta.verbose_name_plural == "Users" # noqa: SLF001 + + def test_user_manager_methods(self) -> None: + """Test User manager methods.""" + # Test create_user method + user: AbstractUser = User.objects.create_user(username="regularuser", email="regular@example.com", password="pass123") + assert not user.is_staff + assert not user.is_superuser + + # Test create_superuser method + superuser: AbstractUser = User.objects.create_superuser(username="superuser", email="super@example.com", password="superpass123") + assert superuser.is_staff + assert superuser.is_superuser + + def test_user_permissions(self) -> None: + """Test user permissions and groups.""" + user: AbstractUser = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + + # Initially user should have no permissions + assert not user.user_permissions.exists() + assert not user.groups.exists() + + # Test has_perm method (should be False for regular user) + assert not user.has_perm("auth.add_user") + + def test_user_active_status(self) -> None: + """Test user active status functionality.""" + user: AbstractUser = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + + # User should be active by default + assert user.is_active + + # Test deactivating user + user.is_active = False + user.save() + user.refresh_from_db() + + assert not user.is_active + + def test_user_date_joined(self) -> None: + """Test that date_joined is automatically set.""" + user: AbstractUser = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + + assert user.date_joined is not None + + def test_user_last_login_initially_none(self) -> None: + """Test that last_login is initially None.""" + user: AbstractUser = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + + assert user.last_login is None + + +@pytest.mark.django_db +class TestUserModelEdgeCases: + """Test edge cases and error conditions for the User model.""" + + def test_create_user_without_username(self) -> None: + """Test that creating a user without username raises an error.""" + with pytest.raises(ValueError, match="The given username must be set"): + User.objects.create_user(username="", email="test@example.com", password="testpass123") + + def test_create_superuser_without_is_staff(self) -> None: + """Test that create_superuser enforces is_staff=True.""" + with pytest.raises(ValueError, match=r"Superuser must have is_staff=True."): + User.objects.create_superuser(username="admin", email="admin@example.com", password="adminpass123", is_staff=False) + + def test_create_superuser_without_is_superuser(self) -> None: + """Test that create_superuser enforces is_superuser=True.""" + with pytest.raises(ValueError, match=r"Superuser must have is_superuser=True."): + User.objects.create_superuser(username="admin", email="admin@example.com", password="adminpass123", is_superuser=False) + + def test_user_with_very_long_username(self) -> None: + """Test username length validation.""" + # Django's default max_length for username is 150 + long_username: str = "a" * 151 + + user = User(username=long_username, email="test@example.com") + + with pytest.raises(ValidationError): + user.full_clean() + + def test_user_with_invalid_email_format(self) -> None: + """Test email format validation.""" + user = User(username="testuser", email="invalid-email-format") + + with pytest.raises(ValidationError): + user.full_clean() diff --git a/pyproject.toml b/pyproject.toml index bcb2e95..aca59b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,28 +4,30 @@ version = "0.1.0" description = "Get notified when a new drop is available on Twitch." readme = "README.md" requires-python = ">=3.13" -dependencies = ["django>=5.2.4"] +dependencies = [ + "django>=5.2.4", + "django-debug-toolbar>=5.2.0", + "platformdirs>=4.3.8", + "python-dotenv>=1.1.1", +] [dependency-groups] -dev = ["pytest>=8.4.1", "pytest-django>=4.11.1"] +dev = ["pytest>=8.4.1", "pytest-django>=4.11.1", "pytest-xdist[psutil]>=3.8.0"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings" python_files = ["test_*.py", "*_test.py"] +addopts = ["-n", "auto", "--reuse-db", "--no-migrations"] [tool.ruff] lint.select = ["ALL"] -preview = true -unsafe-fixes = true -fix = true # Don't automatically remove unused variables lint.unfixable = ["F841"] lint.pydocstyle.convention = "google" lint.isort.required-imports = ["from __future__ import annotations"] -line-length = 140 lint.ignore = [ "CPY001", # Checks for the absence of copyright notices within Python files. @@ -52,7 +54,20 @@ lint.ignore = [ "Q003", # Checks for strings that include escaped quotes, and suggests changing the quote style to avoid the need to escape them. "W191", # Checks for indentation that uses tabs. ] +preview = true +unsafe-fixes = true +fix = true +line-length = 140 [tool.ruff.lint.per-file-ignores] -"**/tests/**" = ["ARG", "FBT", "PLR2004", "S101", "S311"] +"**/tests/**" = [ + "ARG", + "FBT", + "PLR2004", + "S101", + "S311", + "S106", + "PLR6301", + "S105", +] "**/migrations/**" = ["RUF012"] diff --git a/uv.lock b/uv.lock index 577a369..bc6f80d 100644 --- a/uv.lock +++ b/uv.lock @@ -34,6 +34,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/ae/706965237a672434c8b520e89a818e8b047af94e9beb342d0bee405c26c7/django-5.2.4-py3-none-any.whl", hash = "sha256:60c35bd96201b10c6e7a78121bd0da51084733efa303cc19ead021ab179cef5e", size = 8302187, upload-time = "2025-07-02T18:47:35.373Z" }, ] +[[package]] +name = "django-debug-toolbar" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "sqlparse" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/9f/97ba2648f66fa208fc7f19d6895586d08bc5f0ab930a1f41032e60f31a41/django_debug_toolbar-5.2.0.tar.gz", hash = "sha256:9e7f0145e1a1b7d78fcc3b53798686170a5b472d9cf085d88121ff823e900821", size = 297901, upload-time = "2025-04-29T05:23:57.533Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/c2/ed3cb815002664349e9e50799b8c00ef15941f4cad797247cadbdeebab02/django_debug_toolbar-5.2.0-py3-none-any.whl", hash = "sha256:15627f4c2836a9099d795e271e38e8cf5204ccd79d5dbcd748f8a6c284dcd195", size = 262834, upload-time = "2025-04-29T05:23:55.472Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -52,6 +74,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -61,6 +92,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -98,6 +144,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[package.optional-dependencies] +psutil = [ + { name = "psutil" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + [[package]] name = "sqlparse" version = "0.5.3" @@ -113,21 +186,31 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "django" }, + { name = "django-debug-toolbar" }, + { name = "platformdirs" }, + { name = "python-dotenv" }, ] [package.dev-dependencies] dev = [ { name = "pytest" }, { name = "pytest-django" }, + { name = "pytest-xdist", extra = ["psutil"] }, ] [package.metadata] -requires-dist = [{ name = "django", specifier = ">=5.2.4" }] +requires-dist = [ + { name = "django", specifier = ">=5.2.4" }, + { name = "django-debug-toolbar", specifier = ">=5.2.0" }, + { name = "platformdirs", specifier = ">=4.3.8" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, +] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-django", specifier = ">=4.11.1" }, + { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" }, ] [[package]]