Skip to content

Commit

Permalink
Merge pull request #376 from kikkomep/CU-2zgx6fv_Merge-accounts-feature
Browse files Browse the repository at this point in the history
feat: merge accounts feature
  • Loading branch information
kikkomep authored Jan 18, 2024
2 parents 91d6fb1 + bb5102c commit 8fc9bf7
Show file tree
Hide file tree
Showing 7 changed files with 254 additions and 73 deletions.
44 changes: 23 additions & 21 deletions lifemonitor/api/models/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,11 @@

import logging
from typing import List, Optional, Set, Union
from uuid import UUID

from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import aliased
from sqlalchemy.orm.collections import (MappedCollection,
attribute_mapped_collection,
collection)
from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.sql.expression import true

Expand Down Expand Up @@ -183,22 +182,6 @@ def get_hosted_workflows_by_uri(cls, hosting_service: HostingService, uri: str,
return query.all()


class WorkflowVersionCollection(MappedCollection):

def __init__(self) -> None:
super().__init__(lambda wv: wv.workflow.uuid)

@collection.internally_instrumented
def __setitem__(self, key, value, _sa_initiator=None):
current_value = self.get(key, set())
current_value.add(value)
super(WorkflowVersionCollection, self).__setitem__(key, current_value, _sa_initiator)

@collection.internally_instrumented
def __delitem__(self, key, _sa_initiator=None):
super(WorkflowVersionCollection, self).__delitem__(key, _sa_initiator)


class WorkflowVersion(ROCrate):
id = db.Column(db.Integer, db.ForeignKey(ROCrate.id), primary_key=True)
submitter_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=True)
Expand All @@ -211,8 +194,7 @@ class WorkflowVersion(ROCrate):
test_suites = db.relationship("TestSuite", back_populates="workflow_version",
cascade="all, delete")
submitter = db.relationship("User", uselist=False,
backref=db.backref("workflows", cascade="all, delete-orphan",
collection_class=WorkflowVersionCollection))
backref=db.backref("workflow_versions", cascade="all, delete-orphan"))

__mapper_args__ = {
'polymorphic_identity': 'workflow_version'
Expand Down Expand Up @@ -439,3 +421,23 @@ def get_hosted_workflow_versions_by_uri(cls, hosting_service: HostingService, ur
.join(WorkflowVersion, WorkflowVersion.hosting_service_id == hosting_service.id)\
.filter(HostingService.uuid == lm_utils.uuid_param(hosting_service.uuid))\
.filter(WorkflowVersion.uri == uri).all()


def __get_user_workflows_map__(user: User) -> dict[UUID, Set[WorkflowVersion]]:
'''
utility function to get the workflows of a user from the list of workflow versions
submitted by the user
'''
workflows = {}
for v in user.workflow_versions:
w_set = workflows.get(v.workflow.uuid, None)
if w_set is None:
workflows[v.workflow.uuid] = {v}
else:
w_set.add(v)

return workflows


# augmentg the User class with the "workflow" property
User.workflows = property(lambda self: __get_user_workflows_map__(self))
61 changes: 39 additions & 22 deletions lifemonitor/auth/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from .forms import (EmailForm, LoginForm, NotificationsForm, Oauth2ClientForm,
RegisterForm, SetPasswordForm)
from .models import db
from .oauth2.client.services import (get_current_user_identity, get_providers,
from .oauth2.client.services import (get_current_user_identity, get_providers, merge_users,
save_current_user_identity)
from .oauth2.server.services import server
from .services import (authorized, current_registry, current_user,
Expand Down Expand Up @@ -515,29 +515,46 @@ def disable_registry_sync():
@blueprint.route("/merge", methods=("GET", "POST"))
@login_required
def merge():
# get the username and provider from the request
username = request.args.get("username")
provider = request.args.get("provider")
flash(f"Your <b>{provider}</b> identity is already linked to the username "
f"<b>{username}</b> and cannot be merged to <b>{current_user.username}</b>",
category="warning")
return redirect(url_for('auth.profile'))
# form = LoginForm(data={
# "username": username,
# "provider": provider})
# if form.validate_on_submit():
# user = form.get_user()
# if user:
# if user != current_user:
# merge_users(current_user, user, request.args.get("provider"))
# flash(
# "User {username} has been merged into your account".format(
# username=user.username
# )
# )
# return redirect(url_for("auth.index"))
# else:
# form.username.errors.append("Cannot merge with yourself")
# return render_template("auth/merge.j2", form=form)

# Uncomment to disable the merge feature
# flash(f"Your <b>{p>rovider}</b> identity is already linked to the username "
# f"<b>{username}</b> and cannot be merged to <b>{current_user.username}</b>",
# category="warning")
# return redirect(url_for('auth.profile'))

# Check the authenticity of the identity before merging
form = LoginForm(data={
"username": username,
"provider": provider})
if form.validate_on_submit():
user = form.get_user()
if user:
# check if the user is the same as the current user
if user == current_user:
flash("Cannot merge with yourself", category="warning")
form.username.errors.append("Cannot merge with yourself")
else:
# merge the users
resulting_user = merge_users(current_user, user, request.args.get("provider"))
logger.debug("User obtained by the merging process: %r", resulting_user)
logout_user()
# login the resulting user
login_user(resulting_user)
# redirect to the profile page with a flash message
flash(
"User {username} has been merged into your account".format(
username=resulting_user.username
), category="success"
)
return profile()
# render the merge page
return render_template("auth/merge.j2", form=form, identity={
"username": username,
"provider": provider
})


@blueprint.route("/create_apikey", methods=("POST",))
Expand Down
5 changes: 4 additions & 1 deletion lifemonitor/auth/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ def get_user(self):
if not user:
self.username.errors.append("Username not found")
return None
if not user.verify_password(self.password.data):
if not user.has_password:
self.password.errors.append("The user has no password set")
return None
if not self.password.data or not user.verify_password(self.password.data):
self.password.errors.append("Invalid password")
return None
return user
Expand Down
4 changes: 3 additions & 1 deletion lifemonitor/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def get_authorization(self, resource: Resource):
@property
def current_identity(self):
from .services import current_registry, current_user
if not current_user.is_anonymous:
if not current_user.is_anonymous and current_user.id == self.id:
return self.oauth_identity
if current_registry:
for p, i in self.oauth_identity.items():
Expand All @@ -130,6 +130,8 @@ def has_password(self):
return bool(self.password_hash)

def verify_password(self, password):
if not self.password_hash:
return False
return check_password_hash(self.password_hash, password)

def _generate_random_code(self, chars=string.ascii_uppercase + string.digits):
Expand Down
94 changes: 80 additions & 14 deletions lifemonitor/auth/oauth2/client/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import logging

from flask import current_app, session

from lifemonitor import exceptions
from lifemonitor.db import db, db_initialized

Expand Down Expand Up @@ -78,21 +79,86 @@ def config_oauth2_registry(app, providers=None):
logger.debug("OAuth2 registry configured!")


def merge_users(merge_from: User, merge_into: User, provider: str):
def merge_users(merge_from: User, merge_into: User, provider: str = None):
assert merge_into != merge_from
logger.debug("Trying to merge %r, %r, %r", merge_into, merge_from, provider)
for identity in list(merge_from.oauth_identity.values()):
identity.user = merge_into
db.session.add(identity)
# TODO: Move all oauth clients to the new user
for client in list(merge_from.clients):
client.user = merge_into
db.session.add(client)
# TODO: Check for other links to move to the new user
# e.g., tokens, workflows, tests, ....
db.session.delete(merge_from)
db.session.commit()
return merge_into
try:
# start a new transaction
with db.session.no_autoflush:
logger.debug("Trying to merge user %r into %r (provider: %r)", merge_from, merge_into, provider)
# make a copy of the workflow versions submitted by the "merge_from" user
workflow_versions = list(merge_from.workflow_versions)
# update the submitter of the workflow versions
for v in workflow_versions:
v.submitter = merge_into
db.session.add(v)
# update suites submitted by the "merge_from" user
for s in v.test_suites:
if s.submitter == merge_from:
s.submitter = merge_into
db.session.add(s)
# update test instances submitted by the "merge_from" user
for i in s.test_instances:
if i.submitter == merge_from:
i.submitter = merge_into
db.session.add(i)

# update all the remaining permissions
for permission in list(merge_from.permissions):
permission.user = merge_into
db.session.add(permission)

# move all the authorizations granted to the "merge_from" user to the "merge_into" user
for auth in list(merge_from.authorizations):
auth.user = merge_into
db.session.add(auth)

# move all the notification of the user "merge_from" to the user "merge_into"
merge_into_notification_ids = [un.notification.id for un in merge_into.notifications]
for user_notification in list(merge_from.notifications):
if user_notification.notification.id not in merge_into_notification_ids:
user_notification.user = merge_into
db.session.add(user_notification)

# move all the subscriptions of the user "merge_from" to the user "merge_into"
for subscription in list(merge_from.subscriptions):
subscription.user = merge_into
db.session.add(subscription)

# move all the api keys of the user "merge_from" to the user "merge_into"
for api_key in list(merge_from.api_keys):
api_key.user = merge_into
db.session.add(api_key)

# move all the oauth identities of the user "merge_from" to the user "merge_into"
for identity in list(merge_from.oauth_identity):
identity.user = merge_into
db.session.add(identity)

# move all the clients of the user "merge_from" to the user "merge_into"
for client in list(merge_from.clients):
client.user = merge_into
db.session.add(client)

# remove the "merge_from" user
db.session.delete(merge_from)

# commit the changes
db.session.add(merge_into)
db.session.commit()

# remove the "merge_from" user
db.session.delete(merge_from)

# flush the changes
db.session.flush()
# remove the "merge_from" user
return merge_into
except Exception as e:
logger.error("Unable to merge users: %r", e)
if logger.isEnabledFor(logging.DEBUG):
logger.exception(e)
db.session.rollback()
raise exceptions.LifeMonitorException(title="Unable to merge users") from e


def save_current_user_identity(identity: OAuthIdentity):
Expand Down
65 changes: 55 additions & 10 deletions lifemonitor/auth/templates/auth/merge.j2
Original file line number Diff line number Diff line change
@@ -1,15 +1,60 @@
{% extends 'base.j2' %}
{% import 'macros.j2' as macros %}

{% block body_class %} login-page {% endblock %}
{% block body_style %} height: auto; {% endblock %}

{% block body %}
<h1>Merge Account</h1>
<p>If you want to merge another account into this one, log in to that account here. That account must have a password set.</p>
<form method="POST">
{{ form.hidden_tag() }}
{{ macros.render_field(form.username) }}
{{ macros.render_field(form.password) }}
<div class="buttons">
<input type="submit" value="Merge Account">
<div class="login-box" style="margin: 50px 0;">

{{ macros.render_logo(class="login-logo", style="width: auto") }}

<div class="card card-primary card-outline shadow-lg p-3 mb-5 bg-white rounded">

<div class="card-body login-card-body text-center">
<h5 class="login-box-msg text-bold">Merge Account</h5>
<p class="font-weight-light">
Your <b>{{ identity.provider }}</b> identity is already associated
with the existing account under the username <b>{{ identity.username }}</b>.
</p>
<p class="font-weight-light">
If you wish to merge your <b>{{ current_user.username }}</b> account
with your <b>{{ identity.username }}</b> account,
please log in to the later (ensure that it has a password set).
</p>
<form method="POST">
{{ form.hidden_tag() }}
{{ macros.render_custom_field(form.username,caption="identity username",disabled="true")}}
{{ macros.render_custom_field(form.password,caption="identity password") }}

<div class="text-center my-4 row">
<div class="col-6">
<a href="{{ url_for("auth.profile") }}" class="btn btn-block btn-secondary">
Back
</a>
</div>
<div class="col-6">
<button type="submit"
class="btn btn-block btn-primary">
Merge
</button>
</div>
</div>

</form>
<div>
<span style="color: var(--eosc-yellow)">
<i class="fas fa-exclamation-triangle"></i>
<b class="text-warning font-weight-bold">Warning:</b></span>
<span class="font-weight-bold" style="color: var(--test-aborted)">
all content from your current <b class="font-weight-light">{{ current_user.username }}</b> account
will be transferred to the
<b class="font-weight-light">{{ identity.username }}</b> account,
and then the <b class="font-weight-light">{{ current_user.username }}</b>
account will be deleted!
</span>
</div>
</div>
</form>
<p>Warning: all of the content of that account will be transferred to this account, and then that account will be deleted!</p>
</div>
</div>
{% endblock %}
Loading

0 comments on commit 8fc9bf7

Please sign in to comment.