File size: 12,042 Bytes
72eef4f
1b439a0
6af31ea
 
 
72eef4f
6af31ea
1b439a0
 
72eef4f
 
 
6af31ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b439a0
6af31ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aecb7e8
 
 
 
 
 
 
 
 
 
6af31ea
 
 
 
 
 
 
 
72eef4f
6af31ea
72eef4f
6af31ea
1b439a0
6af31ea
1b439a0
 
 
 
 
 
 
 
6af31ea
 
 
1b439a0
 
aecb7e8
 
 
 
 
 
 
 
 
 
1b439a0
 
 
 
 
aecb7e8
1b439a0
 
6af31ea
 
 
 
 
 
 
 
 
 
 
 
 
1b439a0
6af31ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aecb7e8
 
 
 
 
 
 
 
 
 
6af31ea
 
 
 
 
 
 
 
 
 
 
 
 
72eef4f
 
 
6af31ea
aecb7e8
6af31ea
 
 
 
 
 
 
1b439a0
6af31ea
 
 
 
1b439a0
 
 
 
 
 
 
 
 
 
72eef4f
6af31ea
 
 
 
72eef4f
 
6af31ea
 
 
72eef4f
aecb7e8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import logging
# os is no longer needed for token persistence
import json
import time
import requests
from functools import wraps
from flask import current_app, redirect, url_for, session, request
# Import the mongo instance from your extensions file
from .extensions import mongo

logger = logging.getLogger(__name__)


def make_zoho_api_request(method, url_path, params=None, json_data=None, files=None):
    """

    Makes a request to the Zoho Books API, handling authentication and token refresh.

    It now supports multipart/form-data for file uploads via the 'files' parameter.



    For JSON responses, it returns (data, None).

    For file/image downloads, it returns (raw_bytes, headers).

    """
    access_token = get_access_token()
    if not access_token:
        # This will be caught by the calling function's try-except block
        raise Exception("Zoho authentication failed: Could not retrieve a valid access token.")

    # Dynamically determine the base URL from the stored token information
    stored_token = _get_stored_token()
    if not stored_token or 'api_domain' not in stored_token:
        logger.error("Zoho 'api_domain' not found in stored token. The token is outdated or invalid. Please re-authenticate.")
        clear_zoho_token() # Force re-authentication
        raise Exception("Zoho configuration error: API domain missing. Please log in again.")

    api_domain = stored_token['api_domain']
    base_url = f"{api_domain}/books/v3"
    
    headers = {
        "Authorization": f"Zoho-oauthtoken {access_token}",
    }
    # For multipart/form-data (when 'files' is used), 'requests' sets the Content-Type automatically.
    # For JSON, we set it explicitly.
    if json_data:
        headers["Content-Type"] = "application/json"

    full_url = f"{base_url}{url_path}"

    logger.debug(f"Making Zoho API request: {method} {full_url}")
    # Pass the 'files' parameter to the requests call for handling file uploads
    response = requests.request(method, full_url, headers=headers, params=params, json=json_data, files=files)

    # Check for success (200 OK, 201 Created)
    if response.status_code not in [200, 201]:
        error_message = f"Zoho API Error ({response.status_code}): {response.text}"
        logger.error(error_message)
        response.raise_for_status()
    
    content_type = response.headers.get('content-type', '').lower()
    # The print statement below was already present, I've left it as is.
    print(f"Response Content-Type: {content_type}")

    # If the response is not JSON, treat it as a file/image download.
    if 'application/json' not in content_type:
        logger.info(f"Received non-JSON content-type '{content_type}', returning raw content and headers.")
        return response.content, response.headers

    # Handle JSON responses, returning a tuple for consistency.
    if response.text:
        return response.json(), None

    # Handle empty success responses
    return None, None


def initialize_zoho_sdk(grant_token=None, accounts_server_url=None):
    """

    Initializes Zoho authentication. If a grant_token is provided, it exchanges

    it for access/refresh tokens using the correct data center.

    """
    if grant_token:
        logger.info("Initializing Zoho Auth with new GRANT token.")
        try:
            config = current_app.config
            
            # Use the dynamically provided accounts_server_url or default to .in
            base_accounts_url = accounts_server_url if accounts_server_url else 'https://accounts.zohocloud.ca'
            token_url = f'{base_accounts_url}/oauth/v2/token'
            logger.info(f"Using token exchange URL: {token_url}")

            payload = {
                'code': grant_token,
                'client_id': config['ZOHO_CLIENT_ID'],
                'client_secret': config['ZOHO_CLIENT_SECRET'],
                'redirect_uri': config['ZOHO_REDIRECT_URL'],
                'grant_type': 'authorization_code'
            }
            response = requests.post(token_url, data=payload)
            response.raise_for_status()
            token_data = response.json()
            
            # --- EDIT: Print the received datacenter URL and store it ---
            api_domain = token_data.get("api_domain")
            if api_domain:
                logger.info(f"Received Zoho API Domain: {api_domain}")
                print(f"--- Successfully received Zoho API Domain: {api_domain} ---")
            else:
                logger.warning("Could not find 'api_domain' in the token response. API requests might fail.")
                print("--- WARNING: 'api_domain' not found in Zoho's response. ---")

            # Store the accounts server URL for refreshing the token later
            token_data['accounts_server'] = base_accounts_url

            # Log and print refresh token if returned
            if 'refresh_token' in token_data:
                logger.info("Received refresh_token from Zoho during token exchange.")
                logger.info(f"refresh_token: {token_data.get('refresh_token')}")
                print(f"--- Received Zoho refresh_token: {token_data.get('refresh_token')} ---")
            else:
                logger.info("No refresh_token present in Zoho response for this exchange.")
                print("--- No refresh_token received from Zoho in this exchange. ---")

            logger.info(f"Token data received: {token_data}")
            token_data['expires_at'] = int(time.time()) + token_data['expires_in']
            _save_token(token_data)
            logger.info("Successfully exchanged grant token and stored tokens.")
        except requests.exceptions.RequestException as e:
            error_text = e.response.text if e.response else "No response from server"
            logger.critical(f"Failed to exchange grant token: {error_text}", exc_info=True)
            raise Exception(f"Failed to get token from Zoho: {error_text}") from e
    else:
        logger.debug("initialize_zoho_sdk called without grant token. No action taken.")

def _get_stored_token():
    """Reads token data from MongoDB."""
    try:
        # Use a consistent identifier for the single token document
        token_doc = mongo.db.zoho_tokens.find_one({'_id': 'zoho_oauth_token'})
        if token_doc:
            # The _id is not part of the token data itself, remove it
            token_doc.pop('_id', None)
        return token_doc
    except Exception as e:
        logger.error(f"Error reading Zoho token from MongoDB: {e}", exc_info=True)
        return None

def _save_token(token_data):
    """Saves token data to MongoDB."""
    try:
        # Preserve an existing refresh_token if the current token_data doesn't contain one.
        existing = mongo.db.zoho_tokens.find_one({'_id': 'zoho_oauth_token'})
        if existing and 'refresh_token' in existing and 'refresh_token' not in token_data:
            # Preserve the previously stored refresh token
            token_data['refresh_token'] = existing['refresh_token']
            logger.info("Preserved existing refresh_token because the new token response did not include one.")
            print("--- Preserved existing Zoho refresh_token (not returned by current response). ---")

        # Set expires_at if present as int already; otherwise leave as-is
        # Ensure the DB document uses fixed _id
        mongo.db.zoho_tokens.update_one(
            {'_id': 'zoho_oauth_token'},
            {'$set': token_data},
            upsert=True
        )
        logger.info("Zoho token saved to MongoDB (upsert).")
    except Exception as e:
        logger.error(f"Error saving Zoho token to MongoDB: {e}", exc_info=True)

def _refresh_access_token():
    """Uses the refresh token to get a new access token from the correct data center."""
    stored_token = _get_stored_token()
    if not stored_token or 'refresh_token' not in stored_token:
        logger.error("Refresh token not found. Cannot refresh.")
        return None

    logger.info("Refreshing Zoho access token.")
    try:
        config = current_app.config
        
        # Use the stored accounts_server or default for backward compatibility
        base_accounts_url = stored_token.get('accounts_server', 'https://accounts.zohocloud.ca')
        token_url = f'{base_accounts_url}/oauth/v2/token'
        logger.info(f"Using token refresh URL: {token_url}")

        payload = {
            'refresh_token': stored_token['refresh_token'],
            'client_id': config['ZOHO_CLIENT_ID'],
            'client_secret': config['ZOHO_CLIENT_SECRET'],
            'grant_type': 'refresh_token'
        }
        response = requests.post(token_url, data=payload)
        response.raise_for_status()
        new_token_data = response.json()

        # Preserve crucial details not always present in the refresh response
        new_token_data['refresh_token'] = new_token_data.get('refresh_token', stored_token['refresh_token'])
        new_token_data['accounts_server'] = new_token_data.get('accounts_server', base_accounts_url)
        # Use .get for expires_in to avoid KeyError if absent
        new_token_data['expires_at'] = int(time.time()) + int(new_token_data.get('expires_in', 3600))

        # Log whether Zoho returned a refresh_token on refresh calls (often not returned).
        if 'refresh_token' in new_token_data and new_token_data.get('refresh_token') != stored_token.get('refresh_token'):
            logger.info("Zoho returned a new refresh_token during refresh.")
            print(f"--- Zoho returned a new refresh_token during refresh: {new_token_data.get('refresh_token')} ---")
        else:
            logger.info("Zoho did not return a new refresh_token during refresh; preserving existing one.")
            print("--- Zoho did not return a new refresh_token during refresh; using stored refresh_token. ---")

        _save_token(new_token_data)
        logger.info("Successfully refreshed and saved new access token.")
        return new_token_data
    except requests.exceptions.RequestException as e:
        error_text = e.response.text if e.response else "No response from server"
        logger.critical(f"Failed to refresh Zoho access token: {error_text}", exc_info=True)
        clear_zoho_token()
        return None

def get_access_token():
    """Returns a valid access token, refreshing it if necessary."""
    token = _get_stored_token()
    if not token:
        return None

    if token.get('expires_at', 0) < time.time() + 60:
        logger.info("TRYING TO GENERATE A REFRESH TOKEN")
        token = _refresh_access_token()
        if not token:
            return None

    return token.get('access_token')

def is_zoho_token_available():
    """Checks if a refresh token has been persisted in MongoDB."""
    token = _get_stored_token()
    return token is not None and 'refresh_token' in token

def clear_zoho_token():
    """Removes the persisted token from MongoDB to effectively log out."""
    try:
        result = mongo.db.zoho_tokens.delete_one({'_id': 'zoho_oauth_token'})
        if result.deleted_count > 0:
            logger.info("Zoho token removed from MongoDB successfully.")
        else:
            logger.warning("Attempted to clear Zoho token, but none was found in MongoDB.")
    except Exception as e:
        logger.error(f"Error removing Zoho token from MongoDB: {e}", exc_info=True)


def zoho_token_required(function):
    """

    Decorator to ensure a valid Zoho token exists. If not, redirects to the login page.

    """
    @wraps(function)
    def decorator(*args, **kwargs):
        if not is_zoho_token_available():
            session['next_url'] = request.url
            return redirect(url_for("zoho.login"))
        return function(*args, **kwargs)
    return decorator