Skip to content

Commit 5282eb5

Browse files
authored
Feat/enable-register-2 (#4)
* remove bulma, add more comments. * fix urls. error edit. * ref: refactor, move transform out. * add normal logger settings. * replace print with logger. * ref, remove useless code. * ref, migrate tests to root path. * add vscode settings. * remove ignore vscode. * fix register html template. * add mmigrate command for test.. * add management package. * feat, add register forms. * add migration. * add verify email and register details save view. * avoid inside method call by outside. Improve readability. * refactor using login.py. * add register/verify and details to testapp. * add command register record. * fix: register record create. * feat, add admin * fix login error. * remove edit details. * remove details validate. * remove register-details url. * fix: model verbose name. * pass the old test. * add system-name test. * add test email info. * merge login and register request email. * update verify mixin and add new test for user exists. * add more test for token verify. * fix error of update. * enable verify. * update version 0.5.0
1 parent 61d7103 commit 5282eb5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+972
-521
lines changed

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
.venv
2-
.vscode
32
*.pyc
43
__pycache__
54

65

76
db.sqlite3
87
.env
9-
dist
8+
dist
9+
10+
*.log

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"python.testing.pytestArgs": [
3+
"."
4+
],
5+
"python.testing.unittestEnabled": false,
6+
"python.testing.pytestEnabled": true
7+
}

README.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ List of the urls for exmaple project:
2222
- [x] Time-limited of login link.
2323
- [x] limited of sending email. Using TimeLimt to set minutes.
2424
- [x] The link could be used for Login once.
25+
- [x] Register new user.
26+
- [x] Support multiple user.
2527
- [ ] Ban the IP to send mail frequently without login.
26-
- [ ] Multiple user.
28+
- [ ] Enable 2FA.
2729
- [ ] More easier and customizable login link.
2830

2931
## Usage
@@ -42,25 +44,30 @@ INSTALLED_APP = [
4244

4345
```python
4446
from django.shortcuts import render
45-
from django_login_email import views as v
47+
from django.urls import reverse
48+
4649
from django_login_email import email as e
50+
from django_login_email import views as v
4751

4852
# Create your views here.
4953

50-
51-
class MyInfo(e.EmailLoginInfo):
52-
def set_variables(self):
53-
self.subject = "Login request from meterhub"
54-
self.welcome_text = "Welcome to meterhub! Please click the link below to login.<br>"
55-
self.from_email = "sandbox.smtp.mailtrap.io"
54+
loginInfo, registerInfo = e.get_info_class("meterhub")
5655

5756

5857
class LoginView(v.EmailLoginView):
59-
email_info_class = MyInfo
58+
login_info_class = loginInfo
59+
register_info_class = registerInfo
6060

6161

6262
class VerifyView(v.EmailVerifyView):
63-
pass
63+
def get_success_url(self):
64+
return reverse("home")
65+
66+
67+
class LogoutView(v.EmailLogoutView):
68+
pass
69+
70+
6471

6572
```
6673

django_login_email/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.5.0"

django_login_email/admin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
from django.contrib import admin
22

3+
from django_login_email import models
4+
35
# Register your models here.
6+
7+
8+
@admin.register(models.EmailRecord)
9+
class EmailRecordAdmin(admin.ModelAdmin):
10+
list_display = ("email", "expired_time", "validated", "mail_type")

django_login_email/email.py

Lines changed: 172 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,201 @@
1-
from dataclasses import dataclass
2-
import typing as t
31
import abc
4-
import string
52
import datetime
3+
import string
4+
import typing as t
5+
from dataclasses import dataclass
66
from datetime import timezone
77

8+
from django.contrib.auth import get_user_model, login, logout
89
from django.core.mail import EmailMessage
9-
from django.contrib.auth import get_user_model
10-
11-
from django.contrib.auth import login, logout
12-
from . import token
13-
14-
15-
class EmailLoginInfo(object):
16-
subject: str
17-
message: str
18-
welcome_text = "Welcome to meterhub! Please click the link below to login.<br>"
19-
url: str = "http://127.0.0.1:8000/account/verify?token="
20-
login_message: string.Template = string.Template('Click <a href="$url$token">Link</a>')
21-
from_email: str
22-
23-
def __init__(self) -> None:
24-
self.init_variables()
25-
26-
def init_variables(self):
27-
raise NotImplementedError("You must set the subject and from_email.")
28-
29-
def set_token(self, value):
30-
self.message = self.welcome_text + self.login_message.substitute(url=self.url, token=value)
3110

32-
33-
class TimeLimit(object):
34-
minutes: int = 10
35-
36-
37-
@dataclass
38-
class MailRecord(object):
39-
expired_time: t.Optional[datetime.datetime]
40-
email: str
41-
validated: bool
42-
sault: str
11+
from . import errors, token
4312

4413

45-
class MailRecordMixin(abc.ABC):
14+
class EmailInfo(object):
15+
"""Email info."""
4616

47-
@abc.abstractmethod
48-
def get_mail_record(self, mail: str) -> MailRecord:
49-
# for easy to change. use a function.
50-
raise NotImplementedError("You must implement get_mail_record")
17+
subject: str
18+
message: str
19+
from_email: str
20+
welcome_text: str
21+
system_name: str
5122

52-
@abc.abstractmethod
53-
def save_token(self, token: token.TokenDict):
54-
"""to save token in the database or somewhere."""
55-
raise NotImplementedError("You must implement save_token")
23+
url: str = "http://127.0.0.1:8000/account/verify?token="
24+
login_message: string.Template = string.Template('Click <a href="$url$token">Link</a>')
5625

57-
@abc.abstractmethod
58-
def disable_token(self, token: token.TokenDict):
59-
"""Every token should only login once"""
60-
raise NotImplementedError("")
26+
def set_token(self, value):
27+
self.message = self.welcome_text + self.login_message.substitute(
28+
url=self.url, token=value
29+
)
6130

6231

63-
class EmailInfoMixin(MailRecordMixin):
64-
email_info_class: t.Type[EmailLoginInfo]
32+
class EmailLoginInfo(EmailInfo):
33+
"""Email info for login."""
6534

66-
tl: TimeLimit
35+
def __init__(self):
36+
self.subject = f"Welcome to {self.system_name}! Please click the link below to login."
37+
self.from_email = "noreply@example.com"
38+
self.welcome_text: str = (
39+
f"Welcome to {self.system_name}! Please click the link below to login.<br>"
40+
)
6741

68-
def check_user(self, email):
69-
User = get_user_model()
70-
u = User.objects.get(email=email)
71-
return u
7242

73-
def check_could_send(self, email):
74-
re = self.get_mail_record(email)
75-
# TODO: if other user send email, the current user could not sign in.
76-
if (re.expired_time is None) or (re.expired_time <= datetime.datetime.now(tz=timezone.utc)):
77-
return True
78-
return False
43+
class EmailRegisterInfo(EmailInfo):
44+
"""Email info for register."""
7945

80-
def send_valid(self, email: str):
81-
e = self.email_info_class()
82-
m = token.TokenManager(self.tl.minutes)
46+
def __init__(self):
47+
self.subject = (
48+
f"Welcome to {self.system_name}! Please click the link below to register."
49+
)
50+
self.from_email = "noreply@example.com"
51+
self.welcome_text: str = (
52+
f"Welcome to {self.system_name}! Please click the link below to register.<br>"
53+
)
8354

84-
encrypt_token = m.encrypt_mail(email, self.save_token)
85-
e.set_token(encrypt_token)
8655

87-
msg = EmailMessage(e.subject, e.message, e.from_email, [email])
88-
msg.content_subtype = "html"
89-
msg.send()
56+
def get_info_class(sys_name: str) -> t.Tuple[EmailLoginInfo, EmailRegisterInfo]:
57+
class MyLoginInfo(EmailLoginInfo):
58+
system_name = sys_name
9059

91-
def send_login_mail(self, email: str):
92-
self.check_user(email)
93-
if not self.check_could_send(email=email):
94-
raise Exception(f"Cannot send. Wait {self.tl.minutes} minutes.")
60+
class MyRegisterInfo(EmailRegisterInfo):
61+
system_name = sys_name
9562

96-
self.send_valid(email)
63+
return MyLoginInfo, MyRegisterInfo
9764

9865

99-
class EmailValidateMixin(MailRecordMixin):
100-
tl: TimeLimit
101-
102-
def verify_login_mail(self, request, token_v: str):
103-
m = token.TokenManager(self.tl.minutes)
104-
token_str = m.decrypt_token(token=token_v)
105-
token_d = m.transform_token(token_str)
106-
107-
mr = self.get_mail_record(m.get_mail(token_d))
108-
if mr.validated:
109-
raise Exception("Already validated.")
66+
@dataclass
67+
class TimeLimit(object):
68+
minutes: int = 10
11069

111-
token_d = m.check_token(token_d, lambda: mr.sault)
112-
if token_d is None:
113-
raise Exception("Invalid token.")
11470

115-
User = get_user_model()
116-
u = User.objects.get(email=m.get_mail(token_d))
117-
if not u.is_active:
118-
raise Exception("Inactive user, disallow login.")
71+
@dataclass
72+
class MailRecord(object):
73+
expired_time: t.Optional[datetime.datetime]
74+
email: str
75+
validated: bool
76+
sault: str
77+
78+
79+
class MailRecordAPI(abc.ABC):
80+
"""Mixin to get mail record."""
81+
82+
@abc.abstractmethod
83+
def get_mail_record(self, mail: str) -> MailRecord:
84+
"""Get the mail record from the database."""
85+
raise NotImplementedError("You must implement get_mail_record")
86+
87+
@abc.abstractmethod
88+
def save_token(self, token: token.TokenDict):
89+
"""to save token in the database or somewhere."""
90+
raise NotImplementedError("You must implement save_token")
91+
92+
@abc.abstractmethod
93+
def disable_token(self, token: token.TokenDict):
94+
"""Every token should only login once"""
95+
raise NotImplementedError("")
96+
97+
98+
class EmailFunc(MailRecordAPI):
99+
"""Mixin to send email."""
100+
101+
login_info_class: t.Type[EmailLoginInfo]
102+
register_info_class: t.Type[EmailRegisterInfo]
103+
104+
tl: TimeLimit
105+
106+
def check_user(self, email) -> bool:
107+
"""check if the user exists."""
108+
User = get_user_model()
109+
u = User.objects.filter(email=email)
110+
return u.exists()
111+
112+
def check_could_send(self, email) -> bool:
113+
"""check if the email could send."""
114+
re = self.get_mail_record(email)
115+
# TODO: if other user send email, the current user could not sign in.
116+
if (re.expired_time is None) or (
117+
re.expired_time <= datetime.datetime.now(tz=timezone.utc)
118+
):
119+
return True
120+
return False
121+
122+
def get_token_manager(self) -> token.TokenManager:
123+
return token.TokenManager(self.tl.minutes)
124+
125+
def send_valid(self, email: str, mail_type: str):
126+
"""send login/register mail."""
127+
if mail_type == "login":
128+
e = self.login_info_class()
129+
elif mail_type == "register":
130+
e = self.register_info_class()
131+
else:
132+
raise ValueError(f"Invalid mail type: {mail_type}")
133+
134+
m = self.get_token_manager()
135+
encrypt_token = m.encrypt_mail(email, mail_type, self.save_token)
136+
e.set_token(encrypt_token)
137+
138+
msg = EmailMessage(e.subject, e.message, e.from_email, [email])
139+
msg.content_subtype = "html"
140+
msg.send()
141+
142+
def send_login_mail(self, email: str):
143+
"""
144+
send login mail.
145+
"""
146+
# if user not exist, send register mail.
147+
if not self.check_user(email):
148+
mail_type = "register"
149+
else:
150+
mail_type = "login"
151+
152+
# if the email could not send, raise exception.
153+
if not self.check_could_send(email=email):
154+
raise Exception(f"Cannot send. Wait {self.tl.minutes} minutes.")
155+
156+
self.send_valid(email, mail_type)
157+
158+
159+
class EmailVerifyMixin(MailRecordAPI):
160+
"""verify the token in url"""
161+
162+
tl: TimeLimit
163+
164+
def verify_token(self, token_v: str):
165+
m = token.TokenManager(self.tl.minutes)
166+
token_str = m.decrypt_token(token=token_v)
167+
token_d = m.transform_token(token_str)
168+
169+
mr = self.get_mail_record(m.get_mail(token_d))
170+
if mr.validated:
171+
raise errors.ValidatedError("Token already validated.")
172+
173+
token_d = m.check_token(token_d, lambda: mr.sault)
174+
if token_d is None:
175+
raise errors.TokenError("Invalid token.")
119176

120-
self.disable_token(token=token_d)
121-
login(request, u)
177+
User = get_user_model()
178+
u = User.objects.filter(email=m.get_mail(token_d)).first()
179+
if not u:
180+
# if user not exist, create a new user.
181+
# support register by email.
182+
u = User.objects.create(username=m.get_mail(token_d), email=m.get_mail(token_d))
183+
184+
if not u.is_active:
185+
raise Exception("Inactive user, disallow login.")
186+
187+
self.disable_token(token=token_d)
188+
return u
189+
190+
def verify_login_mail(self, request, token_v: str):
191+
"""
192+
verify the login mail.
193+
if user not exist, create a new user.
194+
"""
195+
u = self.verify_token(token_v=token_v)
196+
login(request, u)
122197

123198

124199
class EmailLogoutMixin(object):
125-
def logout(self, request):
126-
logout(request)
200+
def logout(self, request):
201+
logout(request)

0 commit comments

Comments
 (0)