diff --git a/.vscode/settings.json b/.vscode/settings.json index a344182..6316a5e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,7 @@ "Hellsén", "isort", "Joakim", + "kwargs", "lovinator", "Mailgun", "makemigrations", diff --git a/core/__init__.py b/accounts/__init__.py similarity index 100% rename from core/__init__.py rename to accounts/__init__.py diff --git a/core/admin.py b/accounts/admin.py similarity index 66% rename from core/admin.py rename to accounts/admin.py index f72d1f8..5b8b94b 100644 --- a/core/admin.py +++ b/accounts/admin.py @@ -3,7 +3,6 @@ from __future__ import annotations from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from core.models import User +from accounts.models import User -# Register your custom User model with the admin admin.site.register(User, UserAdmin) diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..58ab970 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + """Configuration for the accounts app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + verbose_name = "Accounts" diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..7cc112e --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from django.contrib.auth.forms import UserCreationForm +from django.core.exceptions import ValidationError + +from accounts.models import User + + +class CustomUserCreationForm(UserCreationForm): + """Custom user creation form for the custom User model.""" + + class Meta: + model = User + fields = ("username",) + + def __init__(self, *args, **kwargs) -> None: + """Initialize form with Bootstrap classes.""" + super().__init__(*args, **kwargs) + # Add Bootstrap classes to form fields + for field in self.fields.values(): + field.widget.attrs.update({"class": "form-control"}) + + def clean_username(self) -> str: + """Validate the username using the correct User model. + + Returns: + str: The cleaned username. + + Raises: + ValidationError: If the username already exists. + """ + username = self.cleaned_data.get("username") + if username and User.objects.filter(username=username).exists(): + msg = "A user with that username already exists." + raise ValidationError(msg) + return username or "" diff --git a/core/migrations/0001_initial.py b/accounts/migrations/0001_initial.py similarity index 98% rename from core/migrations/0001_initial.py rename to accounts/migrations/0001_initial.py index 9713b97..8009a93 100644 --- a/core/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.4 on 2025-07-08 15:01 +# Generated by Django 5.2.4 on 2025-07-21 00:54 import django.contrib.auth.models import django.contrib.auth.validators diff --git a/core/migrations/__init__.py b/accounts/migrations/__init__.py similarity index 100% rename from core/migrations/__init__.py rename to accounts/migrations/__init__.py diff --git a/core/models.py b/accounts/models.py similarity index 100% rename from core/models.py rename to accounts/models.py diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..26c8c43 --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +User = get_user_model() + + +class AuthenticationTestCase(TestCase): + """Test authentication functionality.""" + + def setUp(self) -> None: + """Set up test data.""" + self.username = "testuser" + self.password = "testpass123" + self.user = User.objects.create_user(username=self.username, password=self.password) + + def test_login_view_get(self) -> None: + """Test login view GET request.""" + response = self.client.get(reverse("accounts:login")) + assert response.status_code == 200 + self.assertContains(response, "Login") + + def test_signup_view_get(self) -> None: + """Test signup view GET request.""" + response = self.client.get(reverse("accounts:signup")) + assert response.status_code == 200 + self.assertContains(response, "Sign Up") + + def test_login_valid_user(self) -> None: + """Test login with valid credentials.""" + response = self.client.post(reverse("accounts:login"), {"username": self.username, "password": self.password}) + assert response.status_code == 302 # Redirect after login + + def test_login_invalid_user(self) -> None: + """Test login with invalid credentials.""" + response = self.client.post(reverse("accounts:login"), {"username": self.username, "password": "wrongpassword"}) + assert response.status_code == 200 # Stay on login page + self.assertContains(response, "Please enter a correct username and password") + + def test_profile_view_authenticated(self) -> None: + """Test profile view for authenticated user.""" + self.client.login(username=self.username, password=self.password) + response = self.client.get(reverse("accounts:profile")) + assert response.status_code == 200 + self.assertContains(response, self.username) + + def test_profile_view_unauthenticated(self) -> None: + """Test profile view redirects unauthenticated user.""" + response = self.client.get(reverse("accounts:profile")) + assert response.status_code == 302 # Redirect to login + + def test_user_signup(self) -> None: + """Test user signup functionality.""" + response = self.client.post( + reverse("accounts:signup"), {"username": "newuser", "password1": "complexpass123", "password2": "complexpass123"} + ) + assert response.status_code == 302 # Redirect after signup + assert User.objects.filter(username="newuser").exists() + + def test_logout(self) -> None: + """Test user logout functionality.""" + self.client.login(username=self.username, password=self.password) + response = self.client.get(reverse("accounts:logout")) + assert response.status_code == 302 # Redirect after logout diff --git a/core/tests/__init__.py b/accounts/tests/__init__.py similarity index 100% rename from core/tests/__init__.py rename to accounts/tests/__init__.py diff --git a/core/tests/test_admin.py b/accounts/tests/test_admin.py similarity index 100% rename from core/tests/test_admin.py rename to accounts/tests/test_admin.py diff --git a/core/tests/test_models.py b/accounts/tests/test_models.py similarity index 100% rename from core/tests/test_models.py rename to accounts/tests/test_models.py diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..bb2001b --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from django.urls import path + +from accounts import views + +app_name = "accounts" + +urlpatterns = [ + path("login/", views.CustomLoginView.as_view(), name="login"), + path("logout/", views.CustomLogoutView.as_view(), name="logout"), + path("signup/", views.SignUpView.as_view(), name="signup"), + path("profile/", views.profile_view, name="profile"), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..5be3c81 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.contrib.auth import login +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import LoginView, LogoutView +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic import CreateView + +from accounts.forms import CustomUserCreationForm +from accounts.models import User + +if TYPE_CHECKING: + from django.forms import BaseModelForm + from django.http import HttpRequest, HttpResponse + + +class CustomLoginView(LoginView): + """Custom login view with better styling.""" + + template_name = "accounts/login.html" + redirect_authenticated_user = True + + def get_success_url(self) -> str: + """Redirect to the dashboard after successful login. + + Returns: + str: URL to redirect to after successful login. + """ + return reverse_lazy("twitch:dashboard") + + +class CustomLogoutView(LogoutView): + """Custom logout view.""" + + next_page = reverse_lazy("twitch:dashboard") + + +class SignUpView(CreateView): + """User registration view.""" + + model = User + form_class = CustomUserCreationForm + template_name = "accounts/signup.html" + success_url = reverse_lazy("twitch:dashboard") + + def form_valid(self, form: BaseModelForm) -> HttpResponse: + """Login the user after successful registration. + + Args: + form: The validated user creation form. + + Returns: + HttpResponse: Response after successful form processing. + """ + response = super().form_valid(form) + login(self.request, self.object) # type: ignore[attr-defined] + return response + + +@login_required +def profile_view(request: HttpRequest) -> HttpResponse: + """User profile view. + + Args: + request: The HTTP request object. + + Returns: + HttpResponse: Rendered profile template. + """ + return render(request, "accounts/profile.html", {"user": request.user}) diff --git a/config/settings.py b/config/settings.py index 892ce36..3bf42b9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -41,7 +41,7 @@ def get_data_dir() -> Path: DATA_DIR: Path = get_data_dir() ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")] -AUTH_USER_MODEL = "core.User" +AUTH_USER_MODEL = "accounts.User" BASE_DIR: Path = Path(__file__).resolve().parent.parent DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" ROOT_URLCONF = "config.urls" @@ -63,13 +63,17 @@ EMAIL_USE_SSL: bool = os.getenv(key="EMAIL_USE_SSL", default="False").lower() == SERVER_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None) LOGIN_REDIRECT_URL = "/" +LOGIN_URL = "/accounts/login/" LOGOUT_REDIRECT_URL = "/" +ACCOUNT_EMAIL_VERIFICATION = "none" +ACCOUNT_AUTHENTICATION_METHOD = "username" +ACCOUNT_EMAIL_REQUIRED = False + MEDIA_ROOT: Path = DATA_DIR / "media" MEDIA_ROOT.mkdir(exist_ok=True) MEDIA_URL = "/media/" - STATIC_ROOT: Path = BASE_DIR / "staticfiles" STATIC_ROOT.mkdir(exist_ok=True) STATIC_URL = "static/" @@ -114,7 +118,7 @@ INSTALLED_APPS: list[str] = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "core.apps.CoreConfig", + "accounts.apps.AccountsConfig", "twitch.apps.TwitchConfig", ] diff --git a/config/urls.py b/config/urls.py index 224eb2a..d3adb5a 100644 --- a/config/urls.py +++ b/config/urls.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: urlpatterns: list[URLResolver] = [ path(route="admin/", view=admin.site.urls), + path(route="accounts/", view=include("accounts.urls", namespace="accounts")), path(route="", view=include("twitch.urls", namespace="twitch")), ] diff --git a/core/apps.py b/core/apps.py deleted file mode 100644 index df59afa..0000000 --- a/core/apps.py +++ /dev/null @@ -1,17 +0,0 @@ -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/pyproject.toml b/pyproject.toml index 8b2f07a..f208b20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ lint.ignore = [ "ERA001", # Checks for commented-out Python code. "FIX002", # Checks for "TODO" comments. "PLR6301", # Checks for the presence of unused self parameter in methods definitions. + "ANN002", # Checks that function *args arguments have type annotations. + "ANN003", # Checks that function **kwargs arguments have type annotations. # Conflicting lint rules when using Ruff's formatter # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules diff --git a/templates/accounts/login.html b/templates/accounts/login.html new file mode 100644 index 0000000..2d6db3d --- /dev/null +++ b/templates/accounts/login.html @@ -0,0 +1,73 @@ +{% extends 'base.html' %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+
+
+
+
+

Login

+
+
+ {% if form.errors %} +
+
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+
+
+ +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html new file mode 100644 index 0000000..20608f4 --- /dev/null +++ b/templates/accounts/profile.html @@ -0,0 +1,141 @@ +{% extends 'base.html' %} + +{% block title %}Profile{% endblock %} + +{% block content %} +
+
+
+
+
+

User Profile

+
+
+
+
+
+ +
+
{{ user.username }}
+

Member since {{ user.date_joined|date:"F Y" }}

+
+
+
Account Information
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Username:{{ user.username }}
Email:{{ user.email|default:"Not provided" }}
First Name:{{ user.first_name|default:"Not provided" }}
Last Name:{{ user.last_name|default:"Not provided" }}
Date Joined:{{ user.date_joined|date:"F d, Y" }}
Last Login:{{ user.last_login|date:"F d, Y H:i"|default:"Never" }}
Account Status: + {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} + {% if user.is_staff %} + Staff + {% endif %} + {% if user.is_superuser %} + Superuser + {% endif %} +
+
+
+
+ +
+ + +
+
+
Quick Stats
+
+
+
+
+
+

-

+

Campaigns Tracked

+
+
+
+
+

-

+

Drops Collected

+
+
+
+
+

-

+

Games Followed

+
+
+
+

+ Detailed statistics coming soon! +

+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/signup.html b/templates/accounts/signup.html new file mode 100644 index 0000000..49c9153 --- /dev/null +++ b/templates/accounts/signup.html @@ -0,0 +1,96 @@ +{% extends 'base.html' %} + +{% block title %}Sign Up{% endblock %} + +{% block content %} +
+
+
+
+
+

Sign Up

+
+
+ {% if form.errors %} +
+
    + {% for field, errors in form.errors.items %} + {% for error in errors %} +
  • {{ error }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} + +
+ {% csrf_token %} +
+ +
+ + +
+ {% if form.username.help_text %} +
{{ form.username.help_text }}
+ {% endif %} +
+ +
+ +
+ + +
+ {% if form.password1.help_text %} +
{{ form.password1.help_text }}
+ {% endif %} +
+ +
+ +
+ + +
+ {% if form.password2.help_text %} +
{{ form.password2.help_text }}
+ {% endif %} +
+ +
+ +
+
+
+ +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 59e1d91..e50081b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,7 +4,7 @@ - {% block title %}Twitch Drops Tracker{% endblock %} + {% block title %}ttvdrops{% endblock %} @@ -81,11 +81,43 @@ Games + {% if user.is_authenticated %} + {% if user.is_staff %} + {% endif %} + + {% else %} + + + {% endif %}