Coverage for functions \ flipdare \ service \ external_account_service.py: 86%
120 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
13from __future__ import annotations
14from typing import TYPE_CHECKING, Any
15import flask
16from flipdare.app_log import LOG
17from flipdare.mailer.user.delete_account_email import DeleteAccountEmail
18from flipdare.error.app_error import AppError
19from flipdare.generated.schema.error.error_email_schema import ErrorEmailSchema
20from flipdare.service._error_mixin import ErrorMixin
21from flipdare.generated.shared.app_error_code import AppErrorCode
22from flipdare.message.error_message import ErrorMessage
23from flipdare.message.user_message import UserMessage
24from flipdare.request.app_request import AppRequest
25from flipdare.request.data.delete_request_adapter import (
26 DeleteConfirmRequestAdapter,
27 DeleteRequestAdapter,
28)
29from flipdare.request.data.unsubscribe_request_adapter import UnsubscribeRequestAdapter
30from flipdare.request.request_types import AppHttpRequestType
31from flipdare.core.app_response import AppErrorResponse, AppOkResponse
32from flipdare.service._service_provider import ServiceProvider
33from flipdare.service._user_mixin import UserMixin
34from flipdare.util.code_generator import CodeGenerator
35from flipdare.wrapper.user_wrapper import UserWrapper
37if TYPE_CHECKING:
38 from flipdare.manager.db_manager import DbManager
39 from flipdare.manager.backend_manager import BackendManager
40 from flipdare.manager.service_manager import ServiceManager
43class ExternalAccountService(UserMixin, ErrorMixin, ServiceProvider):
44 def __init__(
45 self,
46 db_manager: DbManager | None = None,
47 backend_manager: BackendManager | None = None,
48 service_manager: ServiceManager | None = None,
49 ) -> None:
50 super().__init__(
51 backend_manager=backend_manager,
52 db_manager=db_manager,
53 service_manager=service_manager,
54 )
56 def unsubscribe(self, request: AppRequest[Any]) -> flask.Response:
57 # this is remote so we need to check the honeypot_field
58 # If honeypot_field is NOT empty: Reject the submission immediately
59 # (do not process the unsubscribe request).
60 # If honeypot_field IS empty: Proceed with normal processing.
61 endpoint = request.endpoint
63 unsubscribe_data = UnsubscribeRequestAdapter.from_http(
64 request.raw_request,
65 req_type=AppHttpRequestType.UNSUBSCRIBE,
66 )
68 try:
69 unsubscribe_data.validate()
70 except AppError as error:
71 LOG().error(f"Error validating unsubscribe request: {error}")
72 return self.http_validation_error(
73 endpoint=endpoint,
74 error=error,
75 user_error_code=unsubscribe_data.user_error_code,
76 notify_admin=False,
77 )
79 email = unsubscribe_data.email
80 endpoint = request.endpoint
81 try:
82 user = self.get_user_by_email(endpoint, email)
83 user.email_notifs_enabled = False
84 self.update_user(
85 endpoint=endpoint,
86 user=user,
87 on_error_msg=ErrorMessage.UNSUBSCRIBE_ERROR,
88 )
89 return AppOkResponse.message(
90 message=UserMessage.UNSUBSCRIBE_OK,
91 ).raw_response()
92 except Exception as error:
93 msg = f"Unexpected error in unsubscribe for email {email}: {error}"
94 LOG().error(msg)
95 return self.http_request_error(
96 endpoint=endpoint,
97 notify_admin=False,
98 error_code=AppErrorCode.SERVER,
99 message=msg,
100 error=error,
101 )
103 def delete(self, request: AppRequest[Any]) -> flask.Response:
104 # this is remote so we need to check the honeypot_field
105 # If honeypot_field is NOT empty: Reject the submission immediately
106 # (do not process the unsubscribe request).
107 # If honeypot_field IS empty: Proceed with normal processing.
108 req = request.raw_request
109 endpoint = request.endpoint
111 delete_data = DeleteRequestAdapter.from_http(req, req_type=AppHttpRequestType.DELETE)
113 try:
114 delete_data.validate()
115 except AppError as error:
116 msg = f"Error validating delete request for endpoint {endpoint}: {error}"
117 return self.http_validation_error(
118 endpoint=endpoint,
119 error=error,
120 user_error_code=delete_data.user_error_code,
121 )
123 url = req.url
124 email = delete_data.email
125 user: UserWrapper | None = None
127 try:
128 user = self.get_user_by_email(url, email)
129 delete_code = CodeGenerator.instance().delete_code()
130 user.delete_code = delete_code
131 self.update_user(
132 endpoint=endpoint,
133 user=user,
134 on_error_msg=ErrorMessage.DELETE_ACCOUNT_ERROR,
135 )
136 except AppError as error:
137 return AppErrorResponse.from_context(error.context).raw_response()
138 except Exception as error:
139 msg = f"Unexpected error in delete for email {email}: {error}"
140 LOG().error(msg)
141 return self.http_request_error(
142 endpoint=endpoint,
143 notify_admin=False,
144 error_code=AppErrorCode.SERVER,
145 message=msg,
146 error=error,
147 )
149 assert user is not None # type narrowing, if we got here user should be set
151 LOG().info(f"Sending delete request for {email}")
152 try:
153 email_template = DeleteAccountEmail.delete_confirm(to_user=user)
154 # first we send the email, and update if the email is sent ok
155 self.user_mailer.send(user=user, email_template=email_template, notif_check=False)
156 LOG().info(f"Delete request email sent for {email}")
157 return AppOkResponse.message(UserMessage.DELETE_OK).raw_response()
158 except Exception as error:
159 msg = f"Error creating delete request for email {email}: {error}"
160 LOG().error(msg)
161 return self.http_request_error(
162 endpoint=endpoint,
163 notify_admin=False,
164 error_code=AppErrorCode.SERVER,
165 message=msg,
166 error=error,
167 )
169 def delete_confirm(self, request: AppRequest[Any]) -> flask.Response:
170 # this is remote so we need to check the honeypot_field
171 # If honeypot_field is NOT empty: Reject the submission immediately
172 # (do not process the unsubscribe request).
173 # If honeypot_field IS empty: Proceed with normal processing.
174 req = request.raw_request
175 endpoint = request.endpoint
177 confirm_data = DeleteConfirmRequestAdapter.from_http(
178 req,
179 req_type=AppHttpRequestType.DELETE_CONFIRM,
180 )
182 try:
183 confirm_data.validate()
184 except AppError as error:
185 msg = f"Error validating delete confirm request for endpoint {endpoint}: {error}"
186 return self.http_validation_error(
187 endpoint=endpoint,
188 error=error,
189 user_error_code=confirm_data.user_error_code,
190 )
192 endpoint = req.url
193 email = confirm_data.email
194 code = confirm_data.delete_code
196 if code == "":
197 LOG().error(f"Missing delete code for user {email}")
198 msg = ErrorMessage.MISSING_REQUEST_PIN_CODE
199 return self.http_request_error(
200 endpoint=endpoint,
201 notify_admin=False,
202 error_code=AppErrorCode.INVALID_DATA,
203 message=msg,
204 )
206 user: UserWrapper | None = None
207 try:
208 user = self.get_user_by_email(endpoint, email)
209 except AppError as error:
210 LOG().error(f"Error validating delete confirm request for email {email}: {error}")
211 return AppErrorResponse.from_context(error.context).raw_response()
212 except Exception as error:
213 LOG().error(f"Unexpected error in delete confirm for email {email}: {error}")
214 msg = ErrorMessage.INVALID_EMAIL.formatted(ErrorEmailSchema(email=email))
215 return self.http_request_error(
216 endpoint=endpoint,
217 notify_admin=False,
218 error_code=AppErrorCode.SERVER,
219 message=msg,
220 error=error,
221 )
223 assert user is not None # type narrowing, if we got here user should be set
225 if user.delete_code is None:
226 LOG().error(f"Missing server delete code for user {email}")
227 msg = ErrorMessage.MISSING_SERVER_PIN_CODE
228 return self.http_request_error(
229 endpoint=endpoint,
230 notify_admin=False,
231 error_code=AppErrorCode.INVALID_DATA,
232 message=msg,
233 )
235 actual_code = user.delete_code
236 if str(actual_code).strip() != str(code).strip():
237 # NOTE: its safer to compare as strings
238 LOG().error(f"Invalid delete code for user {email}: exp {actual_code} got {code}")
239 msg = ErrorMessage.PIN_CODE_MISMATCH
240 return self.http_request_error(
241 endpoint=endpoint,
242 notify_admin=False,
243 error_code=AppErrorCode.PIN_CODE_MISMATCH,
244 message=msg,
245 )
247 # NOTE: because of data retention policies for fraud and criminal investigations,
248 # NOTE; we need to keep this data
249 # NOTE: so we set a random password for the user and append
250 # NOTE: an _underscore delete to the email
251 try:
252 updates = {"email": f"{email}_deleted"}
253 self.update_user(
254 endpoint=endpoint,
255 user=user,
256 on_error_msg=ErrorMessage.DELETE_PARTIAL_FAILED,
257 manual_updates=updates,
258 )
259 LOG().info(f"User {email} deleted successfully")
260 return AppOkResponse.message(UserMessage.DELETE_CONFIRM_OK).raw_response()
261 except Exception as error:
262 LOG().error(f"Error deleting user {email}: {error}")
263 return self.http_request_error(
264 endpoint=endpoint,
265 notify_admin=False,
266 error_code=AppErrorCode.SERVER,
267 message=ErrorMessage.DELETE_ACCOUNT_ERROR,
268 error=error,
269 )