From c3e3d66e7044e57a4d92e6a97c3dd8f2cd08330c Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Sun, 10 Oct 2021 18:03:53 +0300 Subject: [PATCH 01/10] feat: Add avatars - Added avatars to users tables - Added a template in order to change or delete avatar - Added the form and validators for the picture file - Added tests - Added translations - Added to the navbar near the username the avatar (if the user has one) - Added to gitignore the avatars --- .gitignore | 3 + lms/lmsdb/bootstrap.py | 7 + lms/lmsdb/models.py | 1 + lms/lmsweb/__init__.py | 1 + lms/lmsweb/forms/update_avatar.py | 21 +++ .../translations/he/LC_MESSAGES/messages.po | 126 +++++++++++------- lms/lmsweb/views.py | 29 +++- lms/models/users.py | 24 +++- lms/static/my.css | 10 +- lms/templates/navbar.html | 6 +- lms/templates/update-avatar.html | 20 +++ lms/templates/user.html | 6 +- requirements.txt | 1 + tests/conftest.py | 7 + tests/samples/seaturtle.jpg | Bin 0 -> 132075 bytes tests/samples/turtle.jpg | Bin 0 -> 3408031 bytes tests/test_users.py | 53 ++++++++ 17 files changed, 259 insertions(+), 56 deletions(-) create mode 100644 lms/lmsweb/forms/update_avatar.py create mode 100644 lms/templates/update-avatar.html create mode 100644 tests/samples/seaturtle.jpg create mode 100644 tests/samples/turtle.jpg diff --git a/.gitignore b/.gitignore index c5b6edef..a4aa6bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ lms/lmsweb/config.py db.sqlite vim.session devops/rabbitmq.cookie + +# Avatars +lms/static/avatars/* diff --git a/lms/lmsdb/bootstrap.py b/lms/lmsdb/bootstrap.py index c992fc39..e1aa427a 100644 --- a/lms/lmsdb/bootstrap.py +++ b/lms/lmsdb/bootstrap.py @@ -315,6 +315,12 @@ def _assessment_migration() -> bool: return True +def _avatar_migration() -> bool: + User = models.User + _migrate_column_in_table_if_needed(User, User.avatar) + return True + + def main(): with models.database.connection_context(): if models.database.table_exists(models.Exercise.__name__.lower()): @@ -328,6 +334,7 @@ def main(): _api_keys_migration() _last_course_viewed_migration() _uuid_migration() + _avatar_migration() if models.database.table_exists(models.UserCourse.__name__.lower()): _add_user_course_constaint() diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index dc29694d..6048800b 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -172,6 +172,7 @@ class User(UserMixin, BaseModel): api_key = CharField() last_course_viewed = ForeignKeyField(Course, null=True) uuid = UUIDField(default=uuid4, unique=True) + avatar = CharField(null=True) def get_id(self): return str(self.uuid) diff --git a/lms/lmsweb/__init__.py b/lms/lmsweb/__init__.py index fe328733..d7c2a018 100644 --- a/lms/lmsweb/__init__.py +++ b/lms/lmsweb/__init__.py @@ -18,6 +18,7 @@ static_dir = project_dir / 'static' config_file = web_dir / 'config.py' config_example_file = web_dir / 'config.py.example' +avatars_path = static_dir / 'avatars' if debug.is_enabled(): diff --git a/lms/lmsweb/forms/update_avatar.py b/lms/lmsweb/forms/update_avatar.py new file mode 100644 index 00000000..935a7920 --- /dev/null +++ b/lms/lmsweb/forms/update_avatar.py @@ -0,0 +1,21 @@ +from flask_babel import gettext as _ # type: ignore +from flask_wtf import FlaskForm +from flask_wtf.file import FileAllowed, FileField, FileRequired, FileSize + +from lms.lmsweb.config import MAX_UPLOAD_SIZE +from lms.utils.files import ALLOWED_IMAGES_EXTENSIONS + + +class UpdateAvatarForm(FlaskForm): + avatar = FileField( + 'Avatar', validators=[ + FileAllowed(list(ALLOWED_IMAGES_EXTENSIONS)), + FileRequired(message=_('No file added')), + FileSize( + max_size=MAX_UPLOAD_SIZE, message=_( + 'File size is too big - %(size)dMB allowed', + size=MAX_UPLOAD_SIZE // 1000000, + ), + ), + ], + ) diff --git a/lms/lmsweb/translations/he/LC_MESSAGES/messages.po b/lms/lmsweb/translations/he/LC_MESSAGES/messages.po index 5a54206c..7b05dcfe 100644 --- a/lms/lmsweb/translations/he/LC_MESSAGES/messages.po +++ b/lms/lmsweb/translations/he/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: 1.0\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-10-03 22:01+0300\n" +"POT-Creation-Date: 2021-10-10 18:00+0300\n" "PO-Revision-Date: 2021-09-29 11:30+0300\n" "Last-Translator: Or Ronai\n" "Language: he\n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.1\n" -#: lmsdb/models.py:879 +#: lmsdb/models.py:918 msgid "Fatal error" msgstr "כישלון חמור" @@ -47,31 +47,31 @@ msgstr "הבודק האוטומטי נכשל ב־ %(number)d דוגמאות בת msgid "Bro, did you check your code?" msgstr "אחי, בדקת את הקוד שלך?" -#: lmsweb/views.py:129 +#: lmsweb/views.py:133 msgid "Can not register now" msgstr "לא ניתן להירשם כעת" -#: lmsweb/views.py:146 +#: lmsweb/views.py:150 msgid "Registration successfully" msgstr "ההרשמה בוצעה בהצלחה" -#: lmsweb/views.py:169 +#: lmsweb/views.py:173 msgid "The confirmation link is expired, new link has been sent to your email" msgstr "קישור האימות פג תוקף, קישור חדש נשלח אל תיבת המייל שלך" -#: lmsweb/views.py:185 +#: lmsweb/views.py:189 msgid "Your user has been successfully confirmed, you can now login" msgstr "המשתמש שלך אומת בהצלחה, כעת אתה יכול להתחבר למערכת" -#: lmsweb/views.py:208 lmsweb/views.py:265 +#: lmsweb/views.py:212 lmsweb/views.py:293 msgid "Your password has successfully changed" msgstr "הסיסמה שלך שונתה בהצלחה" -#: lmsweb/views.py:224 +#: lmsweb/views.py:252 msgid "Password reset link has successfully sent" msgstr "קישור לאיפוס הסיסמה נשלח בהצלחה" -#: lmsweb/views.py:245 +#: lmsweb/views.py:273 msgid "Reset password link is expired" msgstr "קישור איפוס הסיסמה פג תוקף" @@ -93,6 +93,15 @@ msgstr "הסיסמה הנוכחית שהוזנה שגויה" msgid "Invalid email" msgstr "אימייל לא תקין" +#: lmsweb/forms/update_avatar.py:13 +msgid "No file added" +msgstr "לא צורף קובץ" + +#: lmsweb/forms/update_avatar.py:15 +#, python-format +msgid "File size is too big - %(size)dMB allowed" +msgstr "הקובץ גדול מידי - גודל הקובץ המקסימלי הוא עד %(size)dMB" + #: lmsweb/tools/validators.py:13 msgid "The username is already in use" msgstr "שם המשתמש כבר נמצא בשימוש" @@ -116,11 +125,11 @@ msgstr "%(checker)s הגיב לך על תרגיל \"%(subject)s\"." msgid "Your solution for the \"%(subject)s\" exercise has been checked." msgstr "הפתרון שלך לתרגיל \"%(subject)s\" נבדק." -#: models/users.py:28 +#: models/users.py:32 msgid "Invalid username or password" msgstr "שם המשתמש או הסיסמה שהוזנו לא תקינים" -#: models/users.py:31 +#: models/users.py:35 msgid "You have to confirm your registration with the link sent to your email" msgstr "עליך לאשר את מייל האימות" @@ -143,7 +152,7 @@ msgid "Exercise submission system for the Python Course" msgstr "מערכת הגשת תרגילים לקורס פייתון" #: templates/change-password.html:8 templates/change-password.html:17 -#: templates/user.html:19 +#: templates/user.html:22 msgid "Change Password" msgstr "שנה סיסמה" @@ -170,7 +179,7 @@ msgstr "אימות סיסמה" msgid "Exercises" msgstr "תרגילים" -#: templates/exercises.html:21 templates/view.html:113 +#: templates/exercises.html:21 templates/view.html:126 msgid "Comments for the solution" msgstr "הערות על התרגיל" @@ -203,7 +212,7 @@ msgid "Insert your username and password:" msgstr "הזינו את שם המשתמש והסיסמה שלכם:" #: templates/login.html:22 templates/login.html:24 templates/signup.html:16 -#: templates/user.html:11 +#: templates/user.html:14 msgid "Username" msgstr "שם משתמש" @@ -215,35 +224,35 @@ msgstr "שכחת את הסיסמה?" msgid "Register" msgstr "הירשם" -#: templates/navbar.html:21 +#: templates/navbar.html:25 msgid "Messages" msgstr "הודעות" -#: templates/navbar.html:37 +#: templates/navbar.html:41 msgid "Mark all as read" msgstr "סמן הכל כנקרא" -#: templates/navbar.html:45 +#: templates/navbar.html:49 msgid "Courses List" msgstr "רשימת הקורסים" -#: templates/navbar.html:65 +#: templates/navbar.html:69 msgid "Upload Exercises" msgstr "העלאת תרגילים" -#: templates/navbar.html:73 +#: templates/navbar.html:77 msgid "Exercises List" msgstr "רשימת התרגילים" -#: templates/navbar.html:81 +#: templates/navbar.html:85 msgid "Exercises Archive" msgstr "ארכיון התרגילים" -#: templates/navbar.html:91 +#: templates/navbar.html:95 msgid "Check Exercises" msgstr "בדוק תרגילים" -#: templates/navbar.html:98 +#: templates/navbar.html:102 msgid "Logout" msgstr "התנתקות" @@ -261,7 +270,7 @@ msgid "Insert your email for getting link to reset it:" msgstr "הזינו אימייל לצורך שליחת קישור לאיפוס הסיסמה:" #: templates/reset-password.html:14 templates/signup.html:15 -#: templates/user.html:12 +#: templates/user.html:15 msgid "Email Address" msgstr "כתובת אימייל" @@ -293,7 +302,7 @@ msgstr "חמ\"ל תרגילים" msgid "Name" msgstr "שם" -#: templates/status.html:13 templates/user.html:44 +#: templates/status.html:13 templates/user.html:48 msgid "Checked" msgstr "נבדק/ו" @@ -313,6 +322,19 @@ msgstr "מדד" msgid "Archive" msgstr "ארכיון" +#: templates/update-avatar.html:11 +msgid "Change Avatar" +msgstr "שנה תמונת פרופיל" + +#: templates/update-avatar.html:14 +msgid "Update" +msgstr "עדכן" + +#: templates/update-avatar.html:16 +#, fuzzy +msgid "Delete Avatar" +msgstr "מחק תמונת פרופיל" + #: templates/upload.html:7 msgid "Upload Notebooks" msgstr "העלאת מחברות" @@ -337,63 +359,67 @@ msgstr "נכשלו" msgid "User details" msgstr "פרטי משתמש" -#: templates/user.html:16 +#: templates/user.html:19 msgid "Actions" msgstr "פעולות" -#: templates/user.html:24 +#: templates/user.html:23 +msgid "Update Avatar" +msgstr "עדכן תמונת פרופיל" + +#: templates/user.html:28 msgid "Exercises Submitted" msgstr "תרגילים שהוגשו" -#: templates/user.html:29 +#: templates/user.html:33 msgid "Course name" msgstr "שם קורס" -#: templates/user.html:30 +#: templates/user.html:34 msgid "Exercise name" msgstr "שם תרגיל" -#: templates/user.html:31 +#: templates/user.html:35 msgid "Submission status" msgstr "מצב הגשה" -#: templates/user.html:32 +#: templates/user.html:36 msgid "Submission" msgstr "הגשה" -#: templates/user.html:33 +#: templates/user.html:37 msgid "Checker" msgstr "בודק" -#: templates/user.html:34 templates/view.html:21 templates/view.html:104 -msgid "Verbal note" +#: templates/user.html:38 templates/view.html:25 templates/view.html:112 +msgid "Assessment" msgstr "הערה מילולית" -#: templates/user.html:44 +#: templates/user.html:48 msgid "Submitted" msgstr "הוגש" -#: templates/user.html:44 +#: templates/user.html:48 msgid "Not submitted" msgstr "לא הוגש" -#: templates/user.html:56 +#: templates/user.html:60 msgid "Notes" msgstr "פתקיות" -#: templates/user.html:61 templates/user.html:63 +#: templates/user.html:65 templates/user.html:67 msgid "New Note" msgstr "פתקית חדשה" -#: templates/user.html:67 +#: templates/user.html:71 msgid "Related Exercise" msgstr "תרגיל משויך" -#: templates/user.html:76 +#: templates/user.html:80 msgid "Privacy Level" msgstr "רמת פרטיות" -#: templates/user.html:82 +#: templates/user.html:86 msgid "Add Note" msgstr "הוסף פתקית" @@ -425,43 +451,43 @@ msgstr "הפתרון שלך עדיין לא נבדק." msgid "It's important for us that all exercises will be checked by human eye." msgstr "חשוב לנו שכל תרגיל יעבור בדיקה של עין אנושית." -#: templates/view.html:18 +#: templates/view.html:19 msgid "Solver" msgstr "מגיש" -#: templates/view.html:24 +#: templates/view.html:32 msgid "Navigate in solution versions" msgstr "ניווט בגרסאות ההגשה" -#: templates/view.html:30 +#: templates/view.html:38 msgid "Current page" msgstr "סיסמה נוכחית" -#: templates/view.html:38 +#: templates/view.html:46 msgid "Finish Checking" msgstr "סיום בדיקה" -#: templates/view.html:78 +#: templates/view.html:86 msgid "Automatic Checking" msgstr "בדיקות אוטומטיות" -#: templates/view.html:85 +#: templates/view.html:93 msgid "Error" msgstr "כישלון חמור" -#: templates/view.html:90 +#: templates/view.html:98 msgid "Staff Error" msgstr "כישלון חמור" -#: templates/view.html:121 +#: templates/view.html:134 msgid "General comments" msgstr "הערות כלליות" -#: templates/view.html:129 +#: templates/view.html:142 msgid "Checker comments" msgstr "הערות בודק" -#: templates/view.html:139 +#: templates/view.html:152 msgid "Done Checking" msgstr "סיום בדיקה" diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py index 617ddc05..7c0f7c05 100644 --- a/lms/lmsweb/views.py +++ b/lms/lmsweb/views.py @@ -29,6 +29,7 @@ from lms.lmsweb.forms.change_password import ChangePasswordForm from lms.lmsweb.forms.register import RegisterForm from lms.lmsweb.forms.reset_password import RecoverPassForm, ResetPassForm +from lms.lmsweb.forms.update_avatar import UpdateAvatarForm from lms.lmsweb.git_service import GitService from lms.lmsweb.manifest import MANIFEST from lms.lmsweb.redirections import ( @@ -41,7 +42,9 @@ FileSizeError, ForbiddenPermission, LmsError, UnauthorizedError, UploadError, fail, ) -from lms.models.users import SERIALIZER, auth, retrieve_salt +from lms.models.users import ( + SERIALIZER, auth, delete_previous_avatar, retrieve_salt, save_avatar, +) from lms.utils.consts import RTL_LANGUAGES from lms.utils.files import ( get_language_name_by_extension, get_mime_type_by_extention, @@ -211,6 +214,30 @@ def change_password(): )) +@webapp.route('/avatar', methods=['GET', 'POST']) +@login_required +def avatar(): + form = UpdateAvatarForm() + if form.validate_on_submit(): + avatar_file = save_avatar(form.avatar.data) + if current_user.avatar: + delete_previous_avatar(current_user.avatar) + current_user.avatar = avatar_file + current_user.save() + return redirect(url_for('user', user_id=current_user.id)) + + return render_template('update-avatar.html', form=form) + + +@webapp.route('/avatar/delete') +@login_required +def delete_avatar(): + delete_previous_avatar(current_user.avatar) + current_user.avatar = None + current_user.save() + return redirect(url_for('user', user_id=current_user.id)) + + @webapp.route('/reset-password', methods=['GET', 'POST']) @limiter.limit(f'{LIMITS_PER_MINUTE}/minute;{LIMITS_PER_HOUR}/hour') def reset_password(): diff --git a/lms/models/users.py b/lms/models/users.py index afdb1de8..26af6b63 100644 --- a/lms/models/users.py +++ b/lms/models/users.py @@ -1,10 +1,14 @@ +import os import re +import secrets from flask_babel import gettext as _ # type: ignore from itsdangerous import URLSafeTimedSerializer +from PIL import Image +from werkzeug.datastructures import FileStorage from lms.lmsdb.models import User -from lms.lmsweb import config +from lms.lmsweb import avatars_path, config from lms.models.errors import ( ForbiddenPermission, UnauthorizedError, UnhashedPasswordError, ) @@ -38,3 +42,21 @@ def auth(username: str, password: str) -> User: def generate_user_token(user: User) -> str: return SERIALIZER.dumps(user.mail_address, salt=retrieve_salt(user)) + + +def save_avatar(form_picture: FileStorage) -> str: + random_hex = secrets.token_hex(nbytes=8) + _, extension = os.path.splitext(form_picture.filename) + avatar_filename = random_hex + extension + avatar_path = avatars_path / avatar_filename + + output_size = (125, 125) + image = Image.open(form_picture) + image.thumbnail(output_size) + image.save(avatar_path) + return avatar_filename + + +def delete_previous_avatar(avatar_name: str) -> None: + avatar_path = avatars_path / avatar_name + avatar_path.unlink() diff --git a/lms/static/my.css b/lms/static/my.css index 42cab66d..de350f00 100644 --- a/lms/static/my.css +++ b/lms/static/my.css @@ -51,7 +51,8 @@ a { #signup-container, #change-password-container, #reset-password-container, -#recover-password-container { +#recover-password-container, +#update-avatar-container { height: 100%; align-items: center; display: flex; @@ -63,7 +64,8 @@ a { #signup, #change-password, #reset-password, -#recover-password { +#recover-password, +#update-avatar { margin: auto; max-width: 420px; padding: 15px; @@ -89,6 +91,10 @@ a { margin: 0.5rem; } +.avatar-file { + width: auto; +} + .page { margin: 3rem 0; } diff --git a/lms/templates/navbar.html b/lms/templates/navbar.html index d2e49864..9bf739df 100644 --- a/lms/templates/navbar.html +++ b/lms/templates/navbar.html @@ -11,7 +11,11 @@