From 163d20f710cde686ee765d40b36eeb17b0955a0b Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 9 Sep 2026 11:57:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(accounts):=20=E5=AE=9E=E7=8E=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=B5=84=E6=96=99=E4=B8=8E=E8=AE=A4=E8=AF=81=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accounts/__init__.py | 1 + accounts/admin.py | 11 ++++++++++ accounts/apps.py | 8 ++++++++ accounts/forms.py | 17 +++++++++++++++ accounts/migrations/0001_initial.py | 32 +++++++++++++++++++++++++++++ accounts/migrations/__init__.py | 0 accounts/models.py | 23 +++++++++++++++++++++ accounts/services.py | 26 +++++++++++++++++++++++ accounts/tests.py | 21 +++++++++++++++++++ accounts/urls.py | 14 +++++++++++++ accounts/views.py | 24 ++++++++++++++++++++++ 11 files changed, 177 insertions(+) create mode 100644 accounts/__init__.py create mode 100644 accounts/admin.py create mode 100644 accounts/apps.py create mode 100644 accounts/forms.py create mode 100644 accounts/migrations/0001_initial.py create mode 100644 accounts/migrations/__init__.py create mode 100644 accounts/models.py create mode 100644 accounts/services.py create mode 100644 accounts/tests.py create mode 100644 accounts/urls.py create mode 100644 accounts/views.py diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..639df23 --- /dev/null +++ b/accounts/__init__.py @@ -0,0 +1 @@ +"""用户资料与认证页面应用。""" diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..601350f --- /dev/null +++ b/accounts/admin.py @@ -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") diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..59ad0b2 --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + """用户资料应用配置。""" + + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..f2090df --- /dev/null +++ b/accounts/forms.py @@ -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") diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..409e279 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -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': '用户资料', + }, + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..38fb75e --- /dev/null +++ b/accounts/models.py @@ -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() diff --git a/accounts/services.py b/accounts/services.py new file mode 100644 index 0000000..673d5d0 --- /dev/null +++ b/accounts/services.py @@ -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 diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..5f3e7c8 --- /dev/null +++ b/accounts/tests.py @@ -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, "爱丽丝") diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..8b90e44 --- /dev/null +++ b/accounts/urls.py @@ -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"), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..6cde062 --- /dev/null +++ b/accounts/views.py @@ -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})