diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py
index 8fd5b812..8277e1a5 100644
--- a/lms/lmsdb/models.py
+++ b/lms/lmsdb/models.py
@@ -34,6 +34,7 @@
class RoleOptions(enum.Enum):
BANNED = 'Banned'
+ UNVERIFIED = 'Unverified'
STUDENT = 'Student'
STAFF = 'Staff'
VIEWER = 'Viewer'
@@ -67,6 +68,7 @@ class Role(BaseModel):
(RoleOptions.STAFF.value, RoleOptions.STAFF.value),
(RoleOptions.VIEWER.value, RoleOptions.VIEWER.value),
(RoleOptions.STUDENT.value, RoleOptions.STUDENT.value),
+ (RoleOptions.UNVERIFIED.value, RoleOptions.UNVERIFIED.value),
(RoleOptions.BANNED.value, RoleOptions.BANNED.value),
))
@@ -77,6 +79,10 @@ def __str__(self):
def get_banned_role(cls) -> 'Role':
return cls.get(Role.name == RoleOptions.BANNED.value)
+ @classmethod
+ def get_unverified_role(cls) -> 'Role':
+ return cls.get(Role.name == RoleOptions.UNVERIFIED.value)
+
@classmethod
def get_student_role(cls) -> 'Role':
return cls.get(Role.name == RoleOptions.STUDENT.value)
@@ -100,6 +106,10 @@ def by_name(cls, name) -> 'Role':
def is_banned(self) -> bool:
return self.name == RoleOptions.BANNED.value
+ @property
+ def is_unverified(self) -> bool:
+ return self.name == RoleOptions.UNVERIFIED.value
+
@property
def is_student(self) -> bool:
return self.name == RoleOptions.STUDENT.value
@@ -407,8 +417,7 @@ def set_state(self, new_state: SolutionState, **kwargs) -> bool:
**{Solution.state.name: new_state.name},
**kwargs,
).where(requested_solution)
- updated = changes.execute() == 1
- return updated
+ return changes.execute() == 1
def ordered_versions(self) -> Iterable['Solution']:
return Solution.select().where(
diff --git a/lms/lmsweb/__init__.py b/lms/lmsweb/__init__.py
index 5f3a5347..ec48f2ae 100644
--- a/lms/lmsweb/__init__.py
+++ b/lms/lmsweb/__init__.py
@@ -5,6 +5,7 @@
from flask_babel import Babel # type: ignore
from flask_limiter import Limiter # type: ignore
from flask_limiter.util import get_remote_address # type: ignore
+from flask_mail import Mail # type: ignore
from flask_wtf.csrf import CSRFProtect # type: ignore
from lms.utils import config_migrator, debug
@@ -41,6 +42,8 @@
# Localizing configurations
babel = Babel(webapp)
+webmail = Mail(webapp)
+
# Must import files after app's creation
from lms.lmsdb import models # NOQA: F401, E402, I202
diff --git a/lms/lmsweb/config.py.example b/lms/lmsweb/config.py.example
index 1fab4072..d1c500f5 100644
--- a/lms/lmsweb/config.py.example
+++ b/lms/lmsweb/config.py.example
@@ -8,6 +8,20 @@ SECRET_KEY = ''
MAILGUN_API_KEY = os.getenv('MAILGUN_API_KEY')
MAILGUN_DOMAIN = os.getenv('MAILGUN_DOMAIN', 'mail.pythonic.guru')
SERVER_ADDRESS = os.getenv('SERVER_ADDRESS', '127.0.0.1:5000')
+SITE_NAME = 'Learning Python'
+
+# REGISTRATION CONFIGURATIONS
+REGISTRATION_OPEN = True
+CONFIRMATION_TIME = 3600
+
+# MAIL CONFIGURATION
+MAIL_SERVER = 'smtp.gmail.com'
+MAIL_PORT = 465
+MAIL_USE_SSL = True
+MAIL_USE_TLS = False
+MAIL_USERNAME = 'username@gmail.com'
+MAIL_PASSWORD = 'password'
+MAIL_DEFAULT_SENDER = 'username@gmail.com'
# ADMIN PANEL
FLASK_ADMIN_FLUID_LAYOUT = True
diff --git a/lms/lmsweb/forms/register.py b/lms/lmsweb/forms/register.py
new file mode 100644
index 00000000..5e7eb526
--- /dev/null
+++ b/lms/lmsweb/forms/register.py
@@ -0,0 +1,34 @@
+from flask_babel import gettext as _ # type: ignore
+from flask_wtf import FlaskForm
+from wtforms import PasswordField, StringField
+from wtforms.validators import Email, EqualTo, InputRequired, Length
+
+from lms.lmsweb.tools.validators import (
+ UniqueEmailRequired, UniqueUsernameRequired,
+)
+
+
+class RegisterForm(FlaskForm):
+ email = StringField(
+ 'Email', validators=[
+ InputRequired(), Email(message=_('אימייל לא תקין')),
+ UniqueEmailRequired,
+ ],
+ )
+ username = StringField(
+ 'Username', validators=[
+ InputRequired(), UniqueUsernameRequired, Length(min=4, max=20),
+ ],
+ )
+ fullname = StringField(
+ 'Full Name', validators=[InputRequired(), Length(min=3, max=60)],
+ )
+ password = PasswordField(
+ 'Password', validators=[InputRequired(), Length(min=8)], id='password',
+ )
+ confirm = PasswordField(
+ 'Password Confirmation', validators=[
+ InputRequired(),
+ EqualTo('password', message=_('הסיסמאות שהוקלדו אינן זהות')),
+ ],
+ )
diff --git a/lms/lmsweb/tools/validators.py b/lms/lmsweb/tools/validators.py
new file mode 100644
index 00000000..2f502b60
--- /dev/null
+++ b/lms/lmsweb/tools/validators.py
@@ -0,0 +1,21 @@
+from flask_babel import gettext as _ # type: ignore
+from wtforms.fields.core import StringField
+from wtforms.validators import ValidationError
+
+from lms.lmsdb.models import User
+
+
+def UniqueUsernameRequired(
+ _form: 'RegisterForm', field: StringField, # type: ignore # NOQA: F821
+) -> None:
+ username_exists = User.get_or_none(User.username == field.data)
+ if username_exists:
+ raise ValidationError(_('שם המשתמש כבר נמצא בשימוש'))
+
+
+def UniqueEmailRequired(
+ _form: 'RegisterForm', field: StringField, # type: ignore # NOQA: F821
+) -> None:
+ email_exists = User.get_or_none(User.mail_address == field.data)
+ if email_exists:
+ raise ValidationError(_('האימייל כבר נמצא בשימוש'))
diff --git a/lms/lmsweb/translations/en/LC_MESSAGES/messages.po b/lms/lmsweb/translations/en/LC_MESSAGES/messages.po
index 5b5f12e3..0c35ce13 100644
--- a/lms/lmsweb/translations/en/LC_MESSAGES/messages.po
+++ b/lms/lmsweb/translations/en/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: lmsweb-1.0\n"
"Report-Msgid-Bugs-To: bugs@mesicka.com\n"
-"POT-Creation-Date: 2020-10-09 11:20+0300\n"
+"POT-Creation-Date: 2021-09-12 15:10+0300\n"
"PO-Revision-Date: 2020-09-16 18:29+0300\n"
"Last-Translator: Or Ronai\n"
"Language: en\n"
@@ -16,9 +16,9 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.8.0\n"
+"Generated-By: Babel 2.9.1\n"
-#: lmsdb/models.py:632
+#: lmsdb/models.py:698
msgid "כישלון חמור"
msgstr "Fatal error"
@@ -45,26 +45,79 @@ msgstr "The automatic checker couldn't run your code."
msgid "אחי, בדקת את הקוד שלך?"
msgstr "Bro, did you check your code?"
+#: lmsweb/views.py:122
+msgid "לא ניתן להירשם כעת"
+msgstr "Can not register now"
+
+#: lmsweb/views.py:139
+msgid "ההרשמה בוצעה בהצלחה"
+msgstr "Registration successfully"
+
+#: lmsweb/views.py:161
+msgid "קישור האימות פג תוקף, קישור חדש נשלח אל תיבת המייל שלך"
+msgstr "The confirmation link is expired, new link has been sent to your email"
+
+#: lmsweb/views.py:174
+#, fuzzy
+msgid "המשתמש שלך אומת בהצלחה, כעת אתה יכול להתחבר למערכת"
+msgstr "Your user has been successfully confirmed, you can now login"
+
+#: lmsweb/forms/register.py:14
+msgid "אימייל לא תקין"
+msgstr "Invalid email"
+
+#: lmsweb/forms/register.py:32
+msgid "הסיסמאות שהוקלדו אינן זהות"
+msgstr "The passwords are not identical"
+
#: lmsweb/tools/registration.py:105
msgid "מערכת הגשת התרגילים"
msgstr "Exercuse submission system"
-#: models/solutions.py:46
+#: lmsweb/tools/validators.py:13
+msgid "שם המשתמש כבר נמצא בשימוש"
+msgstr "The username already in use"
+
+#: lmsweb/tools/validators.py:21
+msgid "האימייל כבר נמצא בשימוש"
+msgstr "The email already in use"
+
+#: models/register.py:20
+#, python-format
+msgid "מייל אימות - %(site_name)s"
+msgstr "Confirmation mail - %(site_name)s"
+
+#: models/register.py:25
+#, fuzzy, python-format
+msgid "שלום %(fullname)s,\nלינק האימות שלך למערכת הוא: %(link)s"
+msgstr "Hello %(fullname)s,\n Your confirmation link is: %(link)s"
+
+#: models/solutions.py:50
#, python-format
msgid "%(solver)s הגיב לך על בדיקת תרגיל \"%(subject)s\"."
msgstr "%(solver)s has replied for your \"%(subject)s\" check."
-#: models/solutions.py:53
+#: models/solutions.py:57
#, python-format
msgid "%(checker)s הגיב לך על תרגיל \"%(subject)s\"."
msgstr "%(checker)s replied for \"%(subject)s\"."
-#: models/solutions.py:65
+#: models/solutions.py:69
#, python-format
msgid "הפתרון שלך לתרגיל \"%(subject)s\" נבדק."
msgstr "Your solution for the \"%(subject)s\" exercise has been checked."
-#: templates/banned.html:8 templates/login.html:7
+#: models/users.py:25
+#, fuzzy
+msgid "שם המשתמש או הסיסמה שהוזנו לא תקינים"
+msgstr "Invalid username or password"
+
+#: models/users.py:27
+#, fuzzy
+msgid "עליך לאשר את מייל האימות"
+msgstr "You have to confirm your registration with the link sent to your email"
+
+#: templates/banned.html:8 templates/login.html:7 templates/signup.html:8
msgid "תמונת הפרופיל של קורס פייתון"
msgstr "Profile picture of the Python Course"
@@ -84,7 +137,7 @@ msgstr "Exercise submission system for the Python Course"
msgid "תרגילים"
msgstr "Exercises"
-#: templates/exercises.html:21
+#: templates/exercises.html:21 templates/view.html:101
msgid "הערות על התרגיל"
msgstr "Comments for the solution"
@@ -108,7 +161,7 @@ msgstr "All Exercises"
msgid "התחברות"
msgstr "Login"
-#: templates/login.html:10
+#: templates/login.html:10 templates/signup.html:11
msgid "ברוכים הבאים למערכת התרגילים!"
msgstr "Welcome to the exercise system!"
@@ -116,18 +169,22 @@ msgstr "Welcome to the exercise system!"
msgid "הזינו את שם המשתמש והסיסמה שלכם:"
msgstr "Insert your username and password:"
-#: templates/login.html:15 templates/login.html:17
+#: templates/login.html:22 templates/login.html:24 templates/signup.html:16
msgid "שם משתמש"
msgstr "Username"
-#: templates/login.html:21 templates/login.html:23
+#: templates/login.html:28 templates/login.html:30 templates/signup.html:18
msgid "סיסמה"
msgstr "Password"
-#: templates/login.html:28
+#: templates/login.html:35
msgid "התחבר"
msgstr "Login"
+#: templates/login.html:39 templates/signup.html:22
+msgid "הירשם"
+msgstr "Register"
+
#: templates/navbar.html:8
msgid "הלוגו של פרויקט לומדים פייתון: נחש צהוב על רקע עיגול בצבע תכלת, ומתחתיו כתוב - לומדים פייתון."
msgstr "The logo of the Learning Python project: yellow snake on light blue circle background and behind of it written - Learning Python"
@@ -164,6 +221,32 @@ msgstr "Check Exercises"
msgid "התנתקות"
msgstr "Logout"
+#: templates/signup.html:9
+#, fuzzy
+msgid "הרשמה"
+msgstr "Registration"
+
+#: templates/signup.html:12
+msgid "הזינו אימייל וסיסמה לצורך רישום למערכת:"
+msgstr "Insert your email and password for registration:"
+
+#: templates/signup.html:15
+msgid "כתובת אימייל"
+msgstr "Email Address"
+
+#: templates/signup.html:17
+msgid "שם מלא"
+msgstr "Full Name"
+
+#: templates/signup.html:19
+#, fuzzy
+msgid "אימות סיסמה"
+msgstr "Password Confirmation"
+
+#: templates/signup.html:25
+msgid "חזרה לדף ההתחברות"
+msgstr "Back to login page"
+
#: templates/status.html:7
msgid "חמ\"ל תרגילים"
msgstr "Exercises operations room"
@@ -256,23 +339,23 @@ msgstr "Submitted"
msgid "לא הוגש"
msgstr "Not submitted"
-#: templates/user.html:43
+#: templates/user.html:44
msgid "פתקיות:"
msgstr "Notes:"
-#: templates/user.html:60 templates/user.html:62
+#: templates/user.html:49 templates/user.html:51
msgid "פתקית חדשה"
msgstr "New Note"
-#: templates/user.html:66
+#: templates/user.html:55
msgid "תרגיל משויך:"
msgstr "Exercise:"
-#: templates/user.html:75
+#: templates/user.html:64
msgid "רמת פרטיות:"
msgstr "Privacy Level:"
-#: templates/user.html:81
+#: templates/user.html:70
msgid "הוסף פתקית"
msgstr "Add Note"
@@ -332,10 +415,6 @@ msgstr "Error:"
msgid "שגיאת סגל:"
msgstr "Staff Error:"
-#: templates/view.html:101
-msgid "הערות על התרגיל"
-msgstr "Comments for the exercise"
-
#: templates/view.html:109
msgid "הערות כלליות"
msgstr "General comments"
diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py
index ad1d188e..eecc397d 100644
--- a/lms/lmsweb/views.py
+++ b/lms/lmsweb/views.py
@@ -5,15 +5,17 @@
jsonify, make_response, render_template,
request, send_from_directory, url_for,
)
+from flask_babel import gettext as _ # type: ignore
from flask_limiter.util import get_remote_address # type: ignore
from flask_login import ( # type: ignore
current_user, login_required, login_user, logout_user,
)
+from itsdangerous import BadSignature, SignatureExpired
from werkzeug.datastructures import FileStorage
from werkzeug.utils import redirect
from lms.lmsdb.models import (
- ALL_MODELS, Comment, Note, RoleOptions, SharedSolution,
+ ALL_MODELS, Comment, Note, Role, RoleOptions, SharedSolution,
Solution, SolutionFile, User, database,
)
from lms.lmsweb import babel, limiter, routes, webapp
@@ -21,8 +23,10 @@
AdminModelView, SPECIAL_MAPPING, admin, managers_only,
)
from lms.lmsweb.config import (
- LANGUAGES, LIMITS_PER_HOUR, LIMITS_PER_MINUTE, LOCALE, MAX_UPLOAD_SIZE,
+ CONFIRMATION_TIME, LANGUAGES, LIMITS_PER_HOUR,
+ LIMITS_PER_MINUTE, LOCALE, MAX_UPLOAD_SIZE,
)
+from lms.lmsweb.forms.register import RegisterForm
from lms.lmsweb.manifest import MANIFEST
from lms.lmsweb.redirections import (
PERMISSIVE_CORS, get_next_url, login_manager,
@@ -30,7 +34,12 @@
from lms.models import (
comments, notes, notifications, share_link, solutions, upload,
)
-from lms.models.errors import FileSizeError, LmsError, UploadError, fail
+from lms.models.errors import (
+ FileSizeError, ForbiddenPermission, LmsError,
+ UnauthorizedError, UploadError, fail,
+)
+from lms.models.register import SERIALIZER, send_confirmation_mail
+from lms.models.users import auth, retrieve_salt
from lms.utils.consts import RTL_LANGUAGES
from lms.utils.files import (
get_language_name_by_extension, get_mime_type_by_extention,
@@ -83,27 +92,90 @@ def ratelimit_handler(e):
f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour',
deduct_when=lambda response: response.status_code != 200,
)
-def login(login_error: Optional[str] = None):
-
+def login(login_message: Optional[str] = None):
if current_user.is_authenticated:
return get_next_url(request.args.get('next'))
username = request.form.get('username')
password = request.form.get('password')
next_page = request.form.get('next')
- login_error = request.args.get('login_error')
- user = User.get_or_none(username=username)
+ login_message = request.args.get('login_message')
if request.method == 'POST':
- if user is not None and user.is_password_valid(password):
+ try:
+ user = auth(username, password)
+ except (ForbiddenPermission, UnauthorizedError) as e:
+ error_message, _ = e.args
+ error_details = {'next': next_page, 'login_message': error_message}
+ return redirect(url_for('login', **error_details))
+ else:
login_user(user)
return get_next_url(next_page)
- elif user is None or not user.is_password_valid(password):
- login_error = 'Invalid username or password'
- error_details = {'next': next_page, 'login_error': login_error}
- return redirect(url_for('login', **error_details))
- return render_template('login.html', login_error=login_error)
+ return render_template('login.html', login_message=login_message)
+
+
+@webapp.route('/signup', methods=['GET', 'POST'])
+@limiter.limit(f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour')
+def signup():
+ if not webapp.config.get('REGISTRATION_OPEN', False):
+ return redirect(url_for(
+ 'login', login_message=_('לא ניתן להירשם כעת'),
+ ))
+
+ form = RegisterForm()
+ if not form.validate_on_submit():
+ return render_template('signup.html', form=form)
+
+ user = User.create(**{
+ User.mail_address.name: form.email.data,
+ User.username.name: form.username.data,
+ User.fullname.name: form.fullname.data,
+ User.role.name: Role.get_unverified_role(),
+ User.password.name: form.password.data,
+ User.api_key.name: User.random_password(),
+ })
+ send_confirmation_mail(user)
+ return redirect(url_for(
+ 'login', login_message=_('ההרשמה בוצעה בהצלחה'),
+ ))
+
+
+@webapp.route('/confirm-email//')
+@limiter.limit(f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour')
+def confirm_email(user_id: int, token: str):
+ user = User.get_or_none(User.id == user_id)
+ if user is None:
+ return fail(404, 'The authentication code is invalid.')
+
+ if not user.role.is_unverified:
+ return fail(403, 'User has been already confirmed.')
+
+ try:
+ SERIALIZER.loads(
+ token, salt=retrieve_salt(user), max_age=CONFIRMATION_TIME,
+ )
+
+ except SignatureExpired:
+ send_confirmation_mail(user)
+ return redirect(url_for(
+ 'login', login_message=(
+ _('קישור האימות פג תוקף, קישור חדש נשלח אל תיבת המייל שלך'),
+ ),
+ ))
+ except BadSignature:
+ return fail(404, 'The authentication code is invalid.')
+
+ else:
+ update = User.update(
+ role=Role.get_student_role(),
+ ).where(User.username == user.username)
+ update.execute()
+ return redirect(url_for(
+ 'login', login_message=(
+ _('המשתמש שלך אומת בהצלחה, כעת אתה יכול להתחבר למערכת'),
+ ),
+ ))
@webapp.route('/logout')
diff --git a/lms/models/errors.py b/lms/models/errors.py
index 35fecd17..1a2ec15d 100644
--- a/lms/models/errors.py
+++ b/lms/models/errors.py
@@ -5,10 +5,6 @@ class LmsError(Exception):
pass
-class UploadError(LmsError):
- pass
-
-
class AlreadyExists(LmsError):
pass
@@ -21,10 +17,22 @@ class FileSizeError(LmsError):
pass
+class UnhashedPasswordError(LmsError):
+ pass
+
+
+class UploadError(LmsError):
+ pass
+
+
class NotValidRequest(LmsError): # Error 400
pass
+class UnauthorizedError(LmsError): # Error 401
+ pass
+
+
class ForbiddenPermission(LmsError): # Error 403
pass
diff --git a/lms/models/register.py b/lms/models/register.py
new file mode 100644
index 00000000..f19937cb
--- /dev/null
+++ b/lms/models/register.py
@@ -0,0 +1,30 @@
+from flask import url_for
+from flask_babel import gettext as _ # type: ignore
+from flask_mail import Message # type: ignore
+from itsdangerous import URLSafeTimedSerializer
+
+from lms.lmsdb.models import User
+from lms.lmsweb import config, webapp, webmail
+from lms.models.users import retrieve_salt
+
+
+SERIALIZER = URLSafeTimedSerializer(config.SECRET_KEY)
+
+
+def generate_confirmation_token(user: User) -> str:
+ return SERIALIZER.dumps(user.mail_address, salt=retrieve_salt(user))
+
+
+def send_confirmation_mail(user: User) -> None:
+ token = generate_confirmation_token(user)
+ subject = _('מייל אימות - %(site_name)s', site_name=config.SITE_NAME)
+ msg = Message(subject, recipients=[user.mail_address])
+ link = url_for(
+ 'confirm_email', user_id=user.id, token=token, _external=True,
+ )
+ msg.body = _(
+ 'שלום %(fullname)s,\nלינק האימות שלך למערכת הוא: %(link)s',
+ fullname=user.fullname, link=link,
+ )
+ if not webapp.config.get('TESTING'):
+ webmail.send(msg)
diff --git a/lms/models/users.py b/lms/models/users.py
new file mode 100644
index 00000000..bb2720a6
--- /dev/null
+++ b/lms/models/users.py
@@ -0,0 +1,28 @@
+import re
+
+from flask_babel import gettext as _ # type: ignore
+
+from lms.lmsdb.models import User
+from lms.models.errors import (
+ ForbiddenPermission, UnauthorizedError, UnhashedPasswordError,
+)
+
+
+HASHED_PASSWORD = re.compile(r'^pbkdf2.+?\$(?P.+?)\$(?P.+)')
+
+
+def retrieve_salt(user: User) -> str:
+ password = HASHED_PASSWORD.match(user.password)
+ try:
+ return password.groupdict().get('salt')
+ except AttributeError: # should never happen
+ raise UnhashedPasswordError('Password format is invalid.')
+
+
+def auth(username: str, password: str) -> User:
+ user = User.get_or_none(username=username)
+ if user is None or not user.is_password_valid(password):
+ raise UnauthorizedError(_('שם המשתמש או הסיסמה שהוזנו לא תקינים'), 400)
+ elif user.role.is_unverified:
+ raise ForbiddenPermission(_('עליך לאשר את מייל האימות'), 403)
+ return user
diff --git a/lms/static/my.css b/lms/static/my.css
index 8380ae18..4e9cd078 100644
--- a/lms/static/my.css
+++ b/lms/static/my.css
@@ -46,25 +46,29 @@ a {
text-align: right;
}
-#login-container {
+#login-container,
+#signup-container {
height: 100%;
align-items: center;
display: flex;
align-self: center;
}
-#login {
+#login,
+#signup {
margin: auto;
max-width: 420px;
padding: 15px;
width: 100%;
}
-#login-logo {
+#login-logo,
+#signup-logo {
margin-bottom: 1rem;
}
-#login-messege-box {
+#login-message-box,
+#signup-errors {
background: #f1c8c8;
color: #860606;
}
diff --git a/lms/templates/_formhelpers.html b/lms/templates/_formhelpers.html
new file mode 100644
index 00000000..4175e804
--- /dev/null
+++ b/lms/templates/_formhelpers.html
@@ -0,0 +1,13 @@
+{% macro render_field(field, cls) %}
+
+
+ {{ field(class=cls, **kwargs) | safe }}
+
+ {% if field.errors %}
+ {% for error in field.errors %}
+ {{ error }}
+ {% endfor %}
+ {% endif %}
+
+
+{% endmacro %}
diff --git a/lms/templates/login.html b/lms/templates/login.html
index 3fb5515e..b57f9238 100644
--- a/lms/templates/login.html
+++ b/lms/templates/login.html
@@ -10,10 +10,10 @@ {{ _('התחברות') }}
{{ _('ברוכים הבאים למערכת התרגילים!') }}
{{ _('הזינו את שם המשתמש והסיסמה שלכם:') }}
- {% if login_error %}
-
+ {% if login_message %}
+
- {{ login_error }}
+ {{ login_message }}
{% endif %}
@@ -34,6 +34,10 @@
{{ _('התחברות') }}
+ {% if config.REGISTRATION_OPEN %}
+
+
{{ _('הירשם') }}
+ {% endif %}
diff --git a/lms/templates/signup.html b/lms/templates/signup.html
new file mode 100644
index 00000000..b888068b
--- /dev/null
+++ b/lms/templates/signup.html
@@ -0,0 +1,29 @@
+{% extends 'base.html' %}
+{% from "_formhelpers.html" import render_field %}
+
+{% block page_content %}
+
+
+
+
![{{ _('תמונת הפרופיל של קורס פייתון') }}]({{ url_for('static', filename='avatar.jpg') }})
+
{{ _('הרשמה') }}
+
+ {{ _('ברוכים הבאים למערכת התרגילים!') }}
+ {{ _('הזינו אימייל וסיסמה לצורך רישום למערכת:') }}
+
+
+
+
{{ _('חזרה לדף ההתחברות') }}
+
+
+
+{% endblock %}
diff --git a/requirements.txt b/requirements.txt
index 9b9990eb..3b6bda24 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -19,6 +19,7 @@ debugpy==1.0.0rc2
decorator==4.4.2
diff-cover==2.6.1
docker-pycreds==0.4.0
+email-validator==1.1.3
entrypoints==0.3
eradicate==1.0
flake8-alfred==1.1.1
@@ -41,7 +42,8 @@ flake8==3.8.3
Flask-Admin==1.5.8
Flask-Babel==2.0.0
Flask-Limiter==1.4
-git+git://github.com/maxcountryman/flask-login@e3d8079#egg=flask-login
+Flask-Login==0.5.0
+Flask-Mail==0.9.1
Flask-WTF==0.14.3
Flask==1.1.2
future==0.18.2
@@ -55,7 +57,7 @@ ipykernel==5.3.4
ipython-genutils==0.2.0
ipython==7.18.1
ipywidgets==7.5.1
-itsdangerous==1.1.0
+itsdangerous==2.0.1
jedi==0.17.2
jinja2-pluralize==0.3.0
Jinja2==2.11.3
diff --git a/tests/conftest.py b/tests/conftest.py
index 226209dc..93caf4b9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,11 +1,11 @@
import datetime
from functools import wraps
-from lms.models import notes
import os
import random
import string
from typing import List, Optional
+from flask import template_rendered
from flask.testing import FlaskClient
from peewee import SqliteDatabase
import pytest
@@ -49,6 +49,11 @@ def db(db_in_memory):
db_in_memory.rollback()
+@pytest.fixture(autouse=True, scope='function')
+def client():
+ return webapp.test_client()
+
+
@pytest.fixture(autouse=True, scope='session')
def celery_eager():
public_app.conf.update(task_always_eager=True)
@@ -65,6 +70,16 @@ def webapp_configurations():
limiter.enabled = False
+@pytest.fixture(autouse=True, scope='session')
+def disable_mail_sending():
+ webapp.config['TESTING'] = True
+
+
+@pytest.fixture(autouse=True, scope='session')
+def enable_registration():
+ webapp.config['REGISTRATION_OPEN'] = True
+
+
def disable_shareable_solutions():
webapp.config['SHAREABLE_SOLUTIONS'] = False
@@ -77,6 +92,10 @@ def enable_users_comments():
webapp.config['USERS_COMMENTS'] = True
+def disable_registration():
+ webapp.config['REGISTRATION_OPEN'] = False
+
+
def use_limiter(func):
@wraps(func)
def wrapper(*args, **kwargs):
@@ -118,6 +137,10 @@ def create_banned_user(index: int = 0) -> User:
return create_user(RoleOptions.BANNED.value, index)
+def create_unverified_user(index: int = 0) -> User:
+ return create_user(RoleOptions.UNVERIFIED.value, index)
+
+
def create_student_user(index: int = 0) -> User:
return create_user(RoleOptions.STUDENT.value, index)
@@ -141,6 +164,11 @@ def staff_user(staff_password):
return create_staff_user()
+@pytest.fixture()
+def unverified_user():
+ return create_unverified_user()
+
+
@pytest.fixture()
def student_user():
return create_student_user()
@@ -159,6 +187,20 @@ def admin_user():
)
+@pytest.fixture(autouse=True, scope='function')
+def captured_templates():
+ recorded = []
+
+ def record(sender, template, context, **kwargs):
+ recorded.append((template, context))
+
+ template_rendered.connect(record, webapp)
+ try:
+ yield recorded
+ finally:
+ template_rendered.disconnect(record, webapp)
+
+
def create_notification(
student_user: User,
solution: Solution,
diff --git a/tests/samples/config_copy.py b/tests/samples/config_copy.py
index 0d2d93ce..d56c7b9d 100644
--- a/tests/samples/config_copy.py
+++ b/tests/samples/config_copy.py
@@ -12,6 +12,7 @@
)
+
USERS_CSV = 'users.csv'
diff --git a/tests/test_flask_limiter.py b/tests/test_flask_limiter.py
index 311cff55..3457230d 100644
--- a/tests/test_flask_limiter.py
+++ b/tests/test_flask_limiter.py
@@ -1,3 +1,5 @@
+from flask.testing import FlaskClient
+
from lms.lmsweb import routes
from lms.lmsdb.models import Solution, User
from lms.lmsweb import webapp
@@ -7,8 +9,7 @@
class TestLimiter:
@staticmethod
@conftest.use_limiter
- def test_limiter_login_fails(student_user: User):
- client = webapp.test_client()
+ def test_limiter_login_fails(client: FlaskClient, student_user: User):
for _ in range(webapp.config['LIMITS_PER_MINUTE'] - 1):
response = client.post('/login', data={
'username': student_user.username,
@@ -25,16 +26,14 @@ def test_limiter_login_fails(student_user: User):
@staticmethod
@conftest.use_limiter
- def test_limiter_login_refreshes():
- client = webapp.test_client()
+ def test_limiter_login_refreshes(client: FlaskClient):
for _ in range(webapp.config['LIMITS_PER_MINUTE'] + 1):
response = client.get('/login')
assert response.status_code == 200
@staticmethod
@conftest.use_limiter
- def test_limiter_login_success(student_user: User):
- client = webapp.test_client()
+ def test_limiter_login_success(client: FlaskClient, student_user: User):
client.post('/login', data={
'username': student_user.username,
'password': 'fake5',
diff --git a/tests/test_login.py b/tests/test_login.py
index f44edc45..5a6f8e91 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -1,11 +1,11 @@
+from flask.testing import FlaskClient
+
from lms.lmsdb.models import User
-from lms.lmsweb import webapp
class TestLogin:
@staticmethod
- def test_login_password_fail(student_user: User):
- client = webapp.test_client()
+ def test_login_password_fail(client: FlaskClient, student_user: User):
client.post('/login', data={
'username': student_user.username,
'password': 'wrong_pass',
@@ -14,8 +14,7 @@ def test_login_password_fail(student_user: User):
assert fail_login_response.status_code == 302
@staticmethod
- def test_login_username_fail(student_user: User):
- client = webapp.test_client()
+ def test_login_username_fail(client: FlaskClient):
client.post('/login', data={
'username': 'wrong_user',
'password': 'fake pass',
@@ -24,8 +23,21 @@ def test_login_username_fail(student_user: User):
assert fail_login_response.status_code == 302
@staticmethod
- def test_login_success(student_user: User):
- client = webapp.test_client()
+ def test_login_unverified_user(
+ client: FlaskClient, unverified_user: User, captured_templates,
+ ):
+ client.post('/login', data={
+ 'username': unverified_user.username,
+ 'password': 'fake pass',
+ }, follow_redirects=True)
+ template, _ = captured_templates[-1]
+ assert template.name == 'login.html'
+
+ fail_login_response = client.get('/exercises')
+ assert fail_login_response.status_code == 302
+
+ @staticmethod
+ def test_login_success(client: FlaskClient, student_user: User):
client.post('/login', data={
'username': student_user.username,
'password': 'fake pass',
diff --git a/tests/test_registration.py b/tests/test_registration.py
new file mode 100644
index 00000000..f09b4d49
--- /dev/null
+++ b/tests/test_registration.py
@@ -0,0 +1,178 @@
+import time
+from unittest.mock import Mock, patch
+
+from flask.testing import FlaskClient
+
+from lms.lmsweb.config import CONFIRMATION_TIME
+from lms.lmsdb.models import User
+from lms.models.register import generate_confirmation_token
+from tests import conftest
+
+
+class TestRegistration:
+ @staticmethod
+ def test_invalid_username(
+ client: FlaskClient, student_user: User, captured_templates,
+ ):
+ client.post('/signup', data={
+ 'email': 'some_name@mail.com',
+ 'username': student_user.username,
+ 'fullname': 'some_name',
+ 'password': 'some_password',
+ 'confirm': 'some_password',
+ }, follow_redirects=True)
+ template, _ = captured_templates[-1]
+ assert template.name == "signup.html"
+
+ client.post('/login', data={
+ 'username': student_user.username,
+ 'password': 'some_password',
+ }, follow_redirects=True)
+ fail_login_response = client.get('/exercises')
+ assert fail_login_response.status_code == 302
+
+ @staticmethod
+ def test_invalid_email(
+ client: FlaskClient, student_user: User, captured_templates,
+ ):
+ client.post('/signup', data={
+ 'email': student_user.mail_address,
+ 'username': 'some_user',
+ 'fullname': 'some_name',
+ 'password': 'some_password',
+ 'confirm': 'some_password',
+ }, follow_redirects=True)
+ template, _ = captured_templates[-1]
+ assert template.name == 'signup.html'
+
+ client.post('/login', data={
+ 'username': 'some_user',
+ 'password': 'some_password',
+ }, follow_redirects=True)
+ fail_login_response = client.get('/exercises')
+ assert fail_login_response.status_code == 302
+
+ @staticmethod
+ def test_successful_registration(client: FlaskClient, captured_templates):
+ client.post('/signup', data={
+ 'email': 'some_user123@mail.com',
+ 'username': 'some_user',
+ 'fullname': 'some_name',
+ 'password': 'some_password',
+ 'confirm': 'some_password',
+ }, follow_redirects=True)
+ template, _ = captured_templates[-1]
+ assert template.name == 'login.html'
+
+ client.post('/login', data={
+ 'username': 'some_user',
+ 'password': 'some_password',
+ }, follow_redirects=True)
+ fail_login_response = client.get('/exercises')
+ assert fail_login_response.status_code == 302
+
+ user = User.get_or_none(User.username == 'some_user')
+ token = generate_confirmation_token(user)
+ client.get(f'/confirm-email/{user.id}/{token}', follow_redirects=True)
+ client.post('/login', data={
+ 'username': 'some_user',
+ 'password': 'some_password',
+ }, follow_redirects=True)
+ success_login_response = client.get('/exercises')
+ assert success_login_response.status_code == 200
+
+ @staticmethod
+ def test_bad_token_or_id(client: FlaskClient):
+ client.post('/signup', data={
+ 'email': 'some_user123@mail.com',
+ 'username': 'some_user',
+ 'fullname': 'some_name',
+ 'password': 'some_password',
+ 'confirm': 'some_password',
+ }, follow_redirects=True)
+ user = User.get_or_none(User.username == 'some_user')
+ bad_token = "fake-token43@$@"
+ fail_confirm_response = client.get(
+ f'/confirm-email/{user.id}/{bad_token}', follow_redirects=True,
+ )
+ assert fail_confirm_response.status_code == 404
+
+ # No such 999 user id
+ another_fail_response = client.get(
+ f'/confirm-email/999/{bad_token}', follow_redirects=True,
+ )
+ assert another_fail_response.status_code == 404
+
+ @staticmethod
+ def test_use_token_twice(client: FlaskClient):
+ client.post('/signup', data={
+ 'email': 'some_user123@mail.com',
+ 'username': 'some_user',
+ 'fullname': 'some_name',
+ 'password': 'some_password',
+ 'confirm': 'some_password',
+ }, follow_redirects=True)
+ user = User.get_or_none(User.username == 'some_user')
+ token = generate_confirmation_token(user)
+ success_token_response = client.get(
+ f'/confirm-email/{user.id}/{token}', follow_redirects=True,
+ )
+ assert success_token_response.status_code == 200
+
+ fail_token_response = client.get(
+ f'/confirm-email/{user.id}/{token}', follow_redirects=True,
+ )
+ assert fail_token_response.status_code == 403
+
+ @staticmethod
+ def test_expired_token(client: FlaskClient):
+ client.post('/signup', data={
+ 'email': 'some_user123@mail.com',
+ 'username': 'some_user',
+ 'fullname': 'some_name',
+ 'password': 'some_password',
+ 'confirm': 'some_password',
+ }, follow_redirects=True)
+ user = User.get_or_none(User.username == 'some_user')
+ token = generate_confirmation_token(user)
+
+ fake_time = time.time() + CONFIRMATION_TIME + 1
+ with patch('time.time', Mock(return_value=fake_time)):
+ client.get(
+ f'/confirm-email/{user.id}/{token}', follow_redirects=True,
+ )
+ client.post('/login', data={
+ 'username': 'some_user',
+ 'password': 'some_password',
+ }, follow_redirects=True)
+ fail_login_response = client.get('/exercises')
+ assert fail_login_response.status_code == 302
+
+ token = generate_confirmation_token(user)
+ client.get(
+ f'/confirm-email/{user.id}/{token}', follow_redirects=True,
+ )
+ client.post('/login', data={
+ 'username': 'some_user',
+ 'password': 'some_password',
+ }, follow_redirects=True)
+ success_login_response = client.get('/exercises')
+ assert success_login_response.status_code == 200
+
+ @staticmethod
+ def test_registartion_closed(client: FlaskClient, captured_templates):
+ conftest.disable_registration()
+ client.post('/signup', data={
+ 'email': 'some_user123@mail.com',
+ 'username': 'some_user',
+ 'fullname': 'some_name',
+ 'password': 'some_password',
+ 'confirm': 'some_password',
+ }, follow_redirects=True)
+ user = User.get_or_none(User.username == 'some_user')
+ assert user is None
+
+ response = client.get('/signup')
+ template, _ = captured_templates[-1]
+ assert template.name == 'login.html'
+ assert '/signup' not in response.get_data(as_text=True)
diff --git a/tests/test_users.py b/tests/test_users.py
index 8adb55b4..043570ca 100644
--- a/tests/test_users.py
+++ b/tests/test_users.py
@@ -1,5 +1,6 @@
+from flask.testing import FlaskClient
+
from lms.lmsdb.models import User
-from lms.lmsweb import webapp
from tests import conftest
@@ -61,8 +62,7 @@ def test_logout(student_user: User):
assert logout_response.status_code == 200
@staticmethod
- def test_banned_user(banned_user: User):
- client = client = webapp.test_client()
+ def test_banned_user(client: FlaskClient, banned_user: User):
login_response = client.post('/login', data={
'username': banned_user.username,
'password': 'fake pass',