Add environment configuration and email settings

- Created a new .env.example file for environment variable configuration including Django settings, email configurations, and common SMTP provider examples.
- Updated .vscode/settings.json to include additional words for spell checking.
- Enhanced config/settings.py to load environment variables using python-dotenv, added data directory management, and configured email settings.
- Updated config/urls.py to include debug toolbar URLs conditionally based on testing.
- Added pytest configuration in conftest.py for Django testing.
- Created core application with custom User model, admin registration, and migrations.
- Implemented tests for User model and admin functionalities.
- Updated pyproject.toml to include new dependencies for debugging and environment management.
- Updated uv.lock to reflect new package versions and dependencies.
This commit is contained in:
Joakim Hellsén 2025-07-08 04:33:05 +02:00
commit 96a159f691
16 changed files with 697 additions and 57 deletions

68
.env.example Normal file
View file

@ -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

16
.vscode/settings.json vendored
View file

@ -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"
]
}

View file

@ -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,
]

View file

@ -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()]

24
conftest.py Normal file
View file

@ -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)

0
core/__init__.py Normal file
View file

9
core/admin.py Normal file
View file

@ -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)

17
core/apps.py Normal file
View file

@ -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"

View file

@ -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()),
],
),
]

View file

21
core/models.py Normal file
View file

@ -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"

0
core/tests/__init__.py Normal file
View file

86
core/tests/test_admin.py Normal file
View file

@ -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

178
core/tests/test_models.py Normal file
View file

@ -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()

View file

@ -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"]

85
uv.lock generated
View file

@ -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]]