Skip to content

Commit

Permalink
feat: Allow users to set custom profile pictures (#2405)
Browse files Browse the repository at this point in the history
Enhancing the functionality of the MSColab server to allow users to upload small images which would replace generic initials as identifiers during login. This feature personalizes user accounts and significantly enhances the visual aspect of user identification and interaction within the MSColab platform.
  • Loading branch information
workaryangupta authored Jul 8, 2024
1 parent cabee4e commit cfdaf14
Show file tree
Hide file tree
Showing 12 changed files with 347 additions and 127 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ in alphabetic order by first name
- Andreas Hilboll <[email protected]>
- Anveshan Lal <[email protected]>
- Aravind Murali <[email protected]>
- Aryan Gupta <[email protected]>
- Christian Rolf <[email protected]>
- Debajyoti Dasgupta <[email protected]>
- Hrithik Kumar Verma <[email protected]>
Expand Down
25 changes: 0 additions & 25 deletions mslib/mscolab/chat_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@
limitations under the License.
"""
import datetime
import os
import time

import fs
from werkzeug.utils import secure_filename

from mslib.mscolab.conf import mscolab_settings
from mslib.mscolab.models import db, Message, MessageType
Expand Down Expand Up @@ -96,24 +92,3 @@ def delete_message(self, message_id):
upload_dir.remove(fs.path.join(str(message.op_id), file_name))
db.session.delete(message)
db.session.commit()

def add_attachment(self, op_id, upload_folder, file, file_token):
with fs.open_fs('/') as home_fs:
file_dir = fs.path.join(upload_folder, str(op_id))
if '\\' not in file_dir:
if not home_fs.exists(file_dir):
home_fs.makedirs(file_dir)
else:
file_dir = file_dir.replace('\\', '/')
if not os.path.exists(file_dir):
os.makedirs(file_dir)
file_name, file_ext = file.filename.rsplit('.', 1)
file_name = f'{file_name}-{time.strftime("%Y%m%dT%H%M%S")}-{file_token}.{file_ext}'
file_name = secure_filename(file_name)
file_path = fs.path.join(file_dir, file_name)
file.save(file_path)
static_dir = fs.path.basename(upload_folder)
static_dir = static_dir.replace('\\', '/')
static_file_path = os.path.join(static_dir, str(op_id), file_name)
if os.path.exists(file_path):
return static_file_path
2 changes: 1 addition & 1 deletion mslib/mscolab/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class default_mscolab_settings:

# mscolab file upload settings
UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads')
MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB
MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MiB

# used to generate and parse tokens
SECRET_KEY = secrets.token_urlsafe(16)
Expand Down
76 changes: 76 additions & 0 deletions mslib/mscolab/file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
import secrets
import time
import datetime
import fs
import difflib
import logging
import git
import threading
import mimetypes
from werkzeug.utils import secure_filename
from sqlalchemy.exc import IntegrityError
from mslib.mscolab.models import db, Operation, Permission, User, Change, Message
from mslib.mscolab.conf import mscolab_settings
Expand Down Expand Up @@ -225,6 +230,9 @@ def modify_user(self, user, attribute=None, value=None, action=None):
elif action == "delete":
user_query = User.query.filter_by(id=user.id).first()
if user_query is not None:
# Delete profile image if it exists
if user.profile_image_path:
self.delete_user_profile_image(user.profile_image_path)
db.session.delete(user)
db.session.commit()
user_query = User.query.filter_by(id=user.id).first()
Expand All @@ -250,6 +258,74 @@ def modify_user(self, user, attribute=None, value=None, action=None):
db.session.commit()
return True

def delete_user_profile_image(self, image_to_be_deleted):
'''
This function is called when deleting account or updating the profile picture
'''
upload_folder = mscolab_settings.UPLOAD_FOLDER
if sys.platform.startswith('win'):
upload_folder = upload_folder.replace('\\', '/')

with fs.open_fs(upload_folder) as profile_fs:
if profile_fs.exists(image_to_be_deleted):
profile_fs.remove(image_to_be_deleted)
logging.debug(f"Successfully deleted image: {image_to_be_deleted}")

def upload_file(self, file, subfolder=None, identifier=None, include_prefix=False):
"""
Generic function to save files securely in any specified directory with unique filename
and return the relative file path.
"""
upload_folder = mscolab_settings.UPLOAD_FOLDER
if sys.platform.startswith('win'):
upload_folder = upload_folder.replace('\\', '/')

subfolder_path = fs.path.join(upload_folder, str(subfolder) if subfolder else "")
with fs.open_fs(subfolder_path, create=True) as _fs:
# Creating unique and secure filename
file_name, _ = file.filename.rsplit('.', 1)
mime_type, _ = mimetypes.guess_type(file.filename)
file_ext = mimetypes.guess_extension(mime_type) if mime_type else '.unknown'
token = secrets.token_urlsafe()
timestamp = time.strftime("%Y%m%dT%H%M%S")

if identifier:
file_name = f'{identifier}-{timestamp}-{token}{file_ext}'
else:
file_name = f'{file_name}-{timestamp}-{token}{file_ext}'
file_name = secure_filename(file_name)

# Saving the file
with _fs.open(file_name, mode="wb") as f:
file.save(f)

# Relative File path
if include_prefix: # ToDo: add a namespace for the chat attachments, similar as for profile images
static_dir = fs.path.basename(upload_folder)
static_file_path = fs.path.join(static_dir, str(subfolder), file_name)
else:
static_file_path = fs.path.relativefrom(upload_folder, fs.path.join(subfolder_path, file_name))

logging.debug(f'Relative Path: {static_file_path}')
return static_file_path

def save_user_profile_image(self, user_id, image_file):
"""
Save the user's profile image path to the database.
"""
relative_file_path = self.upload_file(image_file, subfolder='profile', identifier=user_id)

user = User.query.get(user_id)
if user:
if user.profile_image_path:
# Delete the previous image
self.delete_user_profile_image(user.profile_image_path)
user.profile_image_path = relative_file_path
db.session.commit()
return True, "Image uploaded successfully"
else:
return False, "User not found"

def update_operation(self, op_id, attribute, value, user):
"""
op_id: operation id
Expand Down
5 changes: 4 additions & 1 deletion mslib/mscolab/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,19 @@ class User(db.Model):
username = db.Column(db.String(255))
emailid = db.Column(db.String(255), unique=True)
password = db.Column(db.String(255))
profile_image_path = db.Column(db.String(255), nullable=True) # relative path
registered_on = db.Column(AwareDateTime, nullable=False)
confirmed = db.Column(db.Boolean, nullable=False, default=False)
confirmed_on = db.Column(AwareDateTime, nullable=True)
permissions = db.relationship('Permission', cascade='all,delete,delete-orphan', backref='user')
authentication_backend = db.Column(db.String(255), nullable=False, default='local')

def __init__(self, emailid, username, password, confirmed=False, confirmed_on=None, authentication_backend='local'):
def __init__(self, emailid, username, password, profile_image_path=None, confirmed=False,
confirmed_on=None, authentication_backend='local'):
self.username = username
self.emailid = emailid
self.hash_password(password)
self.profile_image_path = profile_image_path
self.registered_on = datetime.datetime.now(tz=datetime.timezone.utc)
self.confirmed = confirmed
self.confirmed_on = confirmed_on
Expand Down
40 changes: 38 additions & 2 deletions mslib/mscolab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
See the License for the specific language governing permissions and
limitations under the License.
"""
import fs
import sys
import functools
import json
import logging
Expand Down Expand Up @@ -351,6 +353,41 @@ def get_user():
return json.dumps({'user': {'id': g.user.id, 'username': g.user.username}})


@APP.route('/upload_profile_image', methods=["POST"])
@verify_user
def upload_profile_image():
user_id = request.form['user_id']
file = request.files['image']
if not file:
return jsonify({'message': 'No file provided or invalid file type'}), 400
if not file.mimetype.startswith('image/'):
return jsonify({'message': 'Invalid file type'}), 400
if file.content_length > mscolab_settings.MAX_UPLOAD_SIZE:
return jsonify({'message': 'File too large'}), 413

success, message = fm.save_user_profile_image(user_id, file)
if success:
return jsonify({'message': message}), 200
else:
return jsonify({'message': message}), 400


@APP.route('/fetch_profile_image', methods=["GET"])
@verify_user
def fetch_profile_image():
user_id = request.form['user_id']
user = User.query.get(user_id)
if user and user.profile_image_path:
base_path = mscolab_settings.UPLOAD_FOLDER
if sys.platform.startswith('win'):
base_path = base_path.replace('\\', '/')
filename = user.profile_image_path
with fs.open_fs(base_path) as _fs:
return send_from_directory(_fs.getsyspath(""), filename)
else:
abort(404)


@APP.route("/delete_own_account", methods=["POST"])
@verify_user
def delete_own_account():
Expand Down Expand Up @@ -381,15 +418,14 @@ def message_attachment():
user = g.user
op_id = request.form.get("op_id", None)
if fm.is_member(user.id, op_id):
file_token = secrets.token_urlsafe(16)
file = request.files['file']
message_type = MessageType(int(request.form.get("message_type")))
user = g.user
users = fm.fetch_users_without_permission(int(op_id), user.id)
if users is False:
return jsonify({"success": False, "message": "Could not send message. No file uploaded."})
if file is not None:
static_file_path = cm.add_attachment(op_id, APP.config['UPLOAD_FOLDER'], file, file_token)
static_file_path = fm.upload_file(file, subfolder=str(op_id), include_prefix=True)
if static_file_path is not None:
new_message = cm.add_message(user, static_file_path, op_id, message_type)
new_message_dict = get_message_dict(new_message)
Expand Down
91 changes: 86 additions & 5 deletions mslib/msui/mscolab.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
limitations under the License.
"""
import os
import io
import sys
import json
import hashlib
Expand All @@ -39,11 +40,12 @@
import requests
import re
import webbrowser
import mimetypes
import urllib.request
from urllib.parse import urljoin

from fs import open_fs
from PIL import Image
from PIL import Image, UnidentifiedImageError
from keyring.errors import NoKeyringError, PasswordSetError, InitError

from mslib.msui import flighttrack as ft
Expand All @@ -54,6 +56,8 @@

import PyQt5
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtWidgets import QFileDialog, QMessageBox
from PyQt5.QtGui import QPixmap

from mslib.utils.auth import get_password_from_keyring, save_password_to_keyring
from mslib.utils.verify_user_token import verify_user_token
Expand Down Expand Up @@ -675,7 +679,7 @@ def after_login(self, emailid, url, r):
self.ui.usernameLabel.setText(f"{self.user['username']}")
self.ui.usernameLabel.show()
self.ui.userOptionsTb.show()
self.fetch_gravatar()
self.fetch_profile_image()
# enable add operation menu action
self.ui.actionAddOperation.setEnabled(True)

Expand All @@ -693,7 +697,35 @@ def after_login(self, emailid, url, r):

self.signal_login_mscolab.emit(self.mscolab_server_url, self.token)

def fetch_gravatar(self, refresh=False):
def set_profile_pixmap(self, img_data):
pixmap = QPixmap()
pixmap.loadFromData(img_data)
resized_pixmap = pixmap.scaled(64, 64)

# ToDo : verify by a test if the condition can be simplified
if (hasattr(self, 'profile_dialog') and self.profile_dialog is not None and
hasattr(self.profile_dialog, 'gravatarLabel') and self.profile_dialog.gravatarLabel is not None):
self.profile_dialog.gravatarLabel.setPixmap(resized_pixmap)

icon = QtGui.QIcon()
icon.addPixmap(resized_pixmap, QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.ui.userOptionsTb.setIcon(icon)

def fetch_profile_image(self, refresh=False):
# Display custom profile picture if exists
url = urljoin(self.mscolab_server_url, 'fetch_profile_image')
data = {
"user_id": str(self.user["id"]),
"token": self.token
}
response = requests.get(url, data=data)
if response.status_code == 200:
self.set_profile_pixmap(response.content)
else:
self.fetch_gravatar(refresh)

def fetch_gravatar(self, refresh):
# Display default gravatar if custom profile image is not set
email_hash = hashlib.md5(bytes(self.email.encode('utf-8')).lower()).hexdigest()
email_in_config = self.email in config_loader(dataset="gravatar_ids")
gravatar_img_path = fs.path.join(constants.GRAVATAR_DIR_PATH, f"{email_hash}.png")
Expand Down Expand Up @@ -798,16 +830,61 @@ def on_context_menu(point):
self.profile_dialog.mscolabURLLabel_2.setText(self.mscolab_server_url)
self.profile_dialog.emailLabel_2.setText(self.email)
self.profile_dialog.deleteAccountBtn.clicked.connect(self.delete_account)
self.profile_dialog.uploadImageBtn.clicked.connect(self.upload_image)

# add context menu for right click on image
self.gravatar_menu = QtWidgets.QMenu()
self.gravatar_menu.addAction('Fetch Gravatar', lambda: self.fetch_gravatar(refresh=True))
self.gravatar_menu.addAction('Fetch Gravatar', lambda: self.fetch_profile_image(refresh=True))
self.gravatar_menu.addAction('Remove Gravatar', lambda: self.remove_gravatar())
self.profile_dialog.gravatarLabel.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.profile_dialog.gravatarLabel.customContextMenuRequested.connect(on_context_menu)

self.prof_diag.show()
self.fetch_gravatar()
self.fetch_profile_image()

def upload_image(self):
file_name, _ = QFileDialog.getOpenFileName(self.prof_diag, "Open Image", "",
"Image (*.png *.gif *.jpg *.jpeg *.bpm)")
if file_name:
# Determine the image format
mime_type, _ = mimetypes.guess_type(file_name)
file_format = mime_type.split('/')[1].upper()
try:
# Resize the image and set profile image pixmap
image = Image.open(file_name)
image = image.resize((64, 64), Image.ANTIALIAS)
img_byte_arr = io.BytesIO()
image.save(img_byte_arr, format=file_format)
img_byte_arr.seek(0)
self.set_profile_pixmap(img_byte_arr.getvalue())

# Prepare the file data for upload
try:
img_byte_arr.seek(0) # Reset buffer position
files = {'image': (os.path.basename(file_name), img_byte_arr, mime_type)}
data = {
"user_id": str(self.user["id"]),
"token": self.token
}
url = urljoin(self.mscolab_server_url, 'upload_profile_image')
response = requests.post(url, files=files, data=data)

# Check response status
if response.status_code == 200:
QMessageBox.information(self.prof_diag, "Success", "Image uploaded successfully")
self.fetch_profile_image(refresh=True)
else:
QMessageBox.critical(self.prof_diag, "Error", f"Failed to upload image: {response.text}")

except requests.exceptions.RequestException as e:
QMessageBox.critical(self.prof_diag, "Error", f"Error occurred: {e}")

except UnidentifiedImageError as e:
QMessageBox.critical(self.prof_diag, "Error",
f'Cannot identify image file. Please check the file format. Error : {e}')
except OSError as e:
QMessageBox.critical(self.prof_diag, "Error",
f'Cannot identify image file. Please check the file format. Error: {e}')

def delete_account(self):
# ToDo rename to delete_own_account
Expand Down Expand Up @@ -2084,6 +2161,10 @@ def logout(self):

self.operation_archive_browser.hide()

if hasattr(self, 'profile_dialog'):
del self.profile_dialog
self.profile_dialog = None

# activate first local flighttrack after logging out
self.ui.listFlightTracks.setCurrentRow(0)
self.ui.activate_selected_flight_track()
Expand Down
Loading

0 comments on commit cfdaf14

Please sign in to comment.