1+ import functools
2+ import logging
3+ from typing import NamedTuple
4+
15from aiohttp import web
26from servicelib .aiohttp import status
37from servicelib .aiohttp .requests_validation import parse_request_path_parameters_as
8+ from servicelib .aiohttp .typing_extension import Handler
9+ from servicelib .aiohttp .web_exceptions_extension import get_http_error_class_or_none
10+ from servicelib .logging_errors import create_troubleshotting_log_kwargs
11+ from servicelib .status_codes_utils import is_5xx_server_error
412
513from .._meta import API_VTAG as VTAG
614from ..login .decorators import get_user_id , login_required
715from ..products .api import get_product_name
816from ..projects ._common_models import ProjectPathParams
917from ..security .decorators import permission_required
1018from . import _trash_api
19+ from .exceptions import ProjectRunningConflictError , ProjectStopError , ProjectTrashError
20+
21+ _logger = logging .getLogger (__name__ )
22+
23+
24+ class HttpErrorInfo (NamedTuple ):
25+ status_code : int
26+ msg_template : str
27+
28+
29+ _TO_HTTP_ERROR_MAP : dict [type [Exception ], HttpErrorInfo ] = {
30+ ProjectRunningConflictError : HttpErrorInfo (
31+ status .HTTP_409_CONFLICT ,
32+ "Current study is in use and cannot be trashed [{project_uuid}]" ,
33+ ),
34+ ProjectStopError : HttpErrorInfo (
35+ status .HTTP_503_SERVICE_UNAVAILABLE ,
36+ "Something went wrong while stopping services before trashing. Aborting trash." ,
37+ ),
38+ }
39+
40+
41+ class _DefaultDict (dict ):
42+ def __missing__ (self , key ):
43+ return f"'{ key } =?'"
44+
45+
46+ def _handle_request_exceptions (handler : Handler ):
47+ @functools .wraps (handler )
48+ async def _wrapper (request : web .Request ) -> web .StreamResponse :
49+ try :
50+ return await handler (request )
51+
52+ except ProjectTrashError as exc :
53+ for exc_cls , http_error_info in _TO_HTTP_ERROR_MAP .items ():
54+ if isinstance (exc , exc_cls ):
55+
56+ # safe formatting
57+ user_msg = http_error_info .msg_template .format_map (
58+ _DefaultDict (getattr (exc , "__dict__" , {}))
59+ )
60+
61+ http_error_cls = get_http_error_class_or_none (
62+ http_error_info .status_code
63+ )
64+ assert http_error_cls # nosec
65+
66+ if is_5xx_server_error (http_error_info .status_code ):
67+ _logger .exception (
68+ ** create_troubleshotting_log_kwargs (
69+ user_msg ,
70+ error = exc ,
71+ error_context = {
72+ "request" : request ,
73+ "request.remote" : f"{ request .remote } " ,
74+ "request.method" : f"{ request .method } " ,
75+ "request.path" : f"{ request .path } " ,
76+ },
77+ )
78+ )
79+ raise http_error_cls (reason = user_msg ) from exc
80+ raise
81+
82+ return _wrapper
83+
1184
1285routes = web .RouteTableDef ()
1386
1487
1588@routes .delete (f"/{ VTAG } /trash" , name = "empty_trash" )
1689@login_required
1790@permission_required ("project.delete" )
91+ @_handle_request_exceptions
1892async def empty_trash (request : web .Request ):
1993 user_id = get_user_id (request )
2094 product_name = get_product_name (request )
@@ -26,14 +100,10 @@ async def empty_trash(request: web.Request):
26100 return web .json_response (status = status .HTTP_204_NO_CONTENT )
27101
28102
29- #
30- # Projects
31- #
32-
33-
34103@routes .post (f"/{ VTAG } /projects/{{project_id}}:trash" , name = "trash_project" )
35104@login_required
36105@permission_required ("project.delete" )
106+ @_handle_request_exceptions
37107async def trash_project (request : web .Request ):
38108 user_id = get_user_id (request )
39109 product_name = get_product_name (request )
@@ -53,6 +123,7 @@ async def trash_project(request: web.Request):
53123@routes .post (f"/{ VTAG } /projects/{{project_id}}:untrash" , name = "untrash_project" )
54124@login_required
55125@permission_required ("project.delete" )
126+ @_handle_request_exceptions
56127async def untrash_project (request : web .Request ):
57128 user_id = get_user_id (request )
58129 product_name = get_product_name (request )
0 commit comments