|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | + |
| 3 | +import json |
| 4 | + |
| 5 | +import requests |
| 6 | +from flask import (Flask, g, make_response, request, send_from_directory, |
| 7 | + url_for) |
| 8 | +from werkzeug.middleware.proxy_fix import ProxyFix |
| 9 | + |
| 10 | +app = Flask(__name__) |
| 11 | +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) |
| 12 | + |
| 13 | +PARTS_MAX_COUNT = 10 |
| 14 | +PARTS_QUERY_TIMEOUT = 8.0 |
| 15 | +PARTS_QUERY_FRAGMENT = """ |
| 16 | + fragment f on Stock { |
| 17 | + products { |
| 18 | + basic { |
| 19 | + manufacturer |
| 20 | + mfgpartno |
| 21 | + status |
| 22 | + } |
| 23 | + url |
| 24 | + imageUrl |
| 25 | + datasheetUrl |
| 26 | + } |
| 27 | + summary { |
| 28 | + inStockInventory |
| 29 | + medianPrice |
| 30 | + suppliersInStock |
| 31 | + } |
| 32 | + } |
| 33 | +""" |
| 34 | +PARTS_QUERY_STATUS_MAP = { |
| 35 | + 'active': 'Active', |
| 36 | + 'active-unconfirmed': 'Active', |
| 37 | + 'nrfnd': 'NRND', |
| 38 | + 'eol': 'Obsolete', |
| 39 | + 'obsolete': 'Obsolete', |
| 40 | + 'discontinued': 'Obsolete', |
| 41 | + 'transferred': 'Obsolete', |
| 42 | + 'contact mfr': None, # Not supported, but here to avoid warning. |
| 43 | +} |
| 44 | +MANUFACTURER_REPLACEMENTS = { |
| 45 | + 'ä': 'ae', |
| 46 | + 'ö': 'oe', |
| 47 | + 'ü': 'ue', |
| 48 | + 'texas instruments': 'ti', |
| 49 | + 'stmicroelectronics': 'st', |
| 50 | +} |
| 51 | +MANUFACTURER_REMOVALS = set([ |
| 52 | + 'contact', |
| 53 | + 'devices', |
| 54 | + 'electronics', |
| 55 | + 'inc.', |
| 56 | + 'inc', |
| 57 | + 'incorporated', |
| 58 | + 'integrated', |
| 59 | + 'international', |
| 60 | + 'limited', |
| 61 | + 'ltd.', |
| 62 | + 'ltd', |
| 63 | + 'microelectronics', |
| 64 | + 'semiconductor', |
| 65 | + 'semiconductors', |
| 66 | + 'solutions', |
| 67 | + 'systems', |
| 68 | + 'technology', |
| 69 | + 'usa', |
| 70 | +]) |
| 71 | + |
| 72 | + |
| 73 | +def _get_config(key, fallback=None): |
| 74 | + if 'config' not in g: |
| 75 | + try: |
| 76 | + with open('/config/api.json', 'rb') as f: |
| 77 | + g.config = json.load(f) |
| 78 | + except Exception as e: |
| 79 | + app.logger.critical(str(e)) |
| 80 | + g.config = dict() |
| 81 | + return g.config.get(key, fallback) |
| 82 | + |
| 83 | + |
| 84 | +def _build_headers(): |
| 85 | + return { |
| 86 | + 'Content-Type': 'application/json', |
| 87 | + 'Accept': 'application/json, multipart/mixed', |
| 88 | + 'Authorization': 'Bearer {}'.format(_get_config('parts_query_token')), |
| 89 | + } |
| 90 | + |
| 91 | + |
| 92 | +def _build_request(parts): |
| 93 | + args = [] |
| 94 | + queries = [] |
| 95 | + variables = {} |
| 96 | + for i in range(len(parts)): |
| 97 | + args.append('$mpn{}:String!'.format(i)) |
| 98 | + queries.append('q{}:findStocks(mfgpartno:$mpn{}){{...f}}'.format(i, i)) |
| 99 | + variables['mpn{}'.format(i)] = parts[i]['mpn'] |
| 100 | + query = 'query Stocks({}) {{\n{}\n}}'.format( |
| 101 | + ','.join(args), |
| 102 | + '\n'.join(queries) |
| 103 | + ) + PARTS_QUERY_FRAGMENT |
| 104 | + return dict(query=query, variables=variables) |
| 105 | + |
| 106 | + |
| 107 | +def _get_basic_value(product, key): |
| 108 | + if type(product) is dict: |
| 109 | + basic = product.get('basic') |
| 110 | + if type(basic) is dict: |
| 111 | + value = basic.get(key) |
| 112 | + if type(value) is str: |
| 113 | + return value |
| 114 | + return '' |
| 115 | + |
| 116 | + |
| 117 | +def _normalize_manufacturer(mfr): |
| 118 | + mfr = mfr.lower() |
| 119 | + for old, new in MANUFACTURER_REPLACEMENTS.items(): |
| 120 | + mfr = mfr.replace(old, new) |
| 121 | + terms = [s for s in mfr.split(' ') if s not in MANUFACTURER_REMOVALS] |
| 122 | + return ' '.join(terms) |
| 123 | + |
| 124 | + |
| 125 | +def _calc_product_match_score(p, mpn_n, mfr_n): |
| 126 | + score = 0 |
| 127 | + |
| 128 | + status_p = PARTS_QUERY_STATUS_MAP.get(_get_basic_value(p, 'status')) |
| 129 | + if status_p == 'Active': |
| 130 | + score += 200 |
| 131 | + elif status_p == 'NRND': |
| 132 | + score += 100 |
| 133 | + |
| 134 | + mpn_p = _get_basic_value(p, 'mfgpartno').lower() |
| 135 | + if mpn_p == mpn_n: |
| 136 | + score += 20 # MPN matches exactly. |
| 137 | + elif mpn_p.replace(' ', '') == mpn_n.replace(' ', ''): |
| 138 | + score += 10 # MPN matches when ignoring whitespaces. |
| 139 | + else: |
| 140 | + return 0 # MPN does not match! |
| 141 | + |
| 142 | + mfr_p = _normalize_manufacturer(_get_basic_value(p, 'manufacturer')) |
| 143 | + if mfr_p == mfr_n: |
| 144 | + score += 4 # Manufacturer matches exactly. |
| 145 | + elif mfr_n in mfr_p: |
| 146 | + score += 3 # Manufacturer matches partially. |
| 147 | + elif mfr_n.replace(' ', '') in mfr_p.replace(' ', ''): |
| 148 | + score += 2 # Manufacturer matches partially when ignoring whitespaces. |
| 149 | + elif mfr_n.split(' ')[0] in mfr_p: |
| 150 | + score += 1 # The first term of the manufacturer matches. |
| 151 | + else: |
| 152 | + return 0 # Manufacturer does not match! |
| 153 | + |
| 154 | + return score |
| 155 | + |
| 156 | + |
| 157 | +def _get_product(data, mpn, manufacturer): |
| 158 | + products = (data.get('products') or []) |
| 159 | + for p in products: |
| 160 | + p['_score'] = _calc_product_match_score( |
| 161 | + p, mpn.lower(), _normalize_manufacturer(manufacturer)) |
| 162 | + products = sorted([p for p in products if p['_score'] > 0], |
| 163 | + key=lambda p: p['_score'], reverse=True) |
| 164 | + return products[0] if len(products) else None |
| 165 | + |
| 166 | + |
| 167 | +def _add_pricing_url(out, data): |
| 168 | + value = data.get('url') |
| 169 | + if value is not None: |
| 170 | + out['pricing_url'] = value |
| 171 | + |
| 172 | + |
| 173 | +def _add_image_url(out, data): |
| 174 | + value = data.get('imageUrl') |
| 175 | + if value is not None: |
| 176 | + out['picture_url'] = value |
| 177 | + |
| 178 | + |
| 179 | +def _add_status(out, data): |
| 180 | + status = data.get('status') or '' |
| 181 | + status_n = status.lower() |
| 182 | + value = PARTS_QUERY_STATUS_MAP.get(status_n.lower()) |
| 183 | + if value is not None: |
| 184 | + out['status'] = value |
| 185 | + elif len(status_n) and (status_n not in PARTS_QUERY_STATUS_MAP): |
| 186 | + app.logger.warning('Unknown part lifecycle status: {}'.format(status)) |
| 187 | + |
| 188 | + |
| 189 | +def _stock_to_availability(stock): |
| 190 | + if stock > 100000: |
| 191 | + return 10 # Very Good |
| 192 | + elif stock > 5000: |
| 193 | + return 5 # Good |
| 194 | + elif stock > 200: |
| 195 | + return 0 # Normal |
| 196 | + elif stock > 0: |
| 197 | + return -5 # Bad |
| 198 | + else: |
| 199 | + return -10 # Very Bad |
| 200 | + |
| 201 | + |
| 202 | +def _suppliers_to_availability(suppliers): |
| 203 | + if suppliers > 30: |
| 204 | + return 10 # Very Good |
| 205 | + elif suppliers > 9: |
| 206 | + return 5 # Good |
| 207 | + elif suppliers > 1: |
| 208 | + return 0 # Normal |
| 209 | + elif suppliers > 0: |
| 210 | + return -5 # Bad |
| 211 | + else: |
| 212 | + return -10 # Very Bad |
| 213 | + |
| 214 | + |
| 215 | +def _add_availability(out, data): |
| 216 | + stock = data.get('inStockInventory') |
| 217 | + suppliers = data.get('suppliersInStock') |
| 218 | + values = [] |
| 219 | + if type(stock) is int: |
| 220 | + values.append(_stock_to_availability(stock)) |
| 221 | + if type(suppliers) is int: |
| 222 | + values.append(_suppliers_to_availability(suppliers)) |
| 223 | + if len(values): |
| 224 | + out['availability'] = min(values) |
| 225 | + |
| 226 | + |
| 227 | +def _add_prices(out, summary): |
| 228 | + value = summary.get('medianPrice') |
| 229 | + if type(value) in [float, int]: |
| 230 | + out['prices'] = [dict(quantity=1, price=float(value))] |
| 231 | + |
| 232 | + |
| 233 | +def _add_resources(out, data): |
| 234 | + value = data.get('datasheetUrl') |
| 235 | + if value is not None: |
| 236 | + out['resources'] = [ |
| 237 | + dict(name="Datasheet", mediatype="application/pdf", url=value), |
| 238 | + ] |
| 239 | + |
| 240 | + |
| 241 | +@app.route('/api/v1/parts', methods=['GET']) |
| 242 | +def parts(): |
| 243 | + enabled = _get_config('parts_operational', False) |
| 244 | + response = make_response(dict( |
| 245 | + provider_name='Partstack', |
| 246 | + provider_url='https://partstack.com', |
| 247 | + provider_logo_url=url_for('parts_static', |
| 248 | + filename='parts-provider-partstack.png', |
| 249 | + _external=True), |
| 250 | + info_url='https://api.librepcb.org/api', |
| 251 | + query_url=url_for('parts_query', _external=True) if enabled else None, |
| 252 | + max_parts=PARTS_MAX_COUNT, |
| 253 | + )) |
| 254 | + response.headers['Cache-Control'] = 'max-age=300' |
| 255 | + return response |
| 256 | + |
| 257 | + |
| 258 | +@app.route('/api/v1/parts/query', methods=['POST']) |
| 259 | +def parts_query(): |
| 260 | + # Get requested parts. |
| 261 | + payload = request.get_json() |
| 262 | + parts = payload['parts'][:PARTS_MAX_COUNT] |
| 263 | + |
| 264 | + # Query parts from information provider. |
| 265 | + query_response = requests.post( |
| 266 | + _get_config('parts_query_url'), |
| 267 | + headers=_build_headers(), |
| 268 | + json=_build_request(parts), |
| 269 | + timeout=PARTS_QUERY_TIMEOUT, |
| 270 | + ) |
| 271 | + query_json = query_response.json() |
| 272 | + data = query_json.get('data') or {} |
| 273 | + errors = query_json.get('errors') or [] |
| 274 | + if (len(data) == 0) and (type(query_json.get('message')) is str): |
| 275 | + errors.append(query_json['message']) |
| 276 | + for error in errors: |
| 277 | + app.logger.warning("GraphQL Error: " + str(error)) |
| 278 | + |
| 279 | + # Convert query response data and return it to the client. |
| 280 | + tx = dict(parts=[]) |
| 281 | + for i in range(len(parts)): |
| 282 | + mpn = parts[i]['mpn'] |
| 283 | + manufacturer = parts[i]['manufacturer'] |
| 284 | + part_data = data.get('q' + str(i)) or {} |
| 285 | + product = _get_product(part_data, mpn, manufacturer) |
| 286 | + part = dict( |
| 287 | + mpn=mpn, |
| 288 | + manufacturer=manufacturer, |
| 289 | + results=0 if product is None else 1, |
| 290 | + ) |
| 291 | + if product is not None: |
| 292 | + basic = product.get('basic') or {} |
| 293 | + summary = part_data.get('summary') or {} |
| 294 | + _add_pricing_url(part, product) |
| 295 | + _add_image_url(part, product) |
| 296 | + _add_status(part, basic) |
| 297 | + _add_availability(part, summary) |
| 298 | + _add_prices(part, summary) |
| 299 | + _add_resources(part, product) |
| 300 | + tx['parts'].append(part) |
| 301 | + return tx |
| 302 | + |
| 303 | + |
| 304 | +@app.route('/api/v1/parts/static/<filename>', methods=['GET']) |
| 305 | +def parts_static(filename): |
| 306 | + return send_from_directory( |
| 307 | + 'static', filename, mimetype='image/png', max_age=24*3600) |
0 commit comments