1010 _url_generator ,
1111 find_nodes_by_content ,
1212)
13- from .session import PicnicAPISession , PicnicAuthError
13+ from .session import (
14+ Picnic2FAError ,
15+ Picnic2FARequired ,
16+ PicnicAPISession ,
17+ PicnicAuthError ,
18+ )
1419
1520DEFAULT_URL = "https://storefront-prod.{}.picnicinternational.com/api/{}"
1621GLOBAL_GATEWAY_URL = "https://gateway-prod.global.picnicinternational.com"
@@ -60,14 +65,18 @@ def _get(self, path: str, add_picnic_headers=False):
6065
6166 return response
6267
63- def _post (self , path : str , data = None , base_url_override = None ):
68+ def _post (
69+ self , path : str , data = None , base_url_override = None , add_picnic_headers = False
70+ ):
6471 url = (base_url_override if base_url_override else self ._base_url ) + path
65- response = self .session .post (url , json = data ).json ()
72+ kwargs = {"json" : data }
73+ if add_picnic_headers :
74+ kwargs ["headers" ] = _HEADERS
75+ response = self .session .post (url , ** kwargs ).json ()
6676
6777 if self ._contains_auth_error (response ):
6878 raise PicnicAuthError (
69- f"Picnic authentication error: \
70- { response ['error' ].get ('message' )} "
79+ f"Picnic authentication error: { response ['error' ].get ('message' )} "
7180 )
7281
7382 return response
@@ -80,12 +89,82 @@ def _contains_auth_error(response):
8089 error_code = response .setdefault ("error" , {}).get ("code" )
8190 return error_code == "AUTH_ERROR" or error_code == "AUTH_INVALID_CRED"
8291
92+ @staticmethod
93+ def _requires_2fa (response ):
94+ if not isinstance (response , dict ):
95+ return False
96+
97+ error_code = response .get ("error" , {}).get ("code" )
98+ return error_code == "TWO_FACTOR_AUTHENTICATION_REQUIRED"
99+
83100 def login (self , username : str , password : str ):
84101 path = "/user/login"
85102 secret = md5 (password .encode ("utf-8" )).hexdigest ()
86103 data = {"key" : username , "secret" : secret , "client_id" : 30100 }
87104
88- return self ._post (path , data )
105+ response = self ._post (path , data , add_picnic_headers = True )
106+
107+ if self ._requires_2fa (response ):
108+ raise Picnic2FARequired (
109+ message = response .get ("error" , {}).get (
110+ "message" , "Two-factor authentication required"
111+ ),
112+ response = response ,
113+ )
114+
115+ return response
116+
117+ def _post_2fa (self , path : str , data = None ):
118+ """POST for 2FA endpoints that may return empty (204) or JSON error bodies."""
119+ url = self ._base_url + path
120+ response = self .session .post (url , json = data , headers = _HEADERS )
121+
122+ if response .status_code == 204 or not response .content :
123+ return None
124+
125+ json_body = response .json ()
126+
127+ # This should not happen because password auth is already done
128+ # at this point, but just in case.
129+ if self ._contains_auth_error (json_body ):
130+ raise PicnicAuthError (
131+ f"Picnic authentication error: { json_body ['error' ].get ('message' )} "
132+ )
133+
134+ error = json_body .get ("error" , {})
135+ if error .get ("code" ):
136+ raise Picnic2FAError (
137+ message = error .get ("message" , "Two-factor authentication failed" ),
138+ code = error ["code" ],
139+ )
140+
141+ return json_body
142+
143+ def generate_2fa_code (self , channel : str = "SMS" ):
144+ """Request a 2FA code to be sent via the specified channel.
145+
146+ Args:
147+ channel: The delivery channel ("SMS" or "EMAIL").
148+
149+ Raises:
150+ Picnic2FAError: If the server returns an error (e.g. invalid channel).
151+ """
152+ path = "/user/2fa/generate"
153+ data = {"channel" : channel }
154+ self ._post_2fa (path , data )
155+
156+ def verify_2fa_code (self , code : str ):
157+ """Verify the 2FA code to complete authentication.
158+
159+ Args:
160+ code: The OTP code received via SMS or email.
161+
162+ Raises:
163+ Picnic2FAError: If the OTP code is invalid.
164+ """
165+ path = "/user/2fa/verify"
166+ data = {"otp" : code }
167+ self ._post_2fa (path , data )
89168
90169 def logged_in (self ):
91170 return self .session .authenticated
0 commit comments