Skip to content

Commit 3913a5e

Browse files
committed
feat: Add registration page
- Added confirmation email system - Added some tests
1 parent 2cf26ed commit 3913a5e

File tree

13 files changed

+197
-13
lines changed

13 files changed

+197
-13
lines changed

lms/lmsdb/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
class RoleOptions(enum.Enum):
3636
BANNED = 'Banned'
37+
NOT_CONFIRMED = 'Not_Confirmed'
3738
STUDENT = 'Student'
3839
STAFF = 'Staff'
3940
VIEWER = 'Viewer'
@@ -67,6 +68,7 @@ class Role(BaseModel):
6768
(RoleOptions.STAFF.value, RoleOptions.STAFF.value),
6869
(RoleOptions.VIEWER.value, RoleOptions.VIEWER.value),
6970
(RoleOptions.STUDENT.value, RoleOptions.STUDENT.value),
71+
(RoleOptions.NOT_CONFIRMED.value, RoleOptions.NOT_CONFIRMED.value),
7072
(RoleOptions.BANNED.value, RoleOptions.BANNED.value),
7173
))
7274

@@ -77,6 +79,10 @@ def __str__(self):
7779
def get_banned_role(cls) -> 'Role':
7880
return cls.get(Role.name == RoleOptions.BANNED.value)
7981

82+
@classmethod
83+
def get_not_confirmed_role(cls) -> 'Role':
84+
return cls.get(Role.name == RoleOptions.NOT_CONFIRMED.value)
85+
8086
@classmethod
8187
def get_student_role(cls) -> 'Role':
8288
return cls.get(Role.name == RoleOptions.STUDENT.value)
@@ -100,6 +106,10 @@ def by_name(cls, name) -> 'Role':
100106
def is_banned(self) -> bool:
101107
return self.name == RoleOptions.BANNED.value
102108

109+
@property
110+
def is_not_confirmed(self) -> bool:
111+
return self.name == RoleOptions.NOT_CONFIRMED.value
112+
103113
@property
104114
def is_student(self) -> bool:
105115
return self.name == RoleOptions.STUDENT.value

lms/lmsweb/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from flask_babel import Babel # type: ignore
66
from flask_limiter import Limiter # type: ignore
77
from flask_limiter.util import get_remote_address # type: ignore
8+
from flask_mail import Mail # type: ignore
89
from flask_wtf.csrf import CSRFProtect # type: ignore
910

1011
from lms.utils import config_migrator, debug
@@ -41,6 +42,8 @@
4142
# Localizing configurations
4243
babel = Babel(webapp)
4344

45+
webmail = Mail(webapp)
46+
4447

4548
# Must import files after app's creation
4649
from lms.lmsdb import models # NOQA: F401, E402, I202

lms/lmsweb/config.py.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ MAILGUN_API_KEY = os.getenv('MAILGUN_API_KEY')
99
MAILGUN_DOMAIN = os.getenv('MAILGUN_DOMAIN', 'mail.pythonic.guru')
1010
SERVER_ADDRESS = os.getenv('SERVER_ADDRESS', '127.0.0.1:5000')
1111

12+
# MAIL CONFIGURATION
13+
MAIL_SERVER = 'smtp.gmail.com'
14+
MAIL_PORT = 465
15+
MAIL_USE_SSL = True
16+
MAIL_USE_TLS = False
17+
MAIL_USERNAME = 'username@gmail.com'
18+
MAIL_PASSWORD = 'password'
19+
1220
# SESSION_COOKIE_SECURE = True
1321
SESSION_COOKIE_HTTPONLY = True
1422
SESSION_COOKIE_SAMESITE = 'Lax'

lms/lmsweb/tools/registration.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@
55
import os
66
import typing
77

8+
from flask import url_for
89
from flask_babel import gettext as _ # type: ignore
10+
from flask_mail import Message # type: ignore
911
from flask_wtf import FlaskForm
12+
from itsdangerous import URLSafeTimedSerializer
1013
from wtforms import PasswordField, StringField
1114
from wtforms.validators import Email, EqualTo, InputRequired, Length
1215

1316
from lms.lmsdb import models
14-
from lms.lmsweb import config
17+
from lms.lmsweb import config, webmail
1518
from lms.utils.log import log
1619

1720
import requests
1821

1922

23+
SERIALIZER = URLSafeTimedSerializer(config.SECRET_KEY)
24+
25+
2026
class RegisterForm(FlaskForm):
2127
email = StringField(
2228
'Email', validators=[
@@ -38,7 +44,7 @@ class RegisterForm(FlaskForm):
3844
confirm = PasswordField(
3945
'Password Confirmation', validators=[
4046
InputRequired(),
41-
EqualTo('password', message=_('הסיסמה שהוקלדה אינה זהה')),
47+
EqualTo('password', message=_('הסיסמאות שהוקלדו אינן זהות')),
4248
],
4349
)
4450

@@ -161,6 +167,21 @@ def _build_user_text(user: UserToCreate) -> str:
161167
return msg
162168

163169

170+
def generate_confirmation_token(email: str) -> str:
171+
return SERIALIZER.dumps(email, salt='email-confirmation')
172+
173+
174+
def send_confirmation_mail(email: str, fullname: str) -> None:
175+
token = generate_confirmation_token(email)
176+
msg = Message(
177+
'Confirmation Email - Learn Python',
178+
sender=f'lms@{config.MAILGUN_DOMAIN}', recipients=[email],
179+
)
180+
link = url_for('confirm_email', token=token, _external=True)
181+
msg.body = f'Hey {fullname},\nYour confirmation link is: {link}'
182+
webmail.send(msg)
183+
184+
164185
if __name__ == '__main__':
165186
registration = UserRegistrationCreator.from_csv_file(config.USERS_CSV)
166187
print(registration.users_to_create) # noqa: T001

lms/lmsweb/views.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
jsonify, make_response, render_template,
66
request, send_from_directory, url_for,
77
)
8+
from flask_babel import gettext as _ # type: ignore
89
from flask_limiter.util import get_remote_address # type: ignore
910
from flask_login import ( # type: ignore
1011
current_user, login_required, login_user, logout_user,
1112
)
13+
from itsdangerous import BadSignature, BadTimeSignature, SignatureExpired
1214
from werkzeug.datastructures import FileStorage
1315
from werkzeug.utils import redirect
1416

@@ -27,7 +29,9 @@
2729
from lms.lmsweb.redirections import (
2830
PERMISSIVE_CORS, get_next_url, login_manager,
2931
)
30-
from lms.lmsweb.tools.registration import RegisterForm
32+
from lms.lmsweb.tools.registration import (
33+
RegisterForm, SERIALIZER, send_confirmation_mail,
34+
)
3135
from lms.models import (
3236
comments, notes, notifications, share_link, solutions, upload,
3337
)
@@ -96,11 +100,19 @@ def login(login_message: Optional[str] = None):
96100
user = User.get_or_none(username=username)
97101

98102
if request.method == 'POST':
99-
if user is not None and user.is_password_valid(password):
103+
if (
104+
user is not None and user.is_password_valid(password)
105+
and not user.role.is_not_confirmed
106+
):
100107
login_user(user)
101108
return get_next_url(next_page)
102-
elif user is None or not user.is_password_valid(password):
103-
login_message = 'Invalid username or password'
109+
elif (
110+
user is None or not user.is_password_valid(password)
111+
or user.role.is_not_confirmed
112+
):
113+
login_message = _('שם המשתמש או הסיסמה שהוזנו לא תקינים')
114+
if user is not None and user.role.is_not_confirmed:
115+
login_message = _('עליך לאשר את המייל')
104116
error_details = {'next': next_page, 'login_message': login_message}
105117
return redirect(url_for('login', **error_details))
106118

@@ -110,25 +122,59 @@ def login(login_message: Optional[str] = None):
110122
@webapp.route('/signup', methods=['GET', 'POST'])
111123
def signup():
112124
form = RegisterForm()
113-
114125
if form.validate_on_submit():
115126
User.get_or_create(**{
116127
User.mail_address.name: form.email.data,
117128
User.username.name: form.username.data,
118129
}, defaults={
119130
User.fullname.name: form.fullname.data,
120-
User.role.name: Role.get_student_role(),
131+
User.role.name: Role.get_not_confirmed_role(),
121132
User.password.name: form.password.data,
122133
User.api_key.name: User.random_password(),
123134
})
124135

136+
send_confirmation_mail(form.email.data, form.fullname.data)
137+
125138
return redirect(url_for(
126-
'login', login_message='Registration Successfully',
139+
'login', login_message=_('ההרשמה בוצעה בהצלחה'),
127140
))
128141

129142
return render_template('signup.html', form=form)
130143

131144

145+
@webapp.route('/confirm-email/<token>')
146+
def confirm_email(token: str):
147+
try:
148+
email = SERIALIZER.loads(
149+
token, salt='email-confirmation', max_age=3600,
150+
)
151+
user = User.get_or_none(User.mail_address == email)
152+
if user is None:
153+
return fail(404, f'No such user with email {email}.')
154+
if not user.role.is_not_confirmed:
155+
return fail(
156+
403, f'User has been already confirmed {user.username}',
157+
)
158+
update = User.update(
159+
role=Role.get_student_role(),
160+
).where(User.username == user.username)
161+
update.execute()
162+
163+
return redirect(url_for(
164+
'login', login_message=(
165+
_('המשתמש שלך אומת בהצלחה, כעת הינך יכול להתחבר למערכת'),
166+
),
167+
))
168+
except SignatureExpired:
169+
return redirect(url_for(
170+
'login', login_message=(
171+
_('קישור האימות פג תוקף, קישור חדש נשלח אל תיבת המייל שלך'),
172+
),
173+
))
174+
except (BadSignature, BadTimeSignature):
175+
return fail(404, 'No such signature')
176+
177+
132178
@webapp.route('/logout')
133179
@login_required
134180
def logout():

lms/templates/login.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ <h1 id="main-title" class="h3 font-weight-normal">{{ _('התחברות') }}</h1>
3535
<button class="btn btn-primary btn-lg btn-block">{{ _('התחבר') }}</button>
3636
</form>
3737
<hr class="mt-3 mb-3"/>
38-
<a href="/signup" class="btn btn-success btn-sm" role="button">{{ _('הרשם') }}</a>
38+
<a href="/signup" class="btn btn-success btn-sm" role="button">{{ _('הירשם') }}</a>
3939
</div>
4040
</div>
4141
</div>

requirements.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ flake8==3.8.3
4242
Flask-Admin==1.5.6
4343
Flask-Babel==2.0.0
4444
Flask-Limiter==1.4
45-
git+git://github.com/maxcountryman/flask-login@e3d8079#egg=flask-login
45+
Flask-Login==0.5.0
46+
Flask-Mail==0.9.1
4647
Flask-WTF==0.14.3
4748
Flask==1.1.2
4849
future==0.18.2
@@ -56,7 +57,7 @@ ipykernel==5.3.4
5657
ipython-genutils==0.2.0
5758
ipython==7.18.1
5859
ipywidgets==7.5.1
59-
itsdangerous==1.1.0
60+
itsdangerous==2.0.1
6061
jedi==0.17.2
6162
jinja2-pluralize==0.3.0
6263
Jinja2==2.11.3

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ def create_banned_user(index: int = 0) -> User:
118118
return create_user(RoleOptions.BANNED.value, index)
119119

120120

121+
def create_not_confirmed_user(index: int = 0) -> User:
122+
return create_user(RoleOptions.NOT_CONFIRMED.value, index)
123+
124+
121125
def create_student_user(index: int = 0) -> User:
122126
return create_user(RoleOptions.STUDENT.value, index)
123127

@@ -141,6 +145,11 @@ def staff_user(staff_password):
141145
return create_staff_user()
142146

143147

148+
@pytest.fixture()
149+
def not_confirmed_user():
150+
return create_not_confirmed_user()
151+
152+
144153
@pytest.fixture()
145154
def student_user():
146155
return create_student_user()

tests/samples/config.py.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ FEATURE_FLAG_CHECK_IDENTICAL_CODE_ON = os.getenv(
1313
'FEATURE_FLAG_CHECK_IDENTICAL_CODE_ON', False,
1414
)
1515

16+
TESTING = True
17+
1618

1719
MAIL_WELCOME_MESSAGE = 'welcome-email'
1820
USERS_CSV = 'users.csv'

tests/samples/config_copy.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
'FEATURE_FLAG_CHECK_IDENTICAL_CODE_ON', False,
1212
)
1313

14+
TESTING = True
15+
1416

1517
USERS_CSV = 'users.csv'
1618

tests/test_login.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ def test_login_username_fail(student_user: User):
2323
fail_login_response = client.get('/exercises')
2424
assert fail_login_response.status_code == 302
2525

26+
@staticmethod
27+
def test_login_not_confirmed_user(not_confirmed_user: User):
28+
client = webapp.test_client()
29+
login_response = client.post('/login', data={
30+
'username': not_confirmed_user.username,
31+
'password': 'fake pass',
32+
}, follow_redirects=True)
33+
assert login_response.request.path == '/login'
34+
fail_login_response = client.get('/exercises')
35+
assert fail_login_response.status_code == 302
36+
2637
@staticmethod
2738
def test_login_success(student_user: User):
2839
client = webapp.test_client()

tests/test_registration.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from lms.lmsweb.tools.registration import generate_confirmation_token
2+
from lms.lmsdb.models import User
3+
from lms.lmsweb import webapp
4+
5+
6+
class TestRegistration:
7+
@staticmethod
8+
def test_invalid_username(student_user: User):
9+
client = webapp.test_client()
10+
response = client.post('/signup', data={
11+
'email': 'some_name@mail.com',
12+
'username': student_user.username,
13+
'fullname': 'some_name',
14+
'password': 'some_password',
15+
'confirm': 'some_password',
16+
}, follow_redirects=True)
17+
assert response.request.path == '/signup'
18+
19+
client.post('/login', data={
20+
'username': student_user.username,
21+
'password': 'some_password',
22+
}, follow_redirects=True)
23+
fail_login_response = client.get('/exercises')
24+
assert fail_login_response.status_code == 302
25+
26+
@staticmethod
27+
def test_invalid_email(student_user: User):
28+
client = webapp.test_client()
29+
response = client.post('/signup', data={
30+
'email': student_user.mail_address,
31+
'username': 'some_user',
32+
'fullname': 'some_name',
33+
'password': 'some_password',
34+
'confirm': 'some_password',
35+
}, follow_redirects=True)
36+
assert response.request.path == '/signup'
37+
38+
client.post('/login', data={
39+
'username': 'some_user',
40+
'password': 'some_password',
41+
}, follow_redirects=True)
42+
fail_login_response = client.get('/exercises')
43+
assert fail_login_response.status_code == 302
44+
45+
@staticmethod
46+
def test_successful_registration():
47+
client = webapp.test_client()
48+
response = client.post('/signup', data={
49+
'email': 'some_user123@mail.com',
50+
'username': 'some_user',
51+
'fullname': 'some_name',
52+
'password': 'some_password',
53+
'confirm': 'some_password',
54+
}, follow_redirects=True)
55+
assert response.request.path == '/login'
56+
57+
client.post('/login', data={
58+
'username': 'some_user',
59+
'password': 'some_password',
60+
}, follow_redirects=True)
61+
fail_login_response = client.get('/exercises')
62+
assert fail_login_response.status_code == 302
63+
64+
token = generate_confirmation_token('some_user123@mail.com')
65+
client.get(f'/confirm-email/{token}', follow_redirects=True)
66+
client.post('/login', data={
67+
'username': 'some_user',
68+
'password': 'some_password',
69+
}, follow_redirects=True)
70+
fail_login_response = client.get('/exercises')
71+
assert fail_login_response.status_code == 200

0 commit comments

Comments
 (0)