feat: add portal allauth account flow
This commit is contained in:
@@ -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] 验证你的邮箱
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user