feat(accounts): 实现用户资料与认证页面
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""用户资料与认证页面应用。"""
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserProfile)
|
||||||
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
|
"""管理员维护用户基础资料。"""
|
||||||
|
|
||||||
|
list_display = ("user", "display_name", "timezone", "updated_at")
|
||||||
|
search_fields = ("user__username", "display_name")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
"""用户资料应用配置。"""
|
||||||
|
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "accounts"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""用户资料表单。"""
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from .models import UserProfile
|
||||||
|
|
||||||
|
TIMEZONE_CHOICES = [("Asia/Shanghai", "中国标准时间(上海)")]
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileForm(forms.ModelForm):
|
||||||
|
"""只暴露第一阶段允许用户自行修改的资料字段。"""
|
||||||
|
|
||||||
|
timezone = forms.ChoiceField(label="时区", choices=TIMEZONE_CHOICES)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
fields = ("display_name", "timezone")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 6.0.8 on 2026-09-09 02:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserProfile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('display_name', models.CharField(blank=True, max_length=80, verbose_name='显示名')),
|
||||||
|
('timezone', models.CharField(default='Asia/Shanghai', max_length=64, verbose_name='时区')),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '用户资料',
|
||||||
|
'verbose_name_plural': '用户资料',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Django 内置用户的非认证资料模型。"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from common.models import TimeStampedModel
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(TimeStampedModel):
|
||||||
|
"""保存可独立演进的展示资料,不复制认证字段。"""
|
||||||
|
|
||||||
|
user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile", verbose_name="用户"
|
||||||
|
)
|
||||||
|
display_name = models.CharField("显示名", max_length=80, blank=True)
|
||||||
|
timezone = models.CharField("时区", max_length=64, default="Asia/Shanghai")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "用户资料"
|
||||||
|
verbose_name_plural = "用户资料"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.display_name or self.user.get_username()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""用户资料服务。"""
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from .models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def get_or_create_profile(user) -> UserProfile:
|
||||||
|
"""显式建立用户资料,避免依赖难以追踪的模型信号。"""
|
||||||
|
|
||||||
|
profile, _ = UserProfile.objects.get_or_create(user=user)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def deactivate_user(actor, target_user_id: int):
|
||||||
|
"""仅允许管理员停用账户;保留关联审计记录。"""
|
||||||
|
|
||||||
|
if not actor.is_staff:
|
||||||
|
raise PermissionError("只有管理员可以停用用户。")
|
||||||
|
user = get_user_model().objects.select_for_update().get(pk=target_user_id)
|
||||||
|
user.is_active = False
|
||||||
|
user.save(update_fields=("is_active",))
|
||||||
|
return user
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileTests(TestCase):
|
||||||
|
"""验证资料显式创建、认证保护和更新边界。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(username="alice", password="safe-pass-123")
|
||||||
|
|
||||||
|
def test_profile_requires_login(self):
|
||||||
|
response = self.client.get(reverse("accounts:profile"))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_profile_is_created_and_updated(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(reverse("accounts:profile"), {"display_name": "爱丽丝", "timezone": "Asia/Shanghai"})
|
||||||
|
self.assertRedirects(response, reverse("accounts:profile"))
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.profile.display_name, "爱丽丝")
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""账户页面路由。"""
|
||||||
|
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "accounts"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("login/", auth_views.LoginView.as_view(), name="login"),
|
||||||
|
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||||
|
path("profile/", views.profile, name="profile"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""认证与当前用户资料页面。"""
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
|
from .forms import UserProfileForm
|
||||||
|
from .services import get_or_create_profile
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def profile(request):
|
||||||
|
"""读取或更新当前用户自己的资料,不接受目标用户参数。"""
|
||||||
|
|
||||||
|
user_profile = get_or_create_profile(request.user)
|
||||||
|
if request.method == "POST":
|
||||||
|
form = UserProfileForm(request.POST, instance=user_profile)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, "个人资料已保存。")
|
||||||
|
return redirect("accounts:profile")
|
||||||
|
else:
|
||||||
|
form = UserProfileForm(instance=user_profile)
|
||||||
|
return render(request, "accounts/profile.html", {"form": form})
|
||||||
Reference in New Issue
Block a user