Skip to content

Commit

Permalink
backend: Add ability to delete a user
Browse files Browse the repository at this point in the history
Signed-off-by: Taylor Smock <[email protected]>
  • Loading branch information
tsmock committed Apr 16, 2024
1 parent 7d26e0c commit f46366b
Show file tree
Hide file tree
Showing 8 changed files with 343 additions and 10 deletions.
47 changes: 47 additions & 0 deletions backend/api/users/resources.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from distutils.util import strtobool
from typing import Optional

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 +50,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 +143,25 @@ 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()):

def delete_users():
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"

return Response(
stream_with_context(delete_users()),
headers={"Content-Type": "application/json-seq"},
)
raise Unauthorized()


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
21 changes: 21 additions & 0 deletions backend/services/users/osm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ 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_osm_details_for_user(user_id: int) -> UserOSMDTO:
"""
Expand All @@ -25,6 +44,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
150 changes: 143 additions & 7 deletions tests/backend/integration/api/users/test_resources.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Optional

from backend.models.postgis.task import Task, TaskStatus
from backend.models.postgis.statuses import UserGender, UserRole, MappingLevel
from backend.exceptions import get_message_from_sub_code


from tests.backend.base import BaseTestCase
from tests.backend.helpers.test_helpers import (
return_canned_user,
Expand All @@ -11,7 +12,6 @@
create_canned_interest,
)


TEST_USERNAME = "test_user"
TEST_USER_ID = 1111111
TEST_EMAIL = "[email protected]"
Expand Down Expand Up @@ -93,11 +93,11 @@ def test_returns_404_if_user_not_found(self):
@staticmethod
def assert_user_detail_response(
response,
user_id=TEST_USER_ID,
username=TEST_USERNAME,
email=TEST_EMAIL,
gender=None,
own_info=True,
user_id: Optional[int] = TEST_USER_ID,
username: Optional[str] = TEST_USERNAME,
email: Optional[str] = TEST_EMAIL,
gender: Optional[str] = None,
own_info: bool = True,
):
assert response.status_code == 200
assert response.json["id"] == user_id
Expand Down Expand Up @@ -556,3 +556,139 @@ def test_email_and_gender_not_returned_if_requested_by_other(self):
TestUsersQueriesUsernameAPI.assert_user_detail_response(
response, TEST_USER_ID, TEST_USERNAME, None, None, False
)

def test_user_can_delete_self(self):
"""Check that a user can delete (redact personal information) themselves"""
# Arrange
self.user.email_address = TEST_EMAIL
self.user.gender = UserGender.FEMALE.value
self.user.save()
# Act
response = self.client.delete(
self.url, headers={"Authorization": self.user_session_token}
)
next_response = self.client.get(
f"/api/v2/users/{TEST_USER_ID}/",
headers={"Authorization": self.user_session_token},
)
# Assert
# Note that we return the deleted user information at this time
TestUsersQueriesUsernameAPI.assert_user_detail_response(
response,
TEST_USER_ID,
TEST_USERNAME,
TEST_EMAIL,
UserGender.FEMALE.name,
True,
)
TestUsersQueriesUsernameAPI.assert_user_detail_response(
next_response, TEST_USER_ID, f"user_{TEST_USER_ID}", None, None, True
)

def test_other_user_cannot_delete_self(self):
"""Check that another user cannot delete (redact personal information) about a different user"""
# Arrange
self.user.email_address = TEST_EMAIL
self.user.gender = UserGender.FEMALE.value
self.user.save()
user_2 = return_canned_user("user_2", 2222222)
user_2.create()
user_2_session_token = generate_encoded_token(user_2.id)
# Act
response = self.client.delete(
self.url, headers={"Authorization": user_2_session_token}
)
# Assert
self.assertEqual(401, response.status_code)
rjson = response.json
self.assertDictEqual(
rjson,
{
"error": {
"code": 401,
"details": {},
"message": "Authentication credentials were missing or incorrect.",
"sub_code": "UNAUTHORIZED",
}
},
)

def test_other_admin_user_can_delete_self(self):
"""Check that another user cannot delete (redact personal information) about a different user"""
# Arrange
self.user.email_address = TEST_EMAIL
self.user.gender = UserGender.FEMALE.value
self.user.save()
user_2 = return_canned_user("user_2", 2222222)
user_2.set_user_role(UserRole.ADMIN)
user_2.create()
user_2_session_token = generate_encoded_token(user_2.id)
# Act
response = self.client.delete(
self.url, headers={"Authorization": user_2_session_token}
)
next_response = self.client.get(
f"/api/v2/users/{TEST_USER_ID}/",
headers={"Authorization": user_2_session_token},
)
# Assert
# Note that we return the deleted user information at this time
TestUsersQueriesUsernameAPI.assert_user_detail_response(
response,
TEST_USER_ID,
TEST_USERNAME,
TEST_EMAIL,
UserGender.FEMALE.name,
False,
)
TestUsersQueriesUsernameAPI.assert_user_detail_response(
next_response, TEST_USER_ID, f"user_{TEST_USER_ID}", None, None, False
)

def test_admin_user_can_remove_redacted_osm_accounts(self):
"""Check that an admin can redact redacted OSM accounts"""
# Arrange
self.user.email_address = TEST_EMAIL
self.user.gender = UserGender.FEMALE.value
self.user.id = 4
self.user.save()
user_2 = return_canned_user("user_2", 2222222)
user_2.set_user_role(UserRole.ADMIN)
user_2.create()
user_2_session_token = generate_encoded_token(user_2.id)
# Act
response = self.client.delete(
"/api/v2/users/", headers={"Authorization": user_2_session_token}
)
next_response = self.client.get(
"/api/v2/users/4/", headers={"Authorization": user_2_session_token}
)
# Assert
self.assertEqual(200, response.status_code)
TestUsersQueriesUsernameAPI.assert_user_detail_response(
next_response, 4, "user_4", None, None, False
)

def test_user_cannot_remove_redacted_osm_accounts(self):
"""Check that a user cannot redact redacted OSM accounts"""
# Arrange
self.user.email_address = TEST_EMAIL
self.user.gender = UserGender.FEMALE.value
self.user.id = 4
self.user.save()
user_2 = return_canned_user("user_2", 2222222)
user_2.set_user_role(UserRole.MAPPER)
user_2.create()
user_2_session_token = generate_encoded_token(user_2.id)
# Act
response = self.client.delete(
"/api/v2/users/", headers={"Authorization": user_2_session_token}
)
next_response = self.client.get(
"/api/v2/users/4/", headers={"Authorization": user_2_session_token}
)
# Assert
self.assertEqual(401, response.status_code)
TestUsersQueriesUsernameAPI.assert_user_detail_response(
next_response, 4, TEST_USERNAME, None, None, False
)
4 changes: 4 additions & 0 deletions tests/backend/integration/services/users/test_osm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ def test_get_osm_details_for_user_returns_user_details_if_valid_user_id(self):
dto = OSMService.get_osm_details_for_user(13526430)
# Assert
self.assertEqual(dto.account_created, "2021-06-10T01:27:18Z")

def test_is_user_deleted(self):
self.assertTrue(OSMService.is_osm_user_gone(535043))
self.assertFalse(OSMService.is_osm_user_gone(2078753))
Loading

0 comments on commit f46366b

Please sign in to comment.