Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: merge accounts feature #376

Merged
merged 13 commits into from
Jan 18, 2024
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