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

Add ability to delete a user's information #6334

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
62 changes: 62 additions & 0 deletions backend/api/users/resources.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
from distutils.util import strtobool
from typing import Optional
from collections.abc import Generator

from flask import stream_with_context, Response
from flask_restful import Resource, current_app, request
from schematics.exceptions import DataError

from backend.models.dtos.user_dto import UserSearchQuery
from backend.models.postgis.user import User
from backend.services.users.authentication_service import token_auth
from backend.services.users.user_service import UserService
from backend.services.project_service import ProjectService
from backend.services.users.osm_service import OSMService
from backend.exceptions import Unauthorized


class UsersRestAPI(Resource):
Expand Down Expand Up @@ -44,6 +51,28 @@ def get(self, user_id):
user_dto = UserService.get_user_dto_by_id(user_id, token_auth.current_user())
return user_dto.to_primitive(), 200

@token_auth.login_required
def delete(self, user_id: Optional[int] = None):
"""
Delete user information by id.
:param user_id: The user to delete
:return: RFC7464 compliant sequence of user objects deleted
200: User deleted
401: Unauthorized - Invalid credentials
404: User not found
500: Internal Server Error
"""
if user_id == token_auth.current_user() or UserService.is_user_an_admin(
token_auth.current_user()
):
return (
UserService.delete_user_by_id(
user_id, token_auth.current_user()
).to_primitive(),
200,
)
raise Unauthorized()


class UsersAllAPI(Resource):
@token_auth.login_required
Expand Down Expand Up @@ -115,6 +144,39 @@ def get(self):
users_dto = UserService.get_all_users(query)
return users_dto.to_primitive(), 200

@token_auth.login_required
def delete(self):
if UserService.is_user_an_admin(token_auth.current_user()):
return Response(
stream_with_context(UsersAllAPI._delete_users()),
headers={"Content-Type": "application/json-seq"},
)
raise Unauthorized()

@staticmethod
def _delete_users() -> Generator[str, None, None]:
# Updated daily
deleted_users = OSMService.get_deleted_users()
if deleted_users:
last_deleted_user = 0
for user in User.get_all_users_not_paginated(User.id):
while last_deleted_user < user.id:
last_deleted_user = next(deleted_users)
if last_deleted_user == user.id:
data = UserService.delete_user_by_id(
user.id, token_auth.current_user()
).to_primitive()
yield f"\u001e{data}\n"
return
# Fall back to hitting the API (if the OSM API is not the default)
for user in User.get_all_users_not_paginated():
# We specifically want to remove users that have deleted their OSM accounts.
if OSMService.is_osm_user_gone(user.id):
data = UserService.delete_user_by_id(
user.id, token_auth.current_user()
).to_primitive()
yield f"\u001e{data}\n"


class UsersQueriesUsernameAPI(Resource):
@token_auth.login_required
Expand Down
6 changes: 6 additions & 0 deletions backend/models/postgis/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ def get_all_for_user(user: int):
applications_dto.applications.append(application_dto)
return applications_dto

@staticmethod
def delete_all_for_user(user: int):
for r in db.session.query(Application).filter(Application.user == user):
db.session.delete(r)
db.session.commit()

def as_dto(self):
app_dto = ApplicationDTO()
app_dto.user = self.user
Expand Down
4 changes: 3 additions & 1 deletion backend/models/postgis/message.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import List

from sqlalchemy.sql.expression import false

from backend import db
Expand Down Expand Up @@ -178,7 +180,7 @@ def delete_multiple_messages(message_ids: list, user_id: int):
db.session.commit()

@staticmethod
def delete_all_messages(user_id: int, message_type_filters: list = None):
def delete_all_messages(user_id: int, message_type_filters: List[int] = None):
"""Deletes all messages to the user
-----------------------------------
:param user_id: user id of the user whose messages are to be deleted
Expand Down
7 changes: 5 additions & 2 deletions backend/models/postgis/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,12 @@ def get_all_users(query: UserSearchQuery) -> UserSearchDTO:
return dto

@staticmethod
def get_all_users_not_paginated():
def get_all_users_not_paginated(*order):
"""Get all users in DB"""
return db.session.query(User.id).all()
query = db.session.query(User.id)
if not order:
return query.all()
return query.order_by(*order).all()

@staticmethod
def filter_users(user_filter: str, project_id: int, page: int) -> UserFilterDTO:
Expand Down
51 changes: 51 additions & 0 deletions backend/services/users/osm_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import re
from collections.abc import Generator
from typing import Optional

import requests
from flask import current_app

Expand All @@ -13,6 +17,51 @@ def __init__(self, message):


class OSMService:
@staticmethod
def is_osm_user_gone(user_id: int) -> bool:
"""
Check if OSM details for the user from OSM API are available
:param user_id: user_id in scope
:raises OSMServiceError
"""
osm_user_details_url = (
f"{current_app.config['OSM_SERVER_URL']}/api/0.6/user/{user_id}.json"
)
response = requests.head(osm_user_details_url)

if response.status_code == 410:
return True
if response.status_code != 200:
raise OSMServiceError("Bad response from OSM")

return False

@staticmethod
def get_deleted_users() -> Optional[Generator[int, None, None]]:
"""
Get the list of deleted users from OpenStreetMap.
This only returns users from the https://www.openstreetmap.org instance.
:return: The deleted users
"""
if current_app.config["OSM_SERVER_URL"] == "https://www.openstreetmap.org":

def get_planet_osm_deleted_users() -> Generator[int, None, None]:
response = requests.get(
"https://planet.openstreetmap.org/users_deleted/users_deleted.txt",
stream=True,
)
username = re.compile(r"^\s*(\d+)\s*$")
try:
for line in response.iter_lines(decode_unicode=True):
match = username.fullmatch(line)
if match:
yield int(match.group(1))
finally:
response.close()

return get_planet_osm_deleted_users()
return None

@staticmethod
def get_osm_details_for_user(user_id: int) -> UserOSMDTO:
"""
Expand All @@ -25,6 +74,8 @@ def get_osm_details_for_user(user_id: int) -> UserOSMDTO:
)
response = requests.get(osm_user_details_url)

if response.status_code == 410:
raise OSMServiceError("User no longer exists on OSM")
if response.status_code != 200:
raise OSMServiceError("Bad response from OSM")

Expand Down
56 changes: 54 additions & 2 deletions backend/services/users/user_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

from cachetools import TTLCache, cached
from flask import current_app
import datetime
Expand Down Expand Up @@ -27,14 +29,14 @@
from backend.models.postgis.task import TaskHistory, TaskAction, Task
from backend.models.dtos.user_dto import UserTaskDTOs
from backend.models.dtos.stats_dto import Pagination
from backend.models.postgis.project_chat import ProjectChat
from backend.models.postgis.statuses import TaskStatus, ProjectStatus
from backend.services.users.osm_service import OSMService, OSMServiceError
from backend.services.messaging.smtp_service import SMTPService
from backend.services.messaging.template_service import (
get_txt_template,
template_var_replacing,
)

from backend.services.users.osm_service import OSMService, OSMServiceError

user_filter_cache = TTLCache(maxsize=1024, ttl=600)

Expand Down Expand Up @@ -190,6 +192,56 @@ def get_user_dto_by_id(user: int, request_user: int) -> UserDTO:
return user.as_dto(request_username)
return user.as_dto()

@staticmethod
def delete_user_by_id(user_id: int, request_user_id: int) -> Optional[UserDTO]:
if user_id == request_user_id or UserService.is_user_an_admin(request_user_id):
user = User.get_by_id(user_id)
original_dto = UserService.get_user_dto_by_id(user_id, request_user_id)
user.accepted_licenses = []
user.city = None
user.country = None
user.email_address = None
user.facebook_id = None
user.gender = None
user.interests = []
user.irc_id = None
user.is_email_verified = False
user.is_expert = False
user.linkedin_id = None
user.name = None
user.picture_url = None
user.self_description_gender = None
user.skype_id = None
user.slack_id = None
user.twitter_id = None
# FIXME: Should we keep user_id since that will make conversations easier to follow?
# Keep in mind that OSM uses user_<int:user_id> on deleted accounts.
user.username = f"user_{user_id}"

# Remove permissions from admin users, keep role for blocked users.
if UserService.is_user_an_admin(user_id):
user.set_user_role(UserRole.MAPPER)
user.save()

# Remove messages that might contain user identifying information.
for message in ProjectChat.query.filter_by(user_id=user_id):
# TODO detect image links and try to delete them
message.message = f"[Deleted user_{user_id} message]"
db.session.commit()

# Drop application keys
from backend.models.postgis.application import Application

Application.delete_all_for_user(user_id)

# Delete all messages (AKA notifications) for the user
Message.delete_all_messages(
user_id, [message_type.value for message_type in MessageType]
)
# Leave interests, licenses, organizations, and tasks alone for now.
return original_dto
return None

@staticmethod
def get_interests_stats(user_id):
# Get all projects that the user has contributed.
Expand Down
41 changes: 36 additions & 5 deletions frontend/src/components/deleteModal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,34 @@ import { AlertIcon } from '../svgIcons';

const DeleteTrigger = forwardRef((props, ref) => <DeleteButton {...props} />);

export function DeleteModal({ id, name, type, className, endpointURL, onDelete }: Object) {
/**
* Called when an object is deleted
* @callback onDelete
* @param success The success object
*/

/**
* Create a delete modal
* @param {number} [id] The id of the object to delete. Ignored if className is defined.
* @param {str} [name] The name of the object (unused)
* @param {('notifications'|'comments'|'users'|'interests'|'categories')} [type] The type of the object to delete. Ignored if className is defined.
* @param {str} [className] The additional css class names
* @param {str} [endpointURL] The endpoint to call
* @param {onDelete} [onDelete] Called when the object is deleted
* @typedef {import('@formatjs/intl').MessageDescriptor} MessageDescriptor
* @param {MessageDescriptor} [message] The message to show the user
* @returns {Element} The delete modal
* @constructor
*/
export function DeleteModal({
id,
name,
type,
className,
endpointURL,
onDelete,
message = messages.delete,
}: Object) {
const navigate = useNavigate();
const modalRef = useRef();
const token = useSelector((state) => state.auth.token);
Expand All @@ -28,9 +55,9 @@ export function DeleteModal({ id, name, type, className, endpointURL, onDelete }
setDeleteStatus('success');
if (type === 'notifications') {
setTimeout(() => navigate(`/inbox`), 750);
} else if (type === 'comments') {
} else if (type === 'comments' || type === 'users') {
setTimeout(() => {
onDelete();
onDelete(success);
modalRef.current.close();
}, 750);
return;
Expand All @@ -48,7 +75,11 @@ export function DeleteModal({ id, name, type, className, endpointURL, onDelete }
<Popup
ref={modalRef}
trigger={
<DeleteTrigger className={`${className || ''} dib ml3`} showText={type !== 'comments'} />
<DeleteTrigger
className={`${className || ''} dib ml3`}
showText={type !== 'comments' && type !== 'users'}
message={message}
/>
}
modal
closeOnDocumentClick
Expand Down Expand Up @@ -86,7 +117,7 @@ export function DeleteModal({ id, name, type, className, endpointURL, onDelete }
<FormattedMessage {...messages.cancel} />
</Button>
<Button className="bg-red white" onClick={() => deleteEntity()}>
<FormattedMessage {...messages.delete} />
<FormattedMessage {...message} />
</Button>
</div>
</>
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/deleteModal/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export default defineMessages({
id: 'deleteModal.status.success.teams',
defaultMessage: 'Team deleted successfully.',
},
success_users: {
id: 'deleteModal.status.success.users',
defaultMessage: 'User deleted successfully.',
},
success_organisations: {
id: 'deleteModal.status.success.organisations',
defaultMessage: 'Organisation deleted successfully.',
Expand Down Expand Up @@ -76,6 +80,10 @@ export default defineMessages({
id: 'deleteModal.status.failure.teams',
defaultMessage: 'An error occurred when trying to delete this team.',
},
failure_users: {
id: 'deleteModal.status.failure.users',
defaultMessage: 'An error occurred when trying to delete this user.',
},
failure_comments: {
id: 'deleteModal.status.failure.comments',
defaultMessage: 'An error occurred when trying to delete this comment.',
Expand Down Expand Up @@ -129,6 +137,10 @@ export default defineMessages({
id: 'deleteModal.title.teams',
defaultMessage: 'Are you sure you want to delete this team?',
},
confirmDeleteTitle_users: {
id: 'deleteModal.title.users',
defaultMessage: "Are you sure you want to redact this user's information?",
},
confirmDeleteTitle_comments: {
id: 'deleteModal.title.comments',
defaultMessage: 'Are you sure you want to delete this comment?',
Expand Down
Loading
Loading