Skip to content

Commit 00a5dda

Browse files
committed
backend: Add ability to delete a user
Signed-off-by: Taylor Smock <tsmock@meta.com>
1 parent 7d26e0c commit 00a5dda

File tree

6 files changed

+192
-3
lines changed

6 files changed

+192
-3
lines changed

backend/api/users/resources.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
from distutils.util import strtobool
2+
from typing import Optional
3+
4+
from flask import stream_with_context, Response
25
from flask_restful import Resource, current_app, request
36
from schematics.exceptions import DataError
47

58
from backend.models.dtos.user_dto import UserSearchQuery
9+
from backend.models.postgis.user import User
610
from backend.services.users.authentication_service import token_auth
711
from backend.services.users.user_service import UserService
812
from backend.services.project_service import ProjectService
13+
from backend.services.users.osm_service import OSMService
914

1015

1116
class UsersRestAPI(Resource):
@@ -44,6 +49,27 @@ def get(self, user_id):
4449
user_dto = UserService.get_user_dto_by_id(user_id, token_auth.current_user())
4550
return user_dto.to_primitive(), 200
4651

52+
@token_auth.login_required
53+
def delete(self, user_id: Optional[int] = None):
54+
"""
55+
Delete user information by id.
56+
:param user_id: The user to delete
57+
:return: RFC7464 compliant sequence of user objects deleted
58+
200: User deleted
59+
401: Unauthorized - Invalid credentials
60+
404: User not found
61+
500: Internal Server Error
62+
"""
63+
if user_id == token_auth.current_user() or UserService.is_user_an_admin(
64+
token_auth.current_user()
65+
):
66+
return (
67+
UserService.delete_user_by_id(
68+
user_id, token_auth.current_user()
69+
).to_primitive(),
70+
200,
71+
)
72+
4773

4874
class UsersAllAPI(Resource):
4975
@token_auth.login_required
@@ -115,6 +141,24 @@ def get(self):
115141
users_dto = UserService.get_all_users(query)
116142
return users_dto.to_primitive(), 200
117143

144+
@token_auth.login_required
145+
def delete(self):
146+
if UserService.is_user_an_admin(token_auth.current_user()):
147+
148+
def delete_users():
149+
for user in User.get_all_users_not_paginated():
150+
# We specifically want to remove users that have deleted their OSM accounts.
151+
if OSMService.is_osm_user_gone(user.id):
152+
data = UserService.delete_user_by_id(
153+
user.id, token_auth.current_user()
154+
).to_primitive()
155+
yield f"\u001e{data}\n"
156+
157+
return Response(
158+
stream_with_context(delete_users()),
159+
headers={"Content-Type": "application/json-seq"},
160+
)
161+
118162

119163
class UsersQueriesUsernameAPI(Resource):
120164
@token_auth.login_required

backend/models/postgis/application.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ def get_all_for_user(user: int):
6060
applications_dto.applications.append(application_dto)
6161
return applications_dto
6262

63+
@staticmethod
64+
def delete_all_for_user(user: int):
65+
for r in db.session.query(Application).filter(Application.user == user):
66+
db.session.delete(r)
67+
db.session.commit()
68+
6369
def as_dto(self):
6470
app_dto = ApplicationDTO()
6571
app_dto.user = self.user

backend/models/postgis/message.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import List
2+
13
from sqlalchemy.sql.expression import false
24

35
from backend import db
@@ -178,7 +180,7 @@ def delete_multiple_messages(message_ids: list, user_id: int):
178180
db.session.commit()
179181

180182
@staticmethod
181-
def delete_all_messages(user_id: int, message_type_filters: list = None):
183+
def delete_all_messages(user_id: int, message_type_filters: List[int] = None):
182184
"""Deletes all messages to the user
183185
-----------------------------------
184186
:param user_id: user id of the user whose messages are to be deleted

backend/services/users/osm_service.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,25 @@ def __init__(self, message):
1313

1414

1515
class OSMService:
16+
@staticmethod
17+
def is_osm_user_gone(user_id: int) -> bool:
18+
"""
19+
Check if OSM details for the user from OSM API are available
20+
:param user_id: user_id in scope
21+
:raises OSMServiceError
22+
"""
23+
osm_user_details_url = (
24+
f"{current_app.config['OSM_SERVER_URL']}/api/0.6/user/{user_id}.json"
25+
)
26+
response = requests.head(osm_user_details_url)
27+
28+
if response.status_code == 410:
29+
return True
30+
if response.status_code != 200:
31+
raise OSMServiceError("Bad response from OSM")
32+
33+
return False
34+
1635
@staticmethod
1736
def get_osm_details_for_user(user_id: int) -> UserOSMDTO:
1837
"""
@@ -25,6 +44,8 @@ def get_osm_details_for_user(user_id: int) -> UserOSMDTO:
2544
)
2645
response = requests.get(osm_user_details_url)
2746

47+
if response.status_code == 410:
48+
raise OSMServiceError("User no longer exists on OSM")
2849
if response.status_code != 200:
2950
raise OSMServiceError("Bad response from OSM")
3051

backend/services/users/user_service.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Optional
2+
13
from cachetools import TTLCache, cached
24
from flask import current_app
35
import datetime
@@ -27,14 +29,14 @@
2729
from backend.models.postgis.task import TaskHistory, TaskAction, Task
2830
from backend.models.dtos.user_dto import UserTaskDTOs
2931
from backend.models.dtos.stats_dto import Pagination
32+
from backend.models.postgis.project_chat import ProjectChat
3033
from backend.models.postgis.statuses import TaskStatus, ProjectStatus
31-
from backend.services.users.osm_service import OSMService, OSMServiceError
3234
from backend.services.messaging.smtp_service import SMTPService
3335
from backend.services.messaging.template_service import (
3436
get_txt_template,
3537
template_var_replacing,
3638
)
37-
39+
from backend.services.users.osm_service import OSMService, OSMServiceError
3840

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

@@ -190,6 +192,56 @@ def get_user_dto_by_id(user: int, request_user: int) -> UserDTO:
190192
return user.as_dto(request_username)
191193
return user.as_dto()
192194

195+
@staticmethod
196+
def delete_user_by_id(user_id: int, request_user_id: int) -> Optional[UserDTO]:
197+
if user_id == request_user_id or UserService.is_user_an_admin(request_user_id):
198+
user = User.get_by_id(user_id)
199+
original_dto = UserService.get_user_dto_by_id(user_id, request_user_id)
200+
user.accepted_licenses = []
201+
user.city = None
202+
user.country = None
203+
user.email_address = None
204+
user.facebook_id = None
205+
user.gender = None
206+
user.interests = []
207+
user.irc_id = None
208+
user.is_email_verified = False
209+
user.is_expert = False
210+
user.linkedin_id = None
211+
user.name = None
212+
user.picture_url = None
213+
user.self_description_gender = None
214+
user.skype_id = None
215+
user.slack_id = None
216+
user.twitter_id = None
217+
# FIXME: Should we keep user_id since that will make conversations easier to follow?
218+
# Keep in mind that OSM uses user_<int:user_id> on deleted accounts.
219+
user.username = f"user_{user_id}"
220+
221+
# Remove permissions from admin users, keep role for blocked users.
222+
if UserService.is_user_an_admin(user_id):
223+
user.set_user_role(UserRole.MAPPER)
224+
user.save()
225+
226+
# Remove messages that might contain user identifying information.
227+
for message in ProjectChat.query.filter_by(user_id=user_id):
228+
# TODO detect image links and try to delete them
229+
message.message = f"[Deleted user_{user_id} message]"
230+
db.session.commit()
231+
232+
# Drop application keys
233+
from backend.models.postgis.application import Application
234+
235+
Application.delete_all_for_user(user_id)
236+
237+
# Delete all messages (AKA notifications) for the user
238+
Message.delete_all_messages(
239+
user_id, [message_type.value for message_type in MessageType]
240+
)
241+
# Leave interests, licenses, organizations, and tasks alone for now.
242+
return original_dto
243+
return None
244+
193245
@staticmethod
194246
def get_interests_stats(user_id):
195247
# Get all projects that the user has contributed.

tests/backend/integration/services/users/test_user_service.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from tests.backend.base import BaseTestCase
44
from backend.models.postgis.message import Message
5+
from backend.models.postgis.statuses import UserRole, UserGender
56
from backend.services.users.user_service import (
67
UserService,
78
MappingLevel,
@@ -97,3 +98,66 @@ def test_register_user_creates_new_user(self):
9798
# Assert
9899
self.assertEqual(expected_user.username, test_user.username)
99100
self.assertEqual(expected_user.mapping_level, MappingLevel.INTERMEDIATE.value)
101+
102+
def add_user_identifying_information(self, user: User) -> User:
103+
user.username = "Test User"
104+
user.email_address = "test@example.com"
105+
user.twitter_id = "@Test"
106+
user.facebook_id = "@FBTest"
107+
user.linkedin_id = "@LinkedIn"
108+
user.slack_id = "@Slack"
109+
user.skype_id = "@Skype"
110+
user.irc_id = "IRC"
111+
user.name = "Some name here"
112+
user.city = "Some city"
113+
user.country = "Some country"
114+
user.picture_url = "https://test.com/path/to/picture.png"
115+
user.gender = UserGender.MALE.value
116+
user.self_description_gender = "I am male"
117+
user.default_editor = "ID"
118+
user.save()
119+
return user
120+
121+
def check_user_details_deleted(self, user: User, deleted: bool):
122+
if deleted:
123+
check = self.assertIsNone
124+
self.assertNotEquals(UserRole.ADMIN.value, user.role)
125+
self.assertEqual(f"user_{user.id}", user.username)
126+
else:
127+
self.assertNotEquals(f"user_{user.id}", user.username)
128+
check = self.assertIsNotNone
129+
check(user.email_address)
130+
check(user.twitter_id)
131+
check(user.facebook_id)
132+
check(user.linkedin_id)
133+
check(user.slack_id)
134+
check(user.skype_id)
135+
check(user.irc_id)
136+
check(user.name)
137+
check(user.city)
138+
check(user.country)
139+
check(user.picture_url)
140+
check(user.gender)
141+
check(user.self_description_gender)
142+
self.assertEqual([], user.accepted_licenses)
143+
self.assertEqual([], user.interests)
144+
145+
def test_delete_user_same_user(self):
146+
test_user = self.add_user_identifying_information(create_canned_user())
147+
UserService.delete_user_by_id(test_user.id, test_user.id)
148+
self.check_user_details_deleted(User().get_by_id(test_user.id), deleted=True)
149+
150+
def test_delete_user_different_user(self):
151+
test_user = self.add_user_identifying_information(create_canned_user())
152+
other_user = return_canned_user("someone", test_user.id + 1)
153+
other_user.create()
154+
UserService.delete_user_by_id(test_user.id, other_user.id)
155+
self.check_user_details_deleted(User().get_by_id(test_user.id), deleted=False)
156+
157+
def test_delete_user_different_admin_user(self):
158+
test_user = self.add_user_identifying_information(create_canned_user())
159+
other_user = return_canned_user("someone", test_user.id + 1)
160+
other_user.set_user_role(UserRole.ADMIN)
161+
other_user.create()
162+
UserService.delete_user_by_id(test_user.id, other_user.id)
163+
self.check_user_details_deleted(User().get_by_id(test_user.id), deleted=True)

0 commit comments

Comments
 (0)