diff --git a/.gitignore b/.gitignore index c5b6edef..cddc507e 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ lms/lmsweb/config.py db.sqlite vim.session devops/rabbitmq.cookie + +# Avatars +uploads/avatars/* diff --git a/lms/lmsdb/bootstrap.py b/lms/lmsdb/bootstrap.py index e6dff390..45740f6e 100644 --- a/lms/lmsdb/bootstrap.py +++ b/lms/lmsdb/bootstrap.py @@ -322,6 +322,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 is_tables_exists(tables: Union[Model, Iterable[Model]]) -> bool: if not isinstance(tables, (tuple, list)): tables = (tables,) @@ -349,6 +355,7 @@ def main(): _api_keys_migration() _last_course_viewed_migration() _uuid_migration() + _avatar_migration() _add_user_course_constaint() diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index f28433e3..b052b843 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -180,6 +180,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, unique=True) def get_id(self): return str(self.uuid) diff --git a/lms/lmsweb/__init__.py b/lms/lmsweb/__init__.py index fe328733..404c0bb0 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 = project_dir.parent / 'uploads' / 'avatars' if debug.is_enabled(): @@ -39,6 +40,9 @@ shutil.copy(str(config_example_file), str(config_file)) config_migrator.migrate(config_file, config_example_file) +if not avatars_path.exists(): + avatars_path.mkdir(parents=True) + webapp.config.from_pyfile(str(config_file)) csrf = CSRFProtect(webapp) diff --git a/lms/lmsweb/forms/update_avatar.py b/lms/lmsweb/forms/update_avatar.py new file mode 100644 index 00000000..a06ea59c --- /dev/null +++ b/lms/lmsweb/forms/update_avatar.py @@ -0,0 +1,22 @@ +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.consts import MB_CONVERSION +from lms.utils.files import ALLOWED_IMAGES_EXTENSIONS + + +class UpdateAvatarForm(FlaskForm): + avatar = FileField( + 'Avatar', validators=[ + FileAllowed(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 // MB_CONVERSION, + ), + ), + ], + ) diff --git a/lms/lmsweb/translations/he/LC_MESSAGES/messages.po b/lms/lmsweb/translations/he/LC_MESSAGES/messages.po index 20cd5355..f25ce4dc 100644 --- a/lms/lmsweb/translations/he/LC_MESSAGES/messages.po +++ b/lms/lmsweb/translations/he/LC_MESSAGES/messages.po @@ -20,7 +20,7 @@ msgstr "" "X-Generator: Poedit 3.0\n" "X-Poedit-Bookmarks: -1,-1,-1,-1,0,-1,-1,-1,-1,-1\n" -#: lmsdb/models.py:921 +#: lmsdb/models.py:926 msgid "Fatal error" msgstr "כישלון חמור" @@ -49,31 +49,31 @@ msgstr "הבודק האוטומטי נכשל ב־ %(number)d דוגמאות בת msgid "Bro, did you check your code?" msgstr "אחי, בדקת את הקוד שלך?" -#: lmsweb/views.py:130 +#: lmsweb/views.py:137 msgid "Can not register now" msgstr "לא ניתן להירשם כעת" -#: lmsweb/views.py:147 +#: lmsweb/views.py:154 msgid "Registration successfully" msgstr "ההרשמה בוצעה בהצלחה" -#: lmsweb/views.py:170 +#: lmsweb/views.py:177 msgid "The confirmation link is expired, new link has been sent to your email" msgstr "קישור האימות פג תוקף, קישור חדש נשלח אל תיבת המייל שלך" -#: lmsweb/views.py:186 +#: lmsweb/views.py:193 msgid "Your user has been successfully confirmed, you can now login" msgstr "המשתמש שלך אומת בהצלחה, כעת אתה יכול להתחבר למערכת" -#: lmsweb/views.py:209 lmsweb/views.py:266 +#: lmsweb/views.py:216 lmsweb/views.py:309 msgid "Your password has successfully changed" msgstr "הסיסמה שלך שונתה בהצלחה" -#: lmsweb/views.py:225 +#: lmsweb/views.py:268 msgid "Password reset link has successfully sent" msgstr "קישור לאיפוס הסיסמה נשלח בהצלחה" -#: lmsweb/views.py:246 +#: lmsweb/views.py:289 msgid "Reset password link is expired" msgstr "הקישור לאיפוס הסיסמה פג תוקף" @@ -95,6 +95,15 @@ msgstr "הסיסמה הנוכחית שהוזנה שגויה" msgid "Invalid email" msgstr "כתובת הדואר האלקטרוני שהוזנה אינה תקינה" +#: lmsweb/forms/update_avatar.py:14 +msgid "No file added" +msgstr "לא צורף קובץ" + +#: lmsweb/forms/update_avatar.py:16 +#, 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 "שם המשתמש הזה כבר נמצא בשימוש" @@ -118,15 +127,19 @@ msgstr "%(checker)s הגיב לך על תרגיל \"%(subject)s\"." msgid "Your solution for the \"%(subject)s\" exercise has been checked." msgstr "הפתרון שלך לתרגיל \"%(subject)s\" נבדק." -#: models/users.py:29 +#: models/users.py:35 msgid "Invalid username or password" msgstr "שם המשתמש או הסיסמה שהוזנו לא תקינים" -#: models/users.py:32 +#: models/users.py:38 msgid "You have to confirm your registration with the link sent to your email" msgstr "עליך לאשר את מייל האימות" -#: models/users.py:50 +#: models/users.py:53 +msgid "Empty filename isn't allowed" +msgstr "לא ניתן להעלות קובץ ללא שם" + +#: models/users.py:88 #, python-format msgid "You are already registered to %(course_name)s course." msgstr "אתה כבר רשום לקורס %(course_name)s." @@ -150,7 +163,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:24 msgid "Change Password" msgstr "שנה סיסמה" @@ -210,7 +223,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:15 msgid "Username" msgstr "שם משתמש" @@ -222,35 +235,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 "התנתקות" @@ -272,7 +285,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:16 msgid "Email Address" msgstr "כתובת אימייל" @@ -304,7 +317,7 @@ msgstr "חמ\"ל תרגילים" msgid "Name" msgstr "שם" -#: templates/status.html:13 templates/user.html:47 +#: templates/status.html:13 templates/user.html:54 msgid "Checked" msgstr "נבדקו" @@ -324,6 +337,18 @@ 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 +msgid "Delete Avatar" +msgstr "מחק תמונת פרופיל" + #: templates/upload.html:7 msgid "Upload Notebooks" msgstr "העלאת מחברות" @@ -348,67 +373,71 @@ msgstr "נכשלו" msgid "User details" msgstr "פרטי משתמש" -#: templates/user.html:16 +#: templates/user.html:21 msgid "Actions" msgstr "פעולות" -#: templates/user.html:21 +#: templates/user.html:25 +msgid "Update Avatar" +msgstr "עדכן תמונת פרופיל" + +#: templates/user.html:27 msgid "Join Courses" msgstr "הירשם לקורסים" -#: templates/user.html:27 +#: templates/user.html:34 msgid "Exercises Submitted" msgstr "תרגילים שהוגשו" -#: templates/user.html:32 +#: templates/user.html:39 msgid "Course name" msgstr "שם קורס" -#: templates/user.html:33 +#: templates/user.html:40 msgid "Exercise name" msgstr "שם תרגיל" -#: templates/user.html:34 +#: templates/user.html:41 msgid "Submission status" msgstr "מצב הגשה" -#: templates/user.html:35 +#: templates/user.html:42 msgid "Submission" msgstr "הגשה" -#: templates/user.html:36 +#: templates/user.html:43 msgid "Checker" msgstr "בודק" -#: templates/user.html:37 templates/view.html:25 templates/view.html:112 +#: templates/user.html:44 templates/view.html:25 templates/view.html:112 msgid "Assessment" msgstr "הערכה מילולית" -#: templates/user.html:47 +#: templates/user.html:54 msgid "Submitted" msgstr "הוגש" -#: templates/user.html:47 +#: templates/user.html:54 msgid "Not submitted" msgstr "לא הוגש" -#: templates/user.html:59 +#: templates/user.html:66 msgid "Notes" msgstr "פתקיות" -#: templates/user.html:64 templates/user.html:66 +#: templates/user.html:71 templates/user.html:73 msgid "New Note" msgstr "פתקית חדשה" -#: templates/user.html:70 +#: templates/user.html:77 msgid "Related Exercise" msgstr "עבור תרגיל" -#: templates/user.html:79 +#: templates/user.html:86 msgid "Privacy Level" msgstr "רמת פרטיות" -#: templates/user.html:85 +#: templates/user.html:92 msgid "Add Note" msgstr "הוסף פתקית" diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py index a7b91444..b6b602a3 100644 --- a/lms/lmsweb/views.py +++ b/lms/lmsweb/views.py @@ -1,3 +1,4 @@ +import asyncio from typing import Any, Callable, Optional import arrow # type: ignore @@ -18,7 +19,9 @@ ALL_MODELS, Comment, Course, Note, Role, RoleOptions, SharedSolution, Solution, SolutionFile, User, UserCourse, database, ) -from lms.lmsweb import babel, http_basic_auth, limiter, routes, webapp +from lms.lmsweb import ( + avatars_path, babel, http_basic_auth, limiter, routes, webapp, +) from lms.lmsweb.admin import ( AdminModelView, SPECIAL_MAPPING, admin, managers_only, ) @@ -29,6 +32,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 ( @@ -39,10 +43,13 @@ ) from lms.models.errors import ( AlreadyExists, FileSizeError, ForbiddenPermission, LmsError, - UnauthorizedError, UploadError, fail, + UnauthorizedError, UnprocessableRequest, UploadError, fail, +) +from lms.models.users import ( + SERIALIZER, auth, avatars_handler, create_avatar_filename, + delete_previous_avatar, retrieve_salt, ) -from lms.models.users import SERIALIZER, auth, retrieve_salt -from lms.utils.consts import RTL_LANGUAGES +from lms.utils.consts import MB_CONVERSION, RTL_LANGUAGES from lms.utils.files import ( get_language_name_by_extension, get_mime_type_by_extention, ) @@ -211,6 +218,42 @@ def change_password(): )) +@webapp.route('/avatar/') +@login_required +def avatar(filename): + return send_from_directory(avatars_path, filename) + + +@webapp.route('/update-avatar', methods=['GET', 'POST']) +@login_required +def update_avatar(): + form = UpdateAvatarForm() + if form.validate_on_submit(): + try: + filename = create_avatar_filename(form.avatar.data) + except UnprocessableRequest as e: + error_message, status_code = e.args + return fail(status_code, error_message) + + asyncio.run(avatars_handler(form.avatar.data, filename)) + if current_user.avatar: + delete_previous_avatar(current_user.avatar) + current_user.avatar = filename + 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(): @@ -561,7 +604,8 @@ def upload_page(course_id: int): return fail(404, 'User not found.') if request.content_length > MAX_UPLOAD_SIZE: return fail( - 413, f'File is too big. {MAX_UPLOAD_SIZE // 1000000}MB allowed.', + 413, + f'File is too big. {MAX_UPLOAD_SIZE // MB_CONVERSION}MB allowed.', ) file: Optional[FileStorage] = request.files.get('file') diff --git a/lms/models/users.py b/lms/models/users.py index e4d9848e..1fb3cf35 100644 --- a/lms/models/users.py +++ b/lms/models/users.py @@ -1,13 +1,19 @@ +import asyncio +from functools import partial +import os import re +from uuid import uuid4 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 Course, User, UserCourse -from lms.lmsweb import config +from lms.lmsweb import avatars_path, config from lms.models.errors import ( AlreadyExists, ForbiddenPermission, UnauthorizedError, - UnhashedPasswordError, + UnhashedPasswordError, UnprocessableRequest, ) @@ -41,6 +47,38 @@ def generate_user_token(user: User) -> str: return SERIALIZER.dumps(user.mail_address, salt=retrieve_salt(user)) +def create_avatar_filename(form_picture: FileStorage) -> str: + __, extension = os.path.splitext(form_picture.filename) + if not extension: + raise UnprocessableRequest(_("Empty filename isn't allowed"), 422) + filename = str(uuid4()) + return filename + extension + + +def save_avatar(form_picture: FileStorage, filename: str) -> None: + avatar_path = avatars_path / filename + output_size = (125, 125) + image = Image.open(form_picture) + image.thumbnail(output_size) + image.save(avatar_path) + + +async def async_avatars_proccess(form_picture: FileStorage, filename: str): + loop = asyncio.get_running_loop() + await loop.run_in_executor( + None, partial(save_avatar, form_picture, filename), + ) + + +async def avatars_handler(form_picture: FileStorage, filename: str): + asyncio.create_task(async_avatars_proccess(form_picture, filename)) + + +def delete_previous_avatar(avatar_name: str) -> None: + avatar_path = avatars_path / avatar_name + avatar_path.unlink(missing_ok=True) + + def join_public_course(course: Course, user: User) -> None: __, created = UserCourse.get_or_create(**{ UserCourse.user.name: user, UserCourse.course.name: course, diff --git a/lms/static/my.css b/lms/static/my.css index 553a0d49..f7efb45c 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 603b2947..c445b9da 100644 --- a/lms/templates/navbar.html +++ b/lms/templates/navbar.html @@ -11,7 +11,11 @@