feat: add portal allauth account flow

This commit is contained in:
QiuSW
2026-07-03 11:27:45 +08:00
parent 7df2c51903
commit d61a641b7e
29 changed files with 446 additions and 31 deletions
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from django.db import transaction
from allauth.account.adapter import DefaultAccountAdapter
from apps.users.models import UserWallet
class CmhubAccountAdapter(DefaultAccountAdapter):
def save_user(self, request, user, form, commit=True):
with transaction.atomic():
saved_user = super().save_user(request, user, form, commit=commit)
if commit:
UserWallet.objects.get_or_create(
user=saved_user,
defaults={"points_balance": 0},
)
return saved_user
@@ -0,0 +1,7 @@
你好,
请打开下面的链接完成 cmhub 邮箱验证:
{{ activate_url }}
如果这不是你的操作,可以忽略本邮件。
@@ -0,0 +1 @@
[cmhub] 验证你的邮箱
+21
View File
@@ -0,0 +1,21 @@
{% extends "portal/base.html" %}
{% block title %}登录 - cmhub{% endblock %}
{% block content %}
<section class="cmhub-panel">
<h1 class="h4 mb-3">登录</h1>
<form method="post" action="{% url 'portal-login' %}">
{% csrf_token %}
{{ form.non_field_errors }}
{{ form.as_p }}
{% if redirect_field_value %}
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}">
{% endif %}
<button class="btn btn-primary w-100" type="submit">登录</button>
</form>
<div class="mt-3 text-secondary">
没有账号?<a href="{% url 'portal-signup' %}">注册</a>
</div>
</section>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "portal/base.html" %}
{% block title %}退出 - cmhub{% endblock %}
{% block content %}
<section class="cmhub-panel">
<h1 class="h4 mb-3">退出登录</h1>
<form method="post" action="{% url 'portal-logout' %}">
{% csrf_token %}
<button class="btn btn-primary w-100" type="submit">退出</button>
</form>
</section>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "portal/base.html" %}
{% block title %}注册 - cmhub{% endblock %}
{% block content %}
<section class="cmhub-panel">
<h1 class="h4 mb-3">注册</h1>
<form method="post" action="{% url 'portal-signup' %}">
{% csrf_token %}
{{ form.non_field_errors }}
{{ form.as_p }}
{% if redirect_field_value %}
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}">
{% endif %}
<button class="btn btn-primary w-100" type="submit">注册</button>
</form>
<div class="mt-3 text-secondary">
已有账号?<a href="{% url 'portal-login' %}">登录</a>
</div>
</section>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "portal/base.html" %}
{% block title %}验证邮箱 - cmhub{% endblock %}
{% block content %}
<section class="cmhub-panel">
<h1 class="h4 mb-3">验证邮箱</h1>
<p class="mb-0 text-secondary">验证邮件已发送,请先完成邮箱验证再登录。</p>
</section>
{% endblock %}
+99
View File
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}cmhub{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root {
--cmhub-bg: #f6f7f9;
--cmhub-ink: #17202a;
--cmhub-line: #d8dee6;
--cmhub-accent: #0f766e;
}
body {
background: var(--cmhub-bg);
color: var(--cmhub-ink);
}
.navbar {
border-bottom: 1px solid var(--cmhub-line);
background: #ffffff;
}
.cmhub-shell {
max-width: 960px;
margin: 0 auto;
padding: 40px 16px;
}
.cmhub-panel {
max-width: 440px;
margin: 0 auto;
border: 1px solid var(--cmhub-line);
border-radius: 8px;
background: #ffffff;
padding: 28px;
}
.cmhub-panel form p {
margin-bottom: 16px;
}
.cmhub-panel label {
display: block;
margin-bottom: 6px;
font-weight: 600;
}
.cmhub-panel input {
width: 100%;
min-height: 40px;
border: 1px solid #b9c2cf;
border-radius: 6px;
padding: 8px 10px;
}
.cmhub-panel .errorlist {
margin: 0 0 12px;
padding-left: 18px;
color: #b42318;
}
.btn-primary {
--bs-btn-bg: var(--cmhub-accent);
--bs-btn-border-color: var(--cmhub-accent);
--bs-btn-hover-bg: #0b5f59;
--bs-btn-hover-border-color: #0b5f59;
}
.metric {
border: 1px solid var(--cmhub-line);
border-radius: 8px;
background: #ffffff;
padding: 18px;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand">
<div class="container">
<a class="navbar-brand fw-semibold" href="{% url 'portal-home' %}">cmhub</a>
<div class="ms-auto d-flex gap-2">
{% if user.is_authenticated %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-dashboard' %}">控制台</a>
<form method="post" action="{% url 'portal-logout' %}">
{% csrf_token %}
<button class="btn btn-sm btn-outline-secondary" type="submit">退出</button>
</form>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-login' %}">登录</a>
<a class="btn btn-sm btn-primary" href="{% url 'portal-signup' %}">注册</a>
{% endif %}
</div>
</div>
</nav>
<main class="cmhub-shell">
{% if messages %}
<div class="mb-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} mb-2">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,20 @@
{% extends "portal/base.html" %}
{% block title %}控制台 - cmhub{% endblock %}
{% block content %}
<div class="d-flex flex-column gap-4">
<div>
<h1 class="h3 mb-1">控制台</h1>
<div class="text-secondary">{{ user.email }}</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<div class="metric">
<div class="text-secondary small">剩余点数</div>
<div class="display-6 fw-semibold">{{ balance.points_balance }}</div>
</div>
</div>
</div>
</div>
{% endblock %}
+113 -2
View File
@@ -1,3 +1,114 @@
from django.test import TestCase
import uuid
# Create your tests here.
from allauth.account.models import EmailAddress
from django.contrib.auth import get_user, get_user_model
from django.core import mail
from django.core.cache import cache
from django.test import Client, TestCase, override_settings
from apps.billing.models import PointsLedger
from apps.users.models import UserWallet
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
class PortalAccountFlowTests(TestCase):
password = "Strong-pass-123"
def setUp(self):
cache.clear()
def create_verified_user(self):
suffix = uuid.uuid4().hex[:8]
user = get_user_model().objects.create_user(
username=f"portal-user-{suffix}",
email=f"portal-user-{suffix}@example.com",
password=self.password,
)
EmailAddress.objects.create(
user=user,
email=user.email,
verified=True,
primary=True,
)
return user
def test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger(self):
suffix = uuid.uuid4().hex[:8]
email = f"signup-{suffix}@example.com"
response = self.client.post(
"/signup",
{
"username": f"signup-{suffix}",
"email": email,
"password1": self.password,
"password2": self.password,
},
)
self.assertEqual(response.status_code, 302)
user = get_user_model().objects.get(email=email)
email_address = EmailAddress.objects.get(user=user, email=email)
wallet = UserWallet.objects.get(user=user)
self.assertFalse(email_address.verified)
self.assertTrue(email_address.primary)
self.assertEqual(wallet.points_balance, 0)
self.assertFalse(PointsLedger.objects.filter(user=user).exists())
self.assertEqual(len(mail.outbox), 1)
def test_unverified_email_cannot_establish_login_session(self):
suffix = uuid.uuid4().hex[:8]
user = get_user_model().objects.create_user(
username=f"unverified-{suffix}",
email=f"unverified-{suffix}@example.com",
password=self.password,
)
EmailAddress.objects.create(
user=user,
email=user.email,
verified=False,
primary=True,
)
self.client.post("/login", {"login": user.email, "password": self.password})
self.assertFalse(get_user(self.client).is_authenticated)
def test_verified_user_can_login_view_dashboard_and_logout(self):
user = self.create_verified_user()
UserWallet.objects.create(user=user, points_balance=42)
login_response = self.client.post(
"/login",
{"login": user.email, "password": self.password},
)
self.assertEqual(login_response.status_code, 302)
self.assertEqual(login_response["Location"], "/dashboard")
self.assertTrue(get_user(self.client).is_authenticated)
dashboard_response = self.client.get("/dashboard")
self.assertEqual(dashboard_response.status_code, 200)
self.assertContains(dashboard_response, user.email)
self.assertContains(dashboard_response, "42")
logout_response = self.client.post("/logout")
self.assertEqual(logout_response.status_code, 302)
self.assertEqual(logout_response["Location"], "/login")
self.assertFalse(get_user(self.client).is_authenticated)
def test_dashboard_requires_session_login(self):
response = self.client.get("/dashboard")
self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/login?next="))
def test_login_post_is_csrf_protected(self):
csrf_client = Client(enforce_csrf_checks=True)
response = csrf_client.post(
"/login",
{"login": "nobody@example.com", "password": "password"},
)
self.assertEqual(response.status_code, 403)
+13
View File
@@ -0,0 +1,13 @@
from allauth.account.views import LoginView, LogoutView, SignupView
from django.urls import path
from django.views.generic import RedirectView
from .views import DashboardView
urlpatterns = [
path("", RedirectView.as_view(pattern_name="portal-dashboard", permanent=False), name="portal-home"),
path("signup", SignupView.as_view(), name="portal-signup"),
path("login", LoginView.as_view(), name="portal-login"),
path("logout", LogoutView.as_view(), name="portal-logout"),
path("dashboard", DashboardView.as_view(), name="portal-dashboard"),
]
+12 -2
View File
@@ -1,3 +1,13 @@
from django.shortcuts import render
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
# Create your views here.
from apps.billing.services import get_balance_snapshot
class DashboardView(LoginRequiredMixin, TemplateView):
template_name = "portal/dashboard.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["balance"] = get_balance_snapshot(self.request.user)
return context