import "dart:async"; import "dart:convert"; import "dart:io"; import "package:flutter/foundation.dart"; import "package:http/http.dart" as http; import "package:intl/intl.dart"; import "package:inventree/main.dart"; import "package:one_context/one_context.dart"; import "package:open_filex/open_filex.dart"; import "package:cached_network_image/cached_network_image.dart"; import "package:flutter/material.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:flutter_cache_manager/flutter_cache_manager.dart"; import "package:path_provider/path_provider.dart"; import "package:inventree/api_form.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/preferences.dart"; import "package:inventree/l10.dart"; import "package:inventree/helpers.dart"; import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/notification.dart"; import "package:inventree/inventree/status_codes.dart"; import "package:inventree/inventree/sentry.dart"; import "package:inventree/user_profile.dart"; import "package:inventree/widget/dialogs.dart"; import "package:inventree/widget/snacks.dart"; /* * Class representing an API response from the server */ class APIResponse { APIResponse({this.url = "", this.method = "", this.statusCode = -1, this.error = "", this.data = const {}}); int statusCode = -1; String url = ""; String method = ""; String error = ""; String errorDetail = ""; dynamic data = {}; // Request is "valid" if a statusCode was returned bool isValid() => (statusCode >= 0) && (statusCode < 500); bool successful() => (statusCode >= 200) && (statusCode < 300); bool redirected() => (statusCode >= 300) && (statusCode < 400); bool clientError() => (statusCode >= 400) && (statusCode < 500); bool serverError() => statusCode >= 500; bool isMap() { return data != null && data is Map; } Map asMap() { if (isMap()) { return data as Map; } else { // Empty map return {}; } } bool isList() { return data != null && data is List; } List asList() { if (isList()) { return data as List; } else { return []; } } /* * Helper function to interpret response, and return a list. * Handles case where the response is paginated, or a complete set of results */ List resultsList() { if (isList()) { return asList(); } else if (isMap()) { var response = asMap(); if (response.containsKey("results")) { return response["results"] as List; } else { return []; } } else { return []; } } } /* * Custom FileService for caching network images * Requires a custom badCertificateCallback, * so we can accept "dodgy" (e.g. self-signed) certificates */ class InvenTreeFileService extends FileService { InvenTreeFileService({HttpClient? client, bool strictHttps = false}) { _client = client ?? HttpClient(); if (_client != null) { _client!.badCertificateCallback = (cert, host, port) { print("BAD CERTIFICATE CALLBACK FOR IMAGE REQUEST"); return !strictHttps; }; } } HttpClient? _client; @override Future get(String url, {Map? headers}) async { final Uri resolved = Uri.base.resolve(url); final HttpClientRequest req = await _client!.getUrl(resolved); if (headers != null) { headers.forEach((key, value) { req.headers.add(key, value); }); } final HttpClientResponse httpResponse = await req.close(); final http.StreamedResponse _response = http.StreamedResponse( httpResponse.timeout(Duration(seconds: 60)), httpResponse.statusCode, contentLength: httpResponse.contentLength < 0 ? 0 : httpResponse.contentLength, reasonPhrase: httpResponse.reasonPhrase, isRedirect: httpResponse.isRedirect, ); return HttpGetResponse(_response); } } /* * InvenTree API - Access to the InvenTree REST interface. * * InvenTree implements token-based authentication, which is * initialised using a username:password combination. */ /* * API class which manages all communication with the InvenTree server */ class InvenTreeAPI { factory InvenTreeAPI() { return _api; } InvenTreeAPI._internal(); // Ensure we only ever create a single instance of the API class static final InvenTreeAPI _api = InvenTreeAPI._internal(); // List of callback functions to trigger when the connection status changes List _statusCallbacks = []; // Register a callback function to be notified when the connection status changes void registerCallback(Function() func) => _statusCallbacks.add(func); void _connectionStatusChanged() { for (Function() func in _statusCallbacks) { // Call the function func(); } } // Minimum required API version for server // 2023-03-04 static const _minApiVersion = 100; bool _strictHttps = false; // Endpoint for requesting an API token static const _URL_TOKEN = "user/token/"; static const _URL_ROLES = "user/roles/"; static const _URL_ME = "user/me/"; // Accessors for various url endpoints String get baseUrl { String url = profile?.server ?? ""; if (!url.endsWith("/")) { url += "/"; } return url; } String _makeUrl(String url) { // Strip leading slash if (url.startsWith("/")) { url = url.substring(1, url.length); } // Prevent double-slash url = url.replaceAll("//", "/"); return baseUrl + url; } String get apiUrl => _makeUrl("/api/"); String get imageUrl => _makeUrl("/image/"); String makeApiUrl(String endpoint) { if (endpoint.startsWith("/api/") || endpoint.startsWith("api/")) { return _makeUrl(endpoint); } else { return _makeUrl("/api/${endpoint}"); } } String makeUrl(String endpoint) => _makeUrl(endpoint); UserProfile? profile; // Available user roles are loaded when connecting to the server Map roles = {}; // Available user permissions are loaded when connecting to the server Map permissions = {}; // Profile authentication token String get token => profile?.token ?? ""; bool get hasToken => token.isNotEmpty; String? get serverAddress { return profile?.server; } /* * Check server connection and display messages if not connected. * Useful as a precursor check before performing operations. */ bool checkConnection() { // Is the server connected? if (!isConnected()) { showSnackIcon( L10().notConnected, success: false, icon: TablerIcons.server ); return false; } // Finally return true; } // Map of user information Map userInfo = {}; String get username => (userInfo["username"] ?? "") as String; // Map of server information Map serverInfo = {}; String get serverInstance => (serverInfo["instance"] ?? "") as String; String get serverVersion => (serverInfo["version"] ?? "") as String; int get apiVersion => (serverInfo["apiVersion"] ?? 1) as int; // Consolidated search request API v102 or newer bool get supportsConsolidatedSearch => apiVersion >= 102; // ReturnOrder supports API v104 or newer bool get supportsReturnOrders => apiVersion >= 104; // "Contact" model exposed to API bool get supportsContactModel => apiVersion >= 104; // Status label endpoints API v105 or newer bool get supportsStatusLabelEndpoints => apiVersion >= 105; // Regex search API v106 or newer bool get supportsRegexSearch => apiVersion >= 106; // Order barcodes API v107 or newer bool get supportsOrderBarcodes => apiVersion >= 107; // Project codes require v109 or newer bool get supportsProjectCodes => apiVersion >= 109; // Does the server support extra fields on stock adjustment actions? bool get supportsStockAdjustExtraFields => apiVersion >= 133; // Does the server support receiving items against a PO using barcodes? bool get supportsBarcodePOReceiveEndpoint => apiVersion >= 139; // Does the server support adding line items to a PO using barcodes? bool get supportsBarcodePOAddLineEndpoint => apiVersion >= 153; // Does the server support allocating stock to sales order using barcodes? bool get supportsBarcodeSOAllocateEndpoint => apiVersion >= 160; // Does the server support the "modern" test results API // Ref: https://github.com/inventree/InvenTree/pull/6430/ bool get supportsModernTestResults => apiVersion >= 169; // Does the server support "null" top-level filtering for PartCategory and StockLocation endpoints? bool get supportsNullTopLevelFiltering => apiVersion < 174; // Does the server support "active" status on Company and SupplierPart API endpoints? bool get supportsCompanyActiveStatus => apiVersion >= 189; // Does the server support the "modern" (consolidated) label printing API? bool get supportsModernLabelPrinting => apiVersion >= 201; // Does the server support the "modern" (consolidated) attachment API? // Ref: https://github.com/inventree/InvenTree/pull/7420 bool get supportsModernAttachments => apiVersion >= 207; bool get supportsUserPermissions => apiVersion >= 207; // Does the server support the "destination" field on the PurchaseOrder model? // Ref: https://github.com/inventree/InvenTree/pull/8403 bool get supportsPurchaseOrderDestination => apiVersion >= 276; // Does the server support the "start_date" field for orders? // Ref: https://github.com/inventree/InvenTree/pull/8966 bool get supportsStartDate => apiVersion >= 306; // Supports separate search against "supplier" / "customer" / "manufacturer" bool get supportsSplitCompanySearch => apiVersion >= 315; // Cached list of plugins (refreshed when we connect to the server) List _plugins = []; // Return a list of plugins enabled on the server // Can optionally filter by a particular 'mixin' type List getPlugins({String mixin = ""}) { List plugins = []; for (var plugin in _plugins) { // Do we wish to filter by a particular mixin? if (mixin.isNotEmpty) { if (!plugin.supportsMixin(mixin)) { continue; } } plugins.add(plugin); } // Return list of matching plugins return plugins; } // Test if the provided plugin mixin is supported by any active plugins bool supportsMixin(String mixin) => getPlugins(mixin: mixin).isNotEmpty; // Connection status flag - set once connection has been validated bool _connected = false; bool _connecting = false; bool isConnected() { return profile != null && _connected && baseUrl.isNotEmpty && hasToken; } bool isConnecting() { return !isConnected() && _connecting; } /* * Perform the required login steps, in sequence. * Internal function, called by connectToServer() * * Performs the following steps: * * 1. Check the api/ endpoint to see if the sever exists * 2. If no token available, perform user authentication * 2. Check the api/user/me/ endpoint to see if the user is authenticated * 3. If not authenticated, purge token, and exit * 4. Request user roles * 5. Request information on available plugins */ Future _connectToServer() async { if (!await _checkServer()) { return false; } if (!hasToken) { return false; } if (!await _checkAuth()) { showServerError(_URL_ME, L10().serverNotConnected, L10().serverAuthenticationError); // Invalidate the token if (profile != null) { profile!.token = ""; await UserProfileDBManager().updateProfile(profile!); } return false; } if (!await _fetchRoles()) { return false; } if (!await _fetchPlugins()) { return false; } // Finally, connected return true; } /* * Check that the remote server is available. * Ping the api/ endpoint, which does not require user authentication */ Future _checkServer() async { String address = profile?.server ?? ""; if (address.isEmpty) { showSnackIcon( L10().incompleteDetails, icon: TablerIcons.exclamation_circle, success: false ); return false; } if (!address.endsWith("/")) { address = address + "/"; } // Cache the "strictHttps" setting, so we can use it later without async requirement _strictHttps = await InvenTreeSettingsManager().getValue(INV_STRICT_HTTPS, false) as bool; debug("Connecting to ${apiUrl}"); APIResponse response = await get("", expectedStatusCode: 200); if (!response.successful()) { debug("Server returned invalid response: ${response.statusCode}"); showStatusCodeError(apiUrl, response.statusCode, details: response.data.toString()); return false; } Map _data = response.asMap(); serverInfo = {..._data}; if (serverVersion.isEmpty) { showServerError( apiUrl, L10().missingData, L10().serverMissingData, ); return false; } if (apiVersion < _minApiVersion) { String message = L10().serverApiVersion + ": ${apiVersion}"; message += "\n"; message += L10().serverApiRequired + ": ${_minApiVersion}"; message += "\n\n"; message += "Ensure your InvenTree server version is up to date!"; showServerError( apiUrl, L10().serverOld, message, ); return false; } // At this point, we have a server which is responding return true; } /* * Check that the user is authenticated * Fetch the user information */ Future _checkAuth() async { debug("Checking user auth @ ${_URL_ME}"); userInfo.clear(); final response = await get(_URL_ME); if (response.successful() && response.statusCode == 200) { userInfo = response.asMap(); return true; } else { debug("Auth request failed: Server returned status ${response.statusCode}"); if (response.data != null) { debug("Server response: ${response.data.toString()}"); } return false; } } /* * Fetch a token from the server, * with a temporary authentication header */ Future fetchToken(UserProfile userProfile, String username, String password) async { debug("Fetching user token from ${userProfile.server}"); profile = userProfile; // Form a name to request the token with String platform_name = "inventree-mobile-app"; final deviceInfo = await getDeviceInfo(); if (Platform.isAndroid) { platform_name += "-android"; } else if (Platform.isIOS) { platform_name += "-ios"; } else if (Platform.isMacOS) { platform_name += "-macos"; } else if (Platform.isLinux) { platform_name += "-linux"; } else if (Platform.isWindows) { platform_name += "-windows"; } if (deviceInfo.containsKey("name")) { platform_name += "-" + (deviceInfo["name"] as String); } if (deviceInfo.containsKey("model")) { platform_name += "-" + (deviceInfo["model"] as String); } if (deviceInfo.containsKey("systemVersion")) { platform_name += "-" + (deviceInfo["systemVersion"] as String); } // Construct auth header from username and password String authHeader = "Basic " + base64Encode(utf8.encode("${username}:${password}")); // Perform request to get a token final response = await get( _URL_TOKEN, params: { "name": platform_name}, headers: { HttpHeaders.authorizationHeader: authHeader} ); // Invalid response if (!response.successful()) { switch (response.statusCode) { case 401: case 403: showServerError( apiUrl, L10().serverAuthenticationError, L10().invalidUsernamePassword, ); break; default: showStatusCodeError(apiUrl, response.statusCode); break; } debug("Token request failed: STATUS ${response.statusCode}"); if (response.data != null) { debug("Response data: ${response.data.toString()}"); } } final data = response.asMap(); if (!data.containsKey("token")) { showServerError( apiUrl, L10().tokenMissing, L10().tokenMissingFromResponse, ); } // Save the token to the user profile userProfile.token = (data["token"] ?? "") as String; debug("Received token from server: ${userProfile.token}"); await UserProfileDBManager().updateProfile(userProfile); return response; } void disconnectFromServer() { debug("API : disconnectFromServer()"); _connected = false; _connecting = false; profile = null; // Clear received settings _globalSettings.clear(); _userSettings.clear(); roles.clear(); _plugins.clear(); serverInfo.clear(); _connectionStatusChanged(); } /* Public facing connection function. */ Future connectToServer(UserProfile prf) async { // Ensure server is first disconnected disconnectFromServer(); profile = prf; if (profile == null) { showSnackIcon( L10().profileSelect, success: false, icon: TablerIcons.exclamation_circle ); return false; } // Cancel notification timer _notification_timer?.cancel(); _connecting = true; _connectionStatusChanged(); // Perform the actual connection routine _connected = await _connectToServer(); _connecting = false; if (_connected) { showSnackIcon( L10().serverConnected, icon: TablerIcons.server, success: true, ); if (_notification_timer == null) { debug("starting notification timer"); _notification_timer = Timer.periodic( Duration(seconds: 5), (timer) { _refreshNotifications(); }); } } _connectionStatusChanged(); fetchStatusCodeData(); return _connected; } /* * Request the user roles (permissions) from the InvenTree server */ Future _fetchRoles() async { roles.clear(); debug("API: Requesting user role data"); final response = await get(_URL_ROLES, expectedStatusCode: 200); if (!response.successful()) { return false; } var data = response.asMap(); if (!data.containsKey("roles")) { roles = {}; permissions = {}; showServerError( apiUrl, L10().serverError, L10().errorUserRoles, ); return false; } roles = (data["roles"] ?? {}) as Map; if (supportsUserPermissions && data.containsKey("permissions")) { permissions = (data["permissions"] ?? {}) as Map; } else { permissions = {}; } return true; } // Request plugin information from the server Future _fetchPlugins() async { _plugins.clear(); debug("API: getPluginInformation()"); // Request a list of plugins from the server final List results = await InvenTreePlugin().list(); for (var result in results) { if (result is InvenTreePlugin) { if (result.active) { // Only add plugins that are active _plugins.add(result); } } } return true; } /* * Check if the user has the given role.permission assigned * e.g. "sales_order", "change" */ bool checkRole(String role, String permission) { if (!_connected) { return false; } // If we do not have enough information, assume permission is allowed if (roles.isEmpty) { debug("checkRole - no roles defined!"); return true; } if (!roles.containsKey(role)) { debug("checkRole - role '$role' not found!"); return true; } if (roles[role] == null) { debug("checkRole - role '$role' is null!"); return false; } try { List perms = List.from(roles[role] as List); return perms.contains(permission); } catch (error, stackTrace) { if (error is TypeError) { // Ignore TypeError } else { // Unknown error - report it! sentryReportError( "api.checkRole", error, stackTrace, context: { "role": role, "permission": permission, "error": error.toString(), } ); } // Unable to determine permission - assume true? return true; } } /* * Check if the user has the particular model permission assigned * e.g. "company", "add" */ bool checkPermission(String model, String permission) { if (!_connected) { return false; } if (permissions.isEmpty) { // Not enough information available - default to True return true; } if (!permissions.containsKey(model)) { debug("checkPermission - model '$model' not found!"); return false; } if (permissions[model] == null) { debug("checkPermission - model '$model' is null!"); return false; } try { List perms = List.from(permissions[model] as List); return perms.contains(permission); } catch (error, stackTrace) { if (error is TypeError) { // Ignore TypeError } else { // Unknown error - report it! sentryReportError( "api.checkPermission", error, stackTrace, context: { "model": model, "permission": permission, "error": error.toString(), } ); } // Unable to determine permission - assume true? return true; } } // Perform a PATCH request Future patch(String url, {Map body = const {}, int? expectedStatusCode}) async { Map _body = body; HttpClientRequest? request = await apiRequest(url, "PATCH"); if (request == null) { // Return an "invalid" APIResponse return APIResponse( url: url, method: "PATCH", error: "HttpClientRequest is null" ); } return completeRequest( request, data: json.encode(_body), statusCode: expectedStatusCode ); } /* * Download a file from the given URL */ Future downloadFile(String url, {bool openOnDownload = true}) async { if (url.isEmpty) { // No URL provided for download return; } // Find the local downlods directory final Directory dir = await getTemporaryDirectory(); String filename = url.split("/").last; String local_path = dir.path + "/" + filename; Uri? _uri = Uri.tryParse(makeUrl(url)); if (_uri == null) { showServerError(url, L10().invalidHost, L10().invalidHostDetails); return; } if (_uri.host.isEmpty) { showServerError(url, L10().invalidHost, L10().invalidHostDetails); return; } HttpClientRequest? _request; final bool strictHttps = await InvenTreeSettingsManager().getValue(INV_STRICT_HTTPS, false) as bool; var client = createClient(url, strictHttps: strictHttps); // Attempt to open a connection to the server try { _request = await client.openUrl("GET", _uri).timeout(Duration(seconds: 10)); // Set headers defaultHeaders().forEach((key, value) { _request?.headers.set(key, value); }); } on SocketException catch (error) { debug("SocketException at ${url}: ${error.toString()}"); showServerError(url, L10().connectionRefused, error.toString()); return; } on TimeoutException { debug("TimeoutException at ${url}"); showTimeoutError(url); return; } on HandshakeException catch (error) { debug("HandshakeException at ${url}:"); debug(error.toString()); showServerError(url, L10().serverCertificateError, error.toString()); return; } catch (error, stackTrace) { debug("Server error at ${url}: ${error.toString()}"); showServerError(url, L10().serverError, error.toString()); sentryReportError( "api.downloadFile : client.openUrl", error, stackTrace, ); return; } try { final response = await _request.close(); if (response.statusCode == 200) { var bytes = await consolidateHttpClientResponseBytes(response); File localFile = File(local_path); await localFile.writeAsBytes(bytes); if (openOnDownload) { OpenFilex.open(local_path); } } else { showStatusCodeError(url, response.statusCode); } } on SocketException catch (error) { showServerError(url, L10().connectionRefused, error.toString()); } on TimeoutException { showTimeoutError(url); } catch (error, stackTrace) { debug("Error downloading image:"); debug(error.toString()); showServerError(url, L10().downloadError, error.toString()); sentryReportError( "api.downloadFile : client.closeRequest", error, stackTrace, ); } } /* * Upload a file to the given URL */ Future uploadFile(String url, File f, {String name = "attachment", String method="POST", Map? fields}) async { var _url = makeApiUrl(url); var request = http.MultipartRequest(method, Uri.parse(_url)); request.headers.addAll(defaultHeaders()); if (fields != null) { fields.forEach((String key, dynamic value) { if (value == null) { request.fields[key] = ""; } else { request.fields[key] = value.toString(); } }); } var _file = await http.MultipartFile.fromPath(name, f.path); request.files.add(_file); APIResponse response = APIResponse( url: url, method: method, ); String jsondata = ""; try { var httpResponse = await request.send().timeout(Duration(seconds: 120)); response.statusCode = httpResponse.statusCode; jsondata = await httpResponse.stream.bytesToString(); response.data = json.decode(jsondata); // Report a server-side error if (response.statusCode == 500) { sentryReportMessage( "Server error in uploadFile()", context: { "url": url, "method": request.method, "name": name, "statusCode": response.statusCode.toString(), "requestHeaders": request.headers.toString(), "responseHeaders": httpResponse.headers.toString(), } ); } } on SocketException catch (error) { showServerError(url, L10().connectionRefused, error.toString()); response.error = "SocketException"; response.errorDetail = error.toString(); } on FormatException { showServerError( url, L10().formatException, L10().formatExceptionJson + ":\n${jsondata}" ); sentryReportMessage( "Error decoding JSON response from server", context: { "method": "uploadFile", "url": url, "statusCode": response.statusCode.toString(), "data": jsondata, } ); } on TimeoutException { showTimeoutError(url); response.error = "TimeoutException"; } catch (error, stackTrace) { showServerError(url, L10().serverError, error.toString()); sentryReportError( "api.uploadFile", error, stackTrace ); response.error = "UnknownError"; response.errorDetail = error.toString(); } return response; } /* * Perform a HTTP OPTIONS request, * to get the available fields at a given endpoint. * We send this with the currently selected "locale", * so that (hopefully) the field messages are correctly translated */ Future options(String url) async { HttpClientRequest? request = await apiRequest(url, "OPTIONS"); if (request == null) { // Return an "invalid" APIResponse return APIResponse( url: url, method: "OPTIONS" ); } return completeRequest(request); } /* * Perform a HTTP POST request * Returns a json object (or null if unsuccessful) */ Future post(String url, {Map body = const {}, int? expectedStatusCode=201}) async { HttpClientRequest? request = await apiRequest(url, "POST"); if (request == null) { // Return an "invalid" APIResponse return APIResponse( url: url, method: "POST" ); } return completeRequest( request, data: json.encode(body), statusCode: expectedStatusCode ); } /* * Perform a request to link a custom barcode to a particular item */ Future linkBarcode(Map body) async { HttpClientRequest? request = await apiRequest("/barcode/link/", "POST"); if (request == null) { return false; } final response = await completeRequest( request, data: json.encode(body), statusCode: 200 ); return response.isValid() && response.statusCode == 200; } /* * Perform a request to unlink a custom barcode from a particular item */ Future unlinkBarcode(Map body) async { HttpClientRequest? request = await apiRequest("/barcode/unlink/", "POST"); if (request == null) { return false; } final response = await completeRequest( request, data: json.encode(body), statusCode: 200, ); return response.isValid() && response.statusCode == 200; } HttpClient createClient(String url, {bool strictHttps = false}) { var client = HttpClient(); client.badCertificateCallback = (X509Certificate cert, String host, int port) { if (strictHttps) { showServerError( url, L10().serverCertificateError, L10().serverCertificateInvalid, ); return false; } // Strict HTTPs not enforced, so we'll ignore the bad cert return true; }; // Set the connection timeout client.connectionTimeout = Duration(seconds: 30); return client; } /* * Initiate a HTTP request to the server * * @param url is the API endpoint * @param method is the HTTP method e.g. "POST" / "PATCH" / "GET" etc; * @param params is the request parameters */ Future apiRequest( String url, String method, { Map urlParams = const {}, Map headers = const {}, } ) async { var _url = makeApiUrl(url); if (_url.isEmpty) { showServerError( url, L10().invalidHost, L10().invalidHostDetails ); return null; } // Add any required query parameters to the URL using ?key=value notation if (urlParams.isNotEmpty) { String query = "?"; urlParams.forEach((k, v) => query += "${k}=${v}&"); _url += query; } // Remove extraneous character if present if (_url.endsWith("&")) { _url = _url.substring(0, _url.length - 1); } Uri? _uri = Uri.tryParse(_url); if (_uri == null || _uri.host.isEmpty) { showServerError( _url, L10().invalidHost, L10().invalidHostDetails ); return null; } HttpClientRequest? _request; final bool strictHttps = await InvenTreeSettingsManager().getValue(INV_STRICT_HTTPS, false) as bool; var client = createClient(url, strictHttps: strictHttps); // Attempt to open a connection to the server try { _request = await client.openUrl(method, _uri).timeout(Duration(seconds: 10)); // Default headers defaultHeaders().forEach((key, value) { _request?.headers.set(key, value); }); // Custom headers headers.forEach((key, value) { _request?.headers.set(key, value); }); return _request; } on SocketException catch (error) { debug("SocketException at ${url}: ${error.toString()}"); showServerError(url, L10().connectionRefused, error.toString()); return null; } on TimeoutException { debug("TimeoutException at ${url}"); showTimeoutError(url); return null; } on OSError catch (error) { debug("OSError at ${url}: ${error.toString()}"); showServerError(url, L10().connectionRefused, error.toString()); return null; } on CertificateException catch (error) { debug("CertificateException at ${url}:"); debug(error.toString()); showServerError(url, L10().serverCertificateError, error.toString()); return null; } on HandshakeException catch (error) { debug("HandshakeException at ${url}:"); debug(error.toString()); showServerError(url, L10().serverCertificateError, error.toString()); return null; } catch (error, stackTrace) { debug("Server error at ${url}: ${error.toString()}"); showServerError(url, L10().serverError, error.toString()); sentryReportError( "api.apiRequest : openUrl", error, stackTrace, context: { "url": url, "method": method, } ); return null; } } /* * Complete an API request, and return an APIResponse object */ Future completeRequest(HttpClientRequest request, {String? data, int? statusCode, bool ignoreResponse = false}) async { if (data != null && data.isNotEmpty) { var encoded_data = utf8.encode(data); request.headers.set(HttpHeaders.contentLengthHeader, encoded_data.length.toString()); request.add(encoded_data); } APIResponse response = APIResponse( method: request.method, url: request.uri.toString() ); String url = request.uri.toString(); try { HttpClientResponse? _response = await request.close().timeout(Duration(seconds: 10)); response.statusCode = _response.statusCode; // If the server returns a server error code, alert the user if (_response.statusCode >= 500) { showStatusCodeError(url, _response.statusCode); // Some server errors are not ones for us to worry about! switch (_response.statusCode) { case 502: // Bad gateway case 503: // Service unavailable case 504: // Gateway timeout break; default: // Any other error code sentryReportMessage( "Server error", context: { "url": request.uri.toString(), "method": request.method, "statusCode": _response.statusCode.toString(), "requestHeaders": request.headers.toString(), "responseHeaders": _response.headers.toString(), "responseData": response.data.toString(), } ); break; } } else { response.data = ignoreResponse ? {} : await responseToJson(url, _response) ?? {}; // First check that the returned status code is what we expected if (statusCode != null && statusCode != _response.statusCode) { showStatusCodeError(url, _response.statusCode, details: response.data.toString()); } } } on HttpException catch (error) { showServerError(url, L10().serverError, error.toString()); response.error = "HTTPException"; response.errorDetail = error.toString(); } on SocketException catch (error) { showServerError(url, L10().connectionRefused, error.toString()); response.error = "SocketException"; response.errorDetail = error.toString(); } on CertificateException catch (error) { debug("CertificateException at ${request.uri.toString()}:"); debug(error.toString()); showServerError(url, L10().serverCertificateError, error.toString()); } on TimeoutException { showTimeoutError(url); response.error = "TimeoutException"; } catch (error, stackTrace) { showServerError(url, L10().serverError, error.toString()); sentryReportError("api.completeRequest", error, stackTrace); response.error = "UnknownError"; response.errorDetail = error.toString(); } return response; } /* * Convert a HttpClientResponse response object to JSON */ dynamic responseToJson(String url, HttpClientResponse response) async { String body = await response.transform(utf8.decoder).join(); try { var data = json.decode(body); return data ?? {}; } on FormatException { switch (response.statusCode) { case 400: case 401: case 403: case 404: // Ignore for unauthorized pages break; case 502: case 503: case 504: // Ignore for server errors break; default: sentryReportMessage( "Error decoding JSON response from server", context: { "headers": response.headers.toString(), "statusCode": response.statusCode.toString(), "data": body.toString(), "endpoint": url, } ); break; } showServerError( url, L10().formatException, L10().formatExceptionJson + ":\n${body}" ); // Return an empty map return {}; } } /* * Perform a HTTP GET request * Returns a json object (or null if did not complete) */ Future get(String url, {Map params = const {}, Map headers = const {}, int? expectedStatusCode=200}) async { HttpClientRequest? request = await apiRequest( url, "GET", urlParams: params, headers: headers, ); if (request == null) { // Return an "invalid" APIResponse return APIResponse( url: url, method: "GET", error: "HttpClientRequest is null", ); } return completeRequest(request); } /* * Perform a HTTP DELETE request */ Future delete(String url) async { HttpClientRequest? request = await apiRequest( url, "DELETE", ); if (request == null) { // Return an "invalid" APIResponse object return APIResponse( url: url, method: "DELETE", error: "HttpClientRequest is null", ); } return completeRequest( request, ignoreResponse: true, ); } // Find the current locale code for the running app String get currentLocale { if (hasContext()) { // Try to get app context BuildContext? context = OneContext().context; if (context != null) { Locale? locale = InvenTreeApp .of(context) ?.locale; if (locale != null) { return locale.languageCode; //.toString(); } } } // Fallback value return Intl.getCurrentLocale(); } // Return a list of request headers Map defaultHeaders() { Map headers = {}; if (hasToken) { headers[HttpHeaders.authorizationHeader] = _authorizationHeader(); } headers[HttpHeaders.acceptHeader] = "application/json"; headers[HttpHeaders.contentTypeHeader] = "application/json"; headers[HttpHeaders.acceptLanguageHeader] = currentLocale; return headers; } // Construct a token authorization header String _authorizationHeader() { if (token.isNotEmpty) { return "Token ${token}"; } else { return ""; } } static String get staticImage => "/static/img/blank_image.png"; static String get staticThumb => "/static/img/blank_image.thumbnail.png"; CachedNetworkImage? getThumbnail(String imageUrl, {double size = 40, bool hideIfNull = false}) { if (hideIfNull) { if (imageUrl.isEmpty) { return null; } } try { return getImage( imageUrl, width: size, height: size ); } catch (error, stackTrace) { sentryReportError("_getThumbnail", error, stackTrace); return null; } } /* * Load image from the InvenTree server, * or from local cache (if it has been cached!) */ CachedNetworkImage getImage(String imageUrl, {double? height, double? width}) { if (imageUrl.isEmpty) { imageUrl = staticImage; } String url = makeUrl(imageUrl); const key = "inventree_network_image"; CacheManager manager = CacheManager( Config( key, fileService: InvenTreeFileService( strictHttps: _strictHttps, ), ) ); return CachedNetworkImage( imageUrl: url, placeholder: (context, url) => CircularProgressIndicator(), errorWidget: (context, url, error) => Icon(TablerIcons.circle_x, color: COLOR_DANGER), httpHeaders: defaultHeaders(), height: height, width: width, cacheManager: manager, ); } // Keep a record of which settings we have received from the server Map _globalSettings = {}; Map _userSettings = {}; Future getGlobalSetting(String key) async { InvenTreeGlobalSetting? setting = _globalSettings[key]; if ((setting != null) && setting.reloadedWithin(Duration(minutes: 5))) { return setting.value; } final response = await InvenTreeGlobalSetting().getModel(key); if (response is InvenTreeGlobalSetting) { response.lastReload = DateTime.now(); _globalSettings[key] = response; return response.value; } else { return ""; } } // Return a boolean global setting value Future getGlobalBooleanSetting(String key, { bool backup = false }) async { String value = await getGlobalSetting(key); if (value.isEmpty) { return backup; } return value.toLowerCase().trim() == "true"; } Future getUserSetting(String key) async { InvenTreeUserSetting? setting = _userSettings[key]; if ((setting != null) && setting.reloadedWithin(Duration(minutes: 5))) { return setting.value; } final response = await InvenTreeUserSetting().getModel(key); if (response is InvenTreeUserSetting) { response.lastReload = DateTime.now(); _userSettings[key] = response; return response.value; } else { return ""; } } // Return a boolean user setting value Future getUserBooleanSetting(String key) async { String value = await getUserSetting(key); return value.toLowerCase().trim() == "true"; } /* * Send a request to the server to locate / identify either a StockItem or StockLocation */ Future locateItemOrLocation(BuildContext context, {int? item, int? location}) async { var plugins = getPlugins(mixin: "locate"); if (plugins.isEmpty) { // TODO: Error message return; } String plugin_name = ""; if (plugins.length == 1) { plugin_name = plugins.first.key; } else { // User selects which plugin to use List> plugin_options = []; for (var plugin in plugins) { plugin_options.add({ "display_name": plugin.humanName, "value": plugin.key, }); } Map fields = { "plugin": { "label": L10().plugin, "type": "choice", "value": plugins.first.key, "choices": plugin_options, "required": true, } }; await launchApiForm( context, L10().locateLocation, "", fields, icon: TablerIcons.location_search, onSuccess: (Map data) async { plugin_name = (data["plugin"] ?? "") as String; } ); } Map body = { "plugin": plugin_name, }; if (item != null) { body["item"] = item.toString(); } if (location != null) { body["location"] = location.toString(); } post( "/api/locate/", body: body, expectedStatusCode: 200, ).then((APIResponse response) { if (response.successful()) { showSnackIcon( L10().requestSuccessful, success: true, ); } }); } // Keep an internal map of status codes Map _status_codes = {}; // Return a status class based on provided URL InvenTreeStatusCode _get_status_class(String url) { if (!_status_codes.containsKey(url)) { _status_codes[url] = InvenTreeStatusCode(url); } return _status_codes[url]!; } // Accessors methods for various status code classes InvenTreeStatusCode get StockHistoryStatus => _get_status_class("stock/track/status/"); InvenTreeStatusCode get StockStatus => _get_status_class("stock/status/"); InvenTreeStatusCode get PurchaseOrderStatus => _get_status_class("order/po/status/"); InvenTreeStatusCode get SalesOrderStatus => _get_status_class("order/so/status/"); void clearStatusCodeData() { StockHistoryStatus.data.clear(); StockStatus.data.clear(); PurchaseOrderStatus.data.clear(); SalesOrderStatus.data.clear(); } Future fetchStatusCodeData({bool forceReload = true}) async { StockHistoryStatus.load(forceReload: forceReload); StockStatus.load(forceReload: forceReload); PurchaseOrderStatus.load(forceReload: forceReload); SalesOrderStatus.load(forceReload: forceReload); } int notification_counter = 0; Timer? _notification_timer; /* * Update notification counter (called periodically) */ Future _refreshNotifications() async { if (!isConnected()) { return; } InvenTreeNotification().count(filters: {"read": "false"}).then((int n) { notification_counter = n; }); } }