├── .gitignore ├── LICENSE ├── README.md ├── config.ini.example ├── inge ├── lib ├── getwarranty.py ├── macmodelshelf.db └── macmodelshelf.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | secrets.ini 2 | *.pem 3 | config.ini 4 | 5 | ### Python template 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | # PyBuilder 63 | target/ 64 | 65 | *.ipynb 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person 2 | obtaining a copy of this software and associated documentation files 3 | (the "Software"), to deal in the Software without restriction, 4 | including without limitation the rights to use, copy, modify, merge, 5 | publish, distribute, sublicense, and/or sell copies of the Software, 6 | and to permit persons to whom the Software is furnished to do so, 7 | subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be 10 | included in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 13 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 14 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 15 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 16 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 17 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | INGE 2 | ==== 3 | _Inventory Gone Easy_ 4 | 5 | ### Warning 6 | 7 | INGE is currently still heavy in development and will likely break. 8 | To fix this, send bug reports and pull requests. 9 | 10 | ### About 11 | 12 | INGE is a small tool that helps you to inventory small batches of devices, saving you time by automation of 13 | tedious tasks. 14 | 15 | It is designed to work together with a JIRA Inventory Project, as described here: 16 | 17 | 18 | It lets you define own inventory workflows with your own existing custom fields that will be used during the scan 19 | of the inventory items. It can automatically fill in warranty and model information based on the serial number 20 | of an apple device. (Using pyMacWarranty and MacModelShelf) 21 | 22 | ### Requirements 23 | 24 | Python 3. 25 | 26 | Python modules: *Click*, *jira*, *python-dateutil*, *prettytable*, *oauthlib*, *jwt*, *cryptography*, *pyjwt*. 27 | 28 | ### Installation 29 | 30 | 1. Clone Repo and run `pip3 install -e .` to install all the requirements. 31 | 2. Copy or Rename `config.ini.example` to `config.ini` 32 | 33 | ### Configuration 34 | 35 | Take a look into the `config.ini` to learn about the configuration. Set your server URL, the project key 36 | 37 | First, you have to configure your JIRA instance for OAuth1, if not yet done. 38 | Atlassian has a good step-by-step tutorial with code examples here: 39 | . 40 | Follow this tutorial. You should end up with a keyfile that you have to reference in the `config.ini` 41 | 42 | If you start INGE the first time using `inge new`, it should prompt you for the consumer key that you 43 | defined in JIRA. Enter it and follow the next instructions. If you don't get an error, you have successfully 44 | logged in to JIRA using OAuth. 45 | 46 | Now the workflows: 47 | for every workflow you need, create a section in the config.ini with a title named `[input.workflowname]`. 48 | There you can define the fields which should be used. There are examples in the `config.ini` 49 | 50 | Match the fields that you define with your existing custom fields in JIRA in the `[fields]` section. 51 | 52 | ### Usage 53 | 54 | `inge new`: adds a device to the inventory system. 55 | 56 | I recommend starting INGE with the `-v` option. 57 | 58 | Parameters: 59 | 60 | `--help` 61 | Shows help. All you need basically 62 | 63 | `-t`, `--itemtype` 64 | Specifies the type of inventory item you want to import. Defaults to `macbook`. 65 | 66 | `-s`, `--simulate` 67 | Simulates Execution - nothing will be written 68 | 69 | `-d`, `--debug` 70 | Debug Mode - outputs contents of lists, dicts and lots of other debug information. Useful for ... debugging 71 | 72 | `-v`, `--verbose` 73 | Verbose Mode - if set, shows more information (and a pretty table) 74 | 75 | Inge currently features only an interactive mode. 76 | It is not yet possible to pass arguments on the command line because I don't know how to do that in a modular way. 77 | 78 | ### Usage with a barcode scanner 79 | 80 | INGE should work great with a barcode scanner. 81 | 82 | Some tips: 83 | 84 | 1. Set your scanner to end a line with a \ if you don't want to press Enter after every scan 85 | 2. INGE will automatically strip the leading *S* from Apple serial numbers 86 | 3. If you have steps in your workflow that you normally just confirm with Enter, you should print out 87 | a barcode that just means \ (consult your barcode scanner's manual for that). This will prevent you 88 | from having to reach to the keyboard all the time. 89 | 90 | 91 | ### Thanks 92 | 93 | Thanks go to: 94 | 95 | * [@magervalp](https://twitter.com/magervalp) for [MacModelShelf](https://github.com/MagerValp/MacModelShelf) 96 | * [@mikeymikey](https://twitter.com/mikeymikey) for [pyMacWarranty](https://github.com/pudquick/pyMacWarranty) 97 | 98 | Two projects which INGE relies heavily on. 99 | 100 | ### To do 101 | 102 | - ~~import custom input options from config~~ 103 | 104 | - write checks to see if a serial number / inventory number already exists 105 | 106 | - ~~implement linked issues~~ 107 | 108 | - if the config file gets any more complicated, maybe I should use JSON or YAML 109 | 110 | - Flask-based Web interface 111 | 112 | - Unit tests via a CI system 113 | 114 | - multiple linked issues (if that is needed) 115 | -------------------------------------------------------------------------------- /config.ini.example: -------------------------------------------------------------------------------- 1 | [jira] 2 | # Fill in your own jira Server here. Required. 3 | server = https://jira.example.com 4 | project = INV 5 | 6 | # The RSA private key for the OAuth1 Authentication 7 | # If you don't know how to set up OAuth in JIRA, read this and look at the example code here: 8 | # https://bitbucket.org/atlassian_tutorial/atlassian-oauth-examples 9 | rsa-private-key = testkey.pem 10 | 11 | # this ugly hack is needed until I figure out how OAuth actually works (I'm so sorry) 12 | # just to be safe, fill this with random data. 13 | verifier = iwfho1iwfnwnqo2dj9328e239honwnkwncwl 14 | 15 | 16 | [fields] 17 | # Put in your own jira custom field names here 18 | # Easiest method to find out the names is to browse to an issue detail page and fire up the web inspector. 19 | # The fields are named in the source of the page. 20 | # Important: Here you have to match the fields you define down in the input modules or you will get errors. 21 | inventory_number = customfield_11012 22 | serial_number = customfield_11007 23 | est_purchase_date = customfield_11302 24 | est_warranty_end_date = customfield_11303 25 | est_manufacture_date = customfield_11304 26 | model = customfield_11308 27 | ram = customfield_11307 28 | disk_size = customfield_11306 29 | #don't change this 30 | summary = summary 31 | 32 | [input.macbook] 33 | # Here you can define the fields for your inventory items 34 | # Syntax: 35 | # field.{your name} = [prompt.][default_value] 36 | # 37 | # For example: 38 | # field.inventory_number = prompt # prompts for an input 39 | # field.inventory_number = prompt.0001 # prompts for an input with the default value 0001 40 | # field.inventory_number = 0001 # always uses 0001 for this field 41 | 42 | field.inventory_number = prompt 43 | 44 | # Don't change this field name (serial_number). It is important for warranty and model type lookup. 45 | field.serial_number = prompt 46 | 47 | field.ram = prompt.16GB 48 | field.disk_size = prompt.512GB 49 | 50 | # appledevice = true: Start a special function that fills out the following fields 51 | # based on online lookup: model, warranty_end_date, manufacture_date, purchase_date. 52 | # This should always be set to true if the device has a serial number. 53 | appledevice = True 54 | 55 | # This needs to be exactly the issue type you have in your JIRA. Required. 56 | issuetype = Computer Asset 57 | 58 | # Links an issue with input type poweradapter to this issue 59 | # Currently only one linked Issue is supported. 60 | # Comment it out if you don't need it. 61 | link = input.poweradapter 62 | 63 | # Summary string template. Words with $ will be replaced by the value of the corresponding field. Required. 64 | summary = myt-$inventory_number 65 | 66 | [input.poweradapter] 67 | field.inventory_number = prompt 68 | field.model = Apple Power Adapter Magsafe 2 69 | summary = pwr-$inventory_number 70 | issuetype = Misc. Hardware Asset 71 | 72 | [input.keyboard] 73 | field.inventory_number = prompt 74 | field.model_identifier = prompt.Apple Wired Keyboard 75 | summary = key-$inventory_number 76 | issuetype = Computer Asset 77 | 78 | # Set up your own workflows! 79 | # Here's another example. 80 | # 81 | #[input.androidphone] 82 | #field.inventory_number = prompt 83 | #field.serial_number = prompt 84 | #appledevice = False 85 | #field.model = prompt.Nexus 6 86 | #summary = $model $inventory_number 87 | #issuetype = Mobile Phone -------------------------------------------------------------------------------- /inge: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | INGE - Inventory Gone Easy 4 | A tool for adding inventory items to an inventory project in JIRA. 5 | More information here: https://carstenwolfram.de/inventory-management-with-jira/ 6 | """ 7 | import os 8 | import configparser 9 | import inspect 10 | import sys 11 | import re 12 | 13 | try: 14 | import click 15 | from jira.client import JIRA 16 | from prettytable import PrettyTable 17 | import lib.getwarranty as g 18 | from lib.macmodelshelf import model, model_code 19 | from oauthlib.oauth1 import SIGNATURE_RSA 20 | from requests_oauthlib import OAuth1Session 21 | except ImportError as importerror: 22 | click = None 23 | JIRA = None 24 | PrettyTable = None 25 | model = None 26 | model_code = None 27 | SIGNATURE_RSA = None 28 | OAuth1Session = None 29 | g = None 30 | print('Error: Required modules not found. You should run "pip install -e ." first\n') 31 | print(importerror) 32 | sys.exit(1) 33 | 34 | VERSION = "0.1" 35 | 36 | scriptpath = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) 37 | 38 | logo = """ 39 | d8, 40 | `8P 41 | 42 | 88b 88bd88b d888b8b d8888b 43 | 88P 88P' ?8bd8P' ?88 d8b_,dP 44 | d88 d88 88P88b ,88b 88b 45 | d88' d88' 88b`?88P'`88b`?888P' {v} 46 | )88 47 | ,88P 48 | `?8888P 49 | 50 | ==== Inventory Gone Easy ==== 51 | """.format(v=VERSION) 52 | 53 | 54 | def sanitize_apple_serial(s): 55 | """ 56 | Checks if an Apple Serial begins with an S and cuts it off brutally 57 | :param s: The serial to check 58 | :return: Sanitized string 59 | """ 60 | s = s.upper().strip() 61 | if s[:1] == "S" and len(s) == 13: 62 | return s[1:] 63 | else: 64 | return s 65 | 66 | 67 | def p(text, message_type="normal", prefix=""): 68 | """ Prints text via click.echo() 69 | :param prefix: Something that will be printed before the actual text 70 | :param text: the text to print 71 | :param message_type: controls how text is displayed, can be either "normal", "warning", "error" or "success" 72 | :return: 73 | """ 74 | if message_type is "warning": 75 | click.echo(click.style(' ', bg='yellow', fg='black') + click.style(' ' + text, fg='yellow')) 76 | 77 | elif message_type is "error": 78 | click.echo(click.style(' ', bg='red', fg='black') + click.style(' ' + text, fg='red'), err=True) 79 | 80 | elif message_type is "success": 81 | click.echo(click.style(' ', bg='green', fg='black') + click.style(' ' + text, fg='green')) 82 | 83 | elif message_type is "debug": 84 | click.echo(click.style('Debug: ', bold=True) + prefix + click.style(str(text))) 85 | 86 | else: 87 | click.echo(text) 88 | 89 | # read config and secrets 90 | config = configparser.ConfigParser() 91 | if not config.read('{}/config.ini'.format(scriptpath)): 92 | p("Config file missing, take a look at config.ini.example and rename it to config.ini", "error") 93 | sys.exit(3) 94 | 95 | secrets = configparser.ConfigParser() 96 | if not secrets.read('{}/secrets.ini'.format(scriptpath)): 97 | p("Seems to be the first start, I'm going to create secrets.ini ", "warning") 98 | try: 99 | secretsfile = open('secrets.ini', 'w') 100 | secretsfile.close() 101 | except BaseException as secretserror: 102 | p('Error at creating secrets.ini: {}'.format(secretserror), 'error') 103 | sys.exit(4) 104 | 105 | 106 | def read(file_path): 107 | """ Read a file and return it's contents. 108 | :param file_path: Path of the - tada - file 109 | """ 110 | with open(file_path) as f: 111 | return f.read() 112 | 113 | 114 | def jira_oauth(): 115 | global RSA_KEY, jira_server, CONSUMER_KEY, ACCESS_TOKEN, ACCESS_TOKEN_SECRET, secretsfile 116 | # OAUTH # 117 | # The contents of the rsa.pem file generated (the private RSA key) 118 | try: 119 | RSA_KEY = read(config.get('jira', 'rsa-private-key')) 120 | except BaseException as e: 121 | p('Error loading the key file. Did you create a key pair? {}'.format(e), 'error') 122 | 123 | # The URLs for the JIRA instance 124 | jira_server = config.get('jira', 'server') 125 | request_token_url = jira_server + '/plugins/servlet/oauth/request-token' 126 | authorize_url = jira_server + '/plugins/servlet/oauth/authorize' 127 | access_token_url = jira_server + '/plugins/servlet/oauth/access-token' 128 | # Step 1: Get a request token 129 | try: 130 | CONSUMER_KEY = secrets.get('jira', 'consumer_key') 131 | ACCESS_TOKEN = secrets.get('jira', 'access_token') 132 | ACCESS_TOKEN_SECRET = secrets.get('jira', 'access_token_secret') 133 | except (configparser.NoOptionError, configparser.NoSectionError): 134 | p('JIRA OAuth Token not found yet. Let\'s set it up.\n', 'warning') 135 | print('We\'ll assume two things:') 136 | print('1. You have already generated a RSA key pair.') 137 | print('2. You have already configured an application link.') 138 | print('If you didn\'t yet, go here and read how to do this:') 139 | print('https://bitbucket.org/atlassian_tutorial/atlassian-oauth-examples') 140 | print('') 141 | print( 142 | 'STEP 1: Enter the Consumer Key\n(You probably set this when you configured the Application link in JIRA)') 143 | CONSUMER_KEY = click.prompt(click.style('Consumer Key', bold=True)) 144 | print("\n") 145 | 146 | oauth = OAuth1Session(CONSUMER_KEY, signature_type='auth_header', 147 | signature_method=SIGNATURE_RSA, rsa_key=RSA_KEY) 148 | request_token = oauth.fetch_request_token(request_token_url) 149 | 150 | # Step 2: Get the end-user's authorization 151 | print("STEP 2: Authorization") 152 | print(" Visit to the following URL to provide authorization:") 153 | print(" {}?oauth_token={}".format(authorize_url, request_token['oauth_token'])) 154 | print("\n") 155 | 156 | while input("Press any key to continue..."): 157 | pass 158 | 159 | # this ugly hack is needed until I figure out how OAuth actually works 160 | oauth._client.client.verifier = config['jira']['verifier'] 161 | 162 | # Step 3: Get the access token 163 | access_token = oauth.fetch_access_token(access_token_url) 164 | 165 | # Step 4: Write it all to the secrets.ini 166 | try: 167 | secretsfile = open("secrets.ini", 'w') 168 | secrets.add_section('jira') 169 | secrets.set('jira', 'consumer_key', CONSUMER_KEY) 170 | secrets.set('jira', 'access_token', access_token['oauth_token']) 171 | secrets.set('jira', 'access_token_secret', access_token['oauth_token_secret']) 172 | secrets.write(secretsfile) 173 | secretsfile.close() 174 | except BaseException as e: 175 | p('Error writing to secrets.ini: {}'.format(e)) 176 | 177 | ACCESS_TOKEN = secrets.get('jira', 'access_token') 178 | ACCESS_TOKEN_SECRET = secrets.get('jira', 'access_token_secret') 179 | 180 | 181 | def connect_to_jira(): 182 | try: 183 | return JIRA(options={'server': jira_server}, oauth={ 184 | 'access_token': ACCESS_TOKEN, 185 | 'access_token_secret': ACCESS_TOKEN_SECRET, 186 | 'consumer_key': CONSUMER_KEY, 187 | 'key_cert': RSA_KEY 188 | }) 189 | except BaseException as e: 190 | p('Error connecting to JIRA.\n{}'.format(e), 'error') 191 | 192 | 193 | def new_item(itemtype): 194 | item = dict() 195 | item['itemtype'] = itemtype 196 | itemtype___format = 'input.{}'.format(itemtype) 197 | 198 | for x in config[itemtype___format].keys(): 199 | if x.startswith('field.'): 200 | if config[itemtype___format][x].startswith('prompt'): 201 | try: 202 | item[x.split('.')[1]] = click.prompt('Enter {}'.format(x.split('.')[1]), 203 | default=config[itemtype___format][x].split('.')[1]) 204 | except IndexError: 205 | item[x.split('.')[1]] = click.prompt('Enter {}'.format(x.split('.')[1])) # if no default given 206 | 207 | # Else: Use the Data from the config and be quiet about it. (predefined field) 208 | else: 209 | item[x.split('.')[1]] = config[itemtype___format][x] 210 | 211 | # Do all kinds of voodo magic if this is an apple device. Warranty, Model description, etc. 212 | if x == 'appledevice': 213 | if config[itemtype___format][x] == 'True': 214 | # Sanitize Serial Number. Removes 'S' from scanned serials 215 | item['serial_number'] = sanitize_apple_serial(item['serial_number']) 216 | # get warranty info from pyMacWarranty 217 | warranty_info = g.offline_warranty(item.get('serial_number')) 218 | item['est_manufacture_date'] = warranty_info[0].get('EST_MANUFACTURE_DATE') 219 | item['est_purchase_date'] = warranty_info[0].get('EST_PURCHASE_DATE') 220 | item['est_warranty_end_date'] = warranty_info[0].get('EST_WARRANTY_END_DATE') 221 | # get the model string from macmodelshelf 222 | item['model'] = model(model_code(sanitize_apple_serial(item.get('serial_number')))) 223 | 224 | if x == 'link': 225 | item[x] = config[itemtype___format][x] 226 | 227 | # summary has to be built last because may depend on other stuff 228 | item['summary'] = build_summary_string(item, itemtype) 229 | return item 230 | 231 | 232 | def build_summary_string(item, devicetype): 233 | """ 234 | Builds a summary string from the template defined in the config.ini 235 | :param item: The item dictionary 236 | :param devicetype: The devicetype (like "macbook") from the config.ini input key 237 | :return: 238 | """ 239 | r = re.compile('\$(\w*)') 240 | summary = config['input.{}'.format(devicetype)]['summary'] 241 | m = r.findall(summary) 242 | for x in m: 243 | summary = summary.replace('$' + x, item[x]) 244 | return summary 245 | 246 | 247 | def build_pretty_table(flags, item): 248 | if flags.debug: 249 | p('Creating Table...', 'debug') 250 | 251 | table = PrettyTable(['Field', 'Value']) 252 | table.align['Field'] = 'r' 253 | table.align['Value'] = 'l' 254 | 255 | for row in item: 256 | if row != 'link': 257 | table.add_row([row, item.get(row)]) 258 | 259 | table.sortby = "Field" 260 | return table 261 | 262 | 263 | def build_issue_dict(item): 264 | """ 265 | Builds the dictionary that is used to write all the stuff into jira 266 | :param item: The inventory item dictionary from the first step 267 | """ 268 | issue_dict = {} 269 | for i in item.keys(): 270 | if i != 'itemtype': 271 | try: 272 | issue_dict[config['fields'][i]] = item[i] 273 | except KeyError: 274 | if i != 'link': 275 | p('The following field does not seem to exist in the \"fields\" ' 276 | 'section of the config.ini: {}'.format(i), 'warning') 277 | issue_dict['summary'] = item['summary'] 278 | 279 | try: 280 | issue_dict['project'] = config['jira']['project'] 281 | except KeyError: 282 | p("No \"project\" key found in config.ini (in section [jira]). ", 'error') 283 | sys.exit(19) 284 | 285 | try: 286 | issue_dict['issuetype'] = {'name': config['input.' + item['itemtype']]['issuetype']} 287 | except KeyError: 288 | p("No \"issuetype\" key found in config.ini (in section [{}]). ".format('input.' + item['itemtype']), 'error') 289 | sys.exit(19) 290 | return issue_dict 291 | 292 | 293 | def create_inventory_item(flags, itemtype, jira): 294 | item = new_item(itemtype) 295 | if flags.debug: 296 | p('PRELIMINARY DICTIONARY: ', 'debug') 297 | p(item, 'debug', 'Original Item: ') 298 | 299 | # build issue dict for jira 300 | issue_dict = build_issue_dict(item) 301 | if flags.debug: 302 | p('ISSUE DICTIONARY FOR JIRA:', 'debug') 303 | print(issue_dict) 304 | 305 | # Create secondary issue if required 306 | if item.get('link'): 307 | item_linked = None 308 | click.echo(click.style( 309 | 'Enter Details for new linked item {} (CTRL-C to quit)'.format(item['link'].split('.')[1]), 310 | fg='white')) 311 | # p('Enter Details for Linked Item: {}'.format(item['link'].split('.')[1])) 312 | item_linked = new_item(item['link'].split('.')[1]) 313 | if flags.debug: 314 | p(item_linked, 'debug', 'Linked Item: ') 315 | issue_linked_dict = build_issue_dict(item_linked) 316 | if flags.debug: 317 | p(issue_linked_dict, 'debug', 'Linked Item Issue Dictionary for JIRA: ') 318 | 319 | if flags.verbose: 320 | p('') 321 | p('Fields for item {}'.format(item['itemtype'])) 322 | p(build_pretty_table(flags, item)) 323 | if 'item_linked' in locals(): 324 | p('') 325 | p('Fields for item {}'.format(item_linked['itemtype'])) 326 | p(build_pretty_table(flags, item_linked)) 327 | 328 | # Writing everything to JIRA 329 | if not flags.simulate: 330 | try: 331 | new_issue = jira.create_issue(fields=issue_dict) 332 | except BaseException as e: 333 | p("Creating Issue failed: {}".format(e), 'error') 334 | sys.exit(20) 335 | 336 | p('Issue for {itemtype} created at ' 337 | '{jiraserver}/browse/{new_issue}'.format(jiraserver=config['jira']['server'], 338 | new_issue=new_issue, 339 | itemtype=item['itemtype']), 340 | 'success') 341 | 342 | # Creating linked issue if required 343 | if 'issue_linked_dict' in locals(): 344 | linked_issue = jira.create_issue(fields=issue_linked_dict) 345 | p('Issue for {itemtype} created at {jiraserver}/browse/{new_issue}'.format( 346 | jiraserver=config['jira']['server'], 347 | new_issue=linked_issue, 348 | itemtype=item_linked['itemtype']), 'success') 349 | # Linking these two issues 350 | jira.create_issue_link('Relates', new_issue, linked_issue) 351 | p('Issue Link created successfully', 'success') 352 | 353 | else: 354 | # Simulation mode output: 355 | p('Issue would have been created on {jiraserver}, if this weren\'t simulation mode.'.format( 356 | jiraserver=config['jira']['server']), 'warning') 357 | 358 | 359 | class Flags(object): 360 | def __init__(self): 361 | """ Helper class for flags like verbose, simulate and debug """ 362 | self.verbose = False 363 | self.simulate = False 364 | self.debug = False 365 | 366 | 367 | pass_flags = click.make_pass_decorator(Flags, ensure=True) 368 | 369 | 370 | @click.group() 371 | @click.version_option(version=VERSION) 372 | @click.option('-v', '--verbose', is_flag=True, help='Shows more information') 373 | @click.option('-d', '--debug', is_flag=True, help='Prints debug information') 374 | @click.option('-s', '--simulate', is_flag=True, help='Simulates run, nothing will be written') 375 | @pass_flags 376 | def main(flags, verbose, debug, simulate): 377 | """ INGE: Inventory Gone Easy. 378 | A tool for adding inventory items to an inventory project in JIRA 379 | :param flags: config flags object 380 | :param verbose: if set, gives more information 381 | :param debug: if set, outputs dicts and even more information 382 | :param simulate: if set, does not connect to JIRA, just simulates 383 | """ 384 | flags.verbose = verbose 385 | flags.simulate = simulate 386 | flags.debug = debug 387 | jira_oauth() 388 | if flags.verbose: 389 | p(logo) 390 | if flags.simulate: 391 | p("Simulation mode, nothing will be written", 'warning') 392 | p('') 393 | 394 | 395 | @main.command() 396 | @click.option('-t', '--itemtype', default='macbook') 397 | @pass_flags 398 | def new(flags, itemtype): 399 | """Creates a new inventory ticket 400 | :param flags: Config Flags object 401 | :param itemtype: Type of device as defined in config.ini (input.itemype) 402 | """ 403 | 404 | # Writes a list of input types from the config in a list 405 | input_types = list() 406 | for x in config.sections(): 407 | if x.startswith('input.'): 408 | input_types.append(x.split('.')[1]) 409 | 410 | if flags.debug: 411 | p(input_types, 'debug', 'Input Type List: ') 412 | 413 | # Checks if specified type is in the config ini. If yes, let's go! 414 | if itemtype not in input_types: 415 | p('Given input type \"{}\" not in config.ini'.format(itemtype), 'error') 416 | sys.exit(17) 417 | 418 | jira = None 419 | if not flags.simulate: 420 | if not jira: 421 | try: 422 | jira = connect_to_jira() 423 | except BaseException as e: 424 | p("Connection to JIRA failed: {}".format(e), 'error') 425 | sys.exit(19) 426 | 427 | # creates one inventory item and writes it to jira 428 | while True: 429 | click.echo(click.style('Enter Details for new item {} (CTRL-C to quit)'.format(itemtype), fg='white')) 430 | 431 | create_inventory_item(flags, itemtype, jira) 432 | p('') 433 | 434 | 435 | if __name__ == '__main__': 436 | main() 437 | -------------------------------------------------------------------------------- /lib/getwarranty.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Version 2.1 4 | # Apple has again changed the warranty lookup method and now redirects to 5 | # a CAPTCHA protected page at https://checkcoverage.apple.com/ 6 | # As such, no "online" warranty status is now possible. All warranty 7 | # information is now estimated. 8 | # 9 | # API changes: 10 | # .online_warranty and .offline_warranty are deprecated and both 11 | # now point to the replacement .warranty (which is offline only) 12 | 13 | # To use as a python module: 14 | # import getwarranty 15 | # results = getwarranty.warranty( ... one or more serials ... ) 16 | 17 | """Usage: getwarranty [OPTION ...] [SERIAL1 SERIAL2 ...] 18 | 19 | List warranty information for one or more Apple devices. 20 | 21 | If no serial number is provided on the command-line, the script will 22 | assume it's running on an OS X machine and attempt to query the local 23 | serial number and provide information regarding it. 24 | 25 | Default output is "ATTRIBUTE: value", per line. Use the options below 26 | for alternate format output. 27 | 28 | Options: 29 | -h, --help Display this message 30 | -f, --file FILE Read serial numbers from FILE (one per line) 31 | -o, --output Save output to specified file instead of stdout 32 | -c, --csv Output in comma-separated format 33 | -t, --tsv Output in tab-separated format 34 | 35 | Example usage: 36 | Read from file, save to csv: getwarranty -f serials.txt -o output.csv 37 | Print local serial to stdout: getwarranty 38 | Several serials to stdout: getwarranty SERIAL1 SERIAL2 SERIAL3 39 | """ 40 | 41 | import sys, subprocess, datetime, os.path, dateutil.parser 42 | import re, types, time, getopt, csv, codecs 43 | from io import StringIO 44 | import xml.etree.ElementTree as ET 45 | # import pickle --- no longer doing pickles, switch to json 46 | try: 47 | import json 48 | except: 49 | import simplejson as json 50 | 51 | try: 52 | import requests 53 | except: 54 | # My strange hack to use standard libs, if requests module isn't available 55 | # http://docs.python-requests.org/en/latest/index.html 56 | # Really, check it out - it's great 57 | import urllib, types 58 | import urllib2 as requests 59 | setattr(requests,'content','') 60 | def get(self, urlstr, params={}): 61 | if (params): 62 | urlstr += "?%s" % urllib.urlencode(params) 63 | self.content = self.urlopen(urlstr).read() 64 | return self 65 | requests.get = types.MethodType(get,requests) 66 | 67 | asd_db = {} 68 | model_db = {} 69 | 70 | def init_asd_db(): 71 | global asd_db 72 | if (not asd_db): 73 | try: 74 | response = requests.get('https://raw.github.com/stefanschmidt/warranty/master/asdcheck') 75 | for model,val in [model_str.strip().split(':') for model_str in response.content.split('\n') if model_str.strip()]: 76 | asd_db[model] = val 77 | except: 78 | asd_db = {} 79 | 80 | def init_model_db(): 81 | global model_db 82 | if (not model_db): 83 | try: 84 | # model_file = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'model_snippets.pickle'), 'rb') 85 | model_file = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'model_snippets.json'), 'r') 86 | # model_db = pickle.load(model_file) 87 | model_db = json.load(model_file) 88 | model_file.close() 89 | except: 90 | model_db = {} 91 | 92 | def blank_machine_dict(): 93 | return {u'SERIAL_ID': u'', 94 | u'PROD_DESCR': u'', 95 | u'ASD_VERSION': u'', 96 | u'EST_APPLECARE_END_DATE': u'', 97 | u'EST_MANUFACTURE_DATE': u'', 98 | u'EST_PURCHASE_DATE': u'', 99 | u'EST_WARRANTY_END_DATE': u'', 100 | u'EST_WARRANTY_STATUS': u'', 101 | u'PURCHASE_DATE': u'', 102 | u'WARRANTY_END_DATE': u'', 103 | u'WARRANTY_STATUS': u'', 104 | u'ERROR_CODE': u''} 105 | 106 | def apple_year_offset(dateobj, years=0): 107 | # Convert to a maleable format 108 | mod_time = dateobj.timetuple() 109 | # Offset year by number of years 110 | mod_time = time.struct_time(tuple([mod_time[0]+years]) + mod_time[1:]) 111 | # Convert back to a datetime obj 112 | return datetime.datetime.fromtimestamp(int(time.mktime(mod_time))) 113 | 114 | def offline_snippet_lookup(serial): 115 | # http://support-sp.apple.com/sp/product?cc=%s&lang=en_US 116 | # https://km.support.apple.com.edgekey.net/kb/securedImage.jsp?configcode=%s&size=72x72 117 | # https://github.com/MagerValp/MacModelShelf 118 | # Serial Number "Snippet": http://www.everymac.com/mac-identification/index-how-to-identify-my-mac.html 119 | global model_db 120 | init_model_db() 121 | if (len(serial) == 11): 122 | snippet = serial[-3:] 123 | elif (len(serial) == 12): 124 | snippet = serial[-4:] 125 | elif (2 < len(serial) < 5): 126 | snippet = serial 127 | else: 128 | return None 129 | return model_db.get(snippet.upper(), None) 130 | 131 | def online_snippet_lookup(serial): 132 | if len(serial) == 11: 133 | snippet = serial[-3:] 134 | elif len(serial) == 12: 135 | snippet = serial[-4:] 136 | elif 2 < len(serial) < 5: 137 | snippet = serial 138 | else: 139 | return None 140 | try: 141 | prod_xml = requests.get('http://support-sp.apple.com/sp/product', params={'cc': snippet, 'lang': 'en_US'}).content 142 | prod_descr = ET.fromstring(prod_xml).find('configCode').text 143 | except: 144 | return none 145 | return prod_descr 146 | 147 | def online_asd_version(prod_descr): 148 | global asd_db 149 | init_asd_db() 150 | try: 151 | return asd_db.get(prod_descr, u'') 152 | except: 153 | return u'' 154 | 155 | def offline_estimated_manufacture(serial): 156 | # http://www.macrumors.com/2010/04/16/apple-tweaks-serial-number-format-with-new-macbook-pro/ 157 | est_date = '' 158 | if 10 < len(serial) < 13: 159 | if len(serial) == 11: 160 | # Old format 161 | year = serial[2].lower() 162 | est_year = 2000 + ' 3456789012'.index(year) 163 | week = int(serial[3:5]) - 1 164 | year_time = datetime.date(year=est_year, month=1, day=1) 165 | if (week): 166 | week_dif = datetime.timedelta(weeks=week) 167 | year_time += week_dif 168 | est_date = u'' + year_time.strftime('%Y-%m-%d') 169 | else: 170 | # New format 171 | alpha_year = 'cdfghjklmnpqrstvwxyz' 172 | year = serial[3].lower() 173 | est_year = 2010 + (alpha_year.index(year) / 2) 174 | # 1st or 2nd half of the year 175 | est_half = alpha_year.index(year) % 2 176 | week = serial[4].lower() 177 | alpha_week = ' 123456789cdfghjklmnpqrtvwxy' 178 | est_week = alpha_week.index(week) + (est_half * 26) - 1 179 | year_time = datetime.date(year=int(est_year), month=1, day=1) 180 | if (est_week): 181 | week_dif = datetime.timedelta(weeks=est_week) 182 | year_time += week_dif 183 | est_date = u'' + year_time.strftime('%Y-%m-%d') 184 | return est_date 185 | 186 | def offline_estimated_applecare_end_date(details): 187 | manu_date = details[u'EST_MANUFACTURE_DATE'] 188 | prod_descr = details[u'PROD_DESCR'] 189 | iOS_device = re.compile('(iPhone|iPad|iPod)') 190 | if (iOS_device.match(prod_descr)): 191 | # iOS: Use date of manufacture + 2 years for max AppleCare coverage 192 | return u'' + apple_year_offset(dateutil.parser.parse(manu_date), 2).strftime('%Y-%m-%d') 193 | else: 194 | # Mac: Use date of manufacture + 3 years for max AppleCare coverage 195 | return u'' + apple_year_offset(dateutil.parser.parse(manu_date), 3).strftime('%Y-%m-%d') 196 | 197 | def offline_estimated_warranty_end_date(details): 198 | manu_date = details[u'EST_MANUFACTURE_DATE'] 199 | return u'' + apple_year_offset(dateutil.parser.parse(manu_date), 1).strftime('%Y-%m-%d') 200 | 201 | def warranty_generator(serials): 202 | # One or more arguments can be passed. 203 | # The arguments can be a single string or a sequence of strings 204 | for serial in serials: 205 | if not isinstance(serial, str): 206 | # if (not hasattr(serial, "strip") and hasattr(serial, "__getitem__") or hasattr(serial, "__iter__")): 207 | # Iterable, but not a string - recurse using items of the sequence as individual arguments 208 | for result in warranty_generator(*serial): 209 | yield result 210 | else: 211 | # Assume string and continue 212 | prod_dict = blank_machine_dict() 213 | prod_dict['SERIAL_ID'] = serial 214 | try: 215 | prod_descr = offline_snippet_lookup(prod_dict['SERIAL_ID']) 216 | except: 217 | prod_descr = None 218 | if (not prod_descr): 219 | # It's not in the snippet database, look it up 220 | try: 221 | prod_descr = online_snippet_lookup(prod_dict['SERIAL_ID']) 222 | except: 223 | prod_descr = None 224 | if (not prod_descr): 225 | prod_dict[u'ERROR_CODE'] = 'Unknown model snippet' 226 | try: 227 | prod_dict[u'PROD_DESCR'] = '' + prod_descr 228 | except: 229 | prod_dict[u'PROD_DESCR'] = '' 230 | # Fill in some details with estimations 231 | try: 232 | prod_dict[u'EST_MANUFACTURE_DATE'] = offline_estimated_manufacture(serial) 233 | except: 234 | prod_dict[u'EST_MANUFACTURE_DATE'] = u'' 235 | if (prod_dict[u'EST_MANUFACTURE_DATE']): 236 | # Try to estimate when coverages expire 237 | prod_dict[u'EST_PURCHASE_DATE'] = u'' + prod_dict[u'EST_MANUFACTURE_DATE'] 238 | prod_dict[u'EST_WARRANTY_END_DATE'] = offline_estimated_warranty_end_date(prod_dict) 239 | prod_dict[u'EST_APPLECARE_END_DATE'] = offline_estimated_applecare_end_date(prod_dict) 240 | if (datetime.datetime.now() > dateutil.parser.parse(prod_dict[u'EST_APPLECARE_END_DATE'])): 241 | prod_dict[u'EST_WARRANTY_STATUS'] = u'EXPIRED' 242 | elif (datetime.datetime.now() > dateutil.parser.parse(prod_dict[u'EST_WARRANTY_END_DATE'])): 243 | prod_dict[u'EST_WARRANTY_STATUS'] = u'APPLECARE' 244 | else: 245 | prod_dict[u'EST_WARRANTY_STATUS'] = u'LIMITED' 246 | yield prod_dict 247 | 248 | def warranty(*serials): 249 | if not serials: 250 | return None 251 | results = list(warranty_generator(serials)) 252 | if (len(serials) == 1) and (len(results) == 1): 253 | if (hasattr(serials[0], "strip") and hasattr(serials[0], "__getitem__") and not hasattr(serials[0], "__iter__")): 254 | return results[0] 255 | return results 256 | 257 | # Deprecated, see notes above 258 | online_warranty_generator = warranty_generator 259 | 260 | # Deprecated, see notes above 261 | online_warranty = warranty 262 | 263 | # Deprecated, see notes above 264 | offline_warranty_generator = warranty_generator 265 | 266 | # Deprecated, see notes above 267 | offline_warranty = warranty 268 | 269 | def my_serial(): 270 | return [(subprocess.Popen("system_profiler SPHardwareDataType |grep -v tray |awk '/Serial/ {print $4}'", shell=True, stdout=subprocess.PIPE).communicate()[0].strip().decode("UTF-8"))] 271 | 272 | class UnicodeWriter: 273 | def __init__(self, f, dialect=csv.excel, encoding="utf-8", **kwds): 274 | self.queue = StringIO.StringIO() 275 | self.writer = csv.writer(self.queue, dialect=dialect, **kwds) 276 | self.stream = f 277 | self.encoder = codecs.getincrementalencoder(encoding)() 278 | def writerow(self, row): 279 | temp = [] 280 | for s in row: 281 | if (type(s) == types.IntType): 282 | temp.append(str(s)) 283 | else: 284 | temp.append(s) 285 | self.writer.writerow([s.encode("utf-8") for s in temp]) 286 | data = self.queue.getvalue() 287 | data = data.decode("utf-8") 288 | data = self.encoder.encode(data) 289 | self.stream.write(data) 290 | self.queue.truncate(0) 291 | def writerows(self, rows): 292 | for row in rows: 293 | self.writerow(row) 294 | 295 | def usage(): 296 | print (__doc__) 297 | sys.exit() 298 | 299 | def main(): 300 | try: 301 | opts, args = getopt.getopt(sys.argv[1:], "hf:o:ct", ["help", "file=", "output=","csv","tsv"]) 302 | except getopt.GetoptError as err: 303 | print (str(err)) 304 | usage() 305 | outfile,format = None,None 306 | for o, a in opts: 307 | if o in ("-h", "--help"): 308 | usage() 309 | elif o in ("-f", "--file"): 310 | try: 311 | f = open(a, "rb") 312 | except: 313 | print ("Unable to read file:", a) 314 | usage() 315 | args = [line for line in f.read().splitlines() if line.strip()] 316 | f.close() 317 | elif o in ("-o", "--output"): 318 | outfile = a 319 | elif o in ("-c", "--csv"): 320 | format = "csv" 321 | elif o in ("-t", "--tsv"): 322 | format = "tsv" 323 | # Whatever args remain are considered serials 324 | serials = args 325 | warranty_dicts = [] 326 | 327 | print(offline_estimated_manufacture("C02PQB5VG8WP")) 328 | 329 | for serial in (serials or my_serial()): 330 | results = warranty(serial) 331 | # print(results) 332 | if type(results) == dict: 333 | results = [results] 334 | warranty_dicts.extend(results) 335 | csv.register_dialect('exceltab', delimiter='\t') 336 | csv.register_dialect('excel', delimiter=',') 337 | # writer = UnicodeWriter(outfile, dialect='exceltab') 338 | if (not format): 339 | plain_format = "" 340 | for result in warranty_dicts: 341 | plain_format += "%s: %s\n" % (u'SERIAL_ID', result[u'SERIAL_ID']) 342 | plain_format += "%s: %s\n" % (u'PROD_DESCR', result[u'PROD_DESCR']) 343 | for key,val in sorted(result.items(), key=lambda x: x[0]): 344 | if (key not in (u'SERIAL_ID', u'PROD_DESCR', u'ERROR_CODE')): 345 | plain_format += "%s: %s\n" % (key, val) 346 | plain_format += "%s: %s\n\n" % (u'ERROR_CODE', result[u'ERROR_CODE']) 347 | if (not outfile): 348 | sys.stdout.write(plain_format) 349 | else: 350 | open(outfile,"wb").write(plain_format) 351 | elif (format in ['csv','tsv']): 352 | if (not outfile): 353 | outfile = sys.stdout 354 | else: 355 | outfile = open(outfile, "wb") 356 | dialect = {'csv': 'excel', 'tsv': 'exceltab'}[format] 357 | writer = UnicodeWriter(outfile, dialect=dialect) 358 | # write out headers 359 | sample_machine = blank_machine_dict() 360 | header_row = [u'SERIAL_ID', u'PROD_DESCR'] 361 | for key,val in sorted(sample_machine.items(), key=lambda x: x[0]): 362 | if (key not in (u'SERIAL_ID', u'PROD_DESCR', u'ERROR_CODE')): 363 | header_row.append(key) 364 | header_row.append(u'ERROR_CODE') 365 | writer.writerow(header_row) 366 | for result in warranty_dicts: 367 | row_data = [result[u'SERIAL_ID']] 368 | row_data.append(result[u'PROD_DESCR']) 369 | for key,val in sorted(result.items(), key=lambda x: x[0]): 370 | if (key not in (u'SERIAL_ID', u'PROD_DESCR', u'ERROR_CODE')): 371 | row_data.append(val) 372 | row_data.append(result[u'ERROR_CODE']) 373 | writer.writerow(row_data) 374 | 375 | if __name__ == "__main__": 376 | main() 377 | -------------------------------------------------------------------------------- /lib/macmodelshelf.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cwolfram/inge/4e585db20fd4f723b8fcae324c6f0b4991e460ac/lib/macmodelshelf.db -------------------------------------------------------------------------------- /lib/macmodelshelf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | import sys 5 | import shelve 6 | try: 7 | # For Python 3.0 and later 8 | from urllib.request import urlopen 9 | except ImportError: 10 | # Fall back to Python 2's urllib2 11 | from urllib2 import urlopen 12 | 13 | from xml.etree import ElementTree 14 | 15 | 16 | DBPATH = "lib/macmodelshelf" 17 | 18 | try: 19 | macmodelshelf = shelve.open(DBPATH) 20 | except BaseException as e: 21 | print ("Couldn't open macmodelshelf.db: %s" % e, file=sys.stderr) 22 | sys.exit(1) 23 | 24 | 25 | def model_code(serial): 26 | if "serial" in serial.lower(): # Workaround for machines with dummy serial numbers. 27 | return None 28 | if len(serial) in (12, 13) and serial.startswith("S"): # Remove S prefix from scanned codes. 29 | serial = serial[1:] 30 | if len(serial) in (11, 12): 31 | return serial[8:] 32 | return None 33 | 34 | 35 | def lookup_mac_model_code_from_apple(model_code): 36 | try: 37 | f = urlopen("http://support-sp.apple.com/sp/product?cc=%s&lang=en_US" % model_code, timeout=2) 38 | et = ElementTree.parse(f) 39 | return et.findtext("configCode") 40 | except: 41 | return None 42 | 43 | 44 | def model(code): 45 | global macmodelshelf 46 | code = code.upper() 47 | try: 48 | model = macmodelshelf[code] 49 | except KeyError: 50 | print ("Looking up %s from Apple" % code, file=sys.stderr) 51 | model = lookup_mac_model_code_from_apple(code) 52 | if model: 53 | macmodelshelf[code] = model 54 | return model 55 | 56 | 57 | def _dump(): 58 | print ("macmodelshelfdump = {") 59 | for code, model in sorted(macmodelshelf.items()): 60 | print (' "%s": "%s",' % (code, model)) 61 | print ("}") 62 | 63 | 64 | if __name__ == '__main__': 65 | try: 66 | if sys.argv[1] == "dump": 67 | _dump() 68 | sys.exit(0) 69 | if len(sys.argv[1]) in (11, 12, 13): 70 | m = model(model_code(sys.argv[1])) 71 | else: 72 | m = model(sys.argv[1]) 73 | if m: 74 | print (m) 75 | sys.exit(0) 76 | else: 77 | print ("Unknown model %s" % repr(sys.argv[1]), file=sys.stderr) 78 | sys.exit(1) 79 | except IndexError: 80 | print ("Usage: macmodelshelf.py serial_number") 81 | sys.exit(1) 82 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='INGE - Inventory Gone Easy', 5 | version='0.1', 6 | py_modules=['inge'], 7 | author='Carsten Wolfram', 8 | author_email='mail@carstenwolfram.de', 9 | install_requires=[ 10 | 'Click', 11 | 'jira', 12 | 'python-dateutil', 13 | 'prettytable', 14 | 'oauthlib', 15 | 'jwt', 16 | 'cryptography', 17 | 'pyjwt' 18 | ], entry_points=''' 19 | [console_scripts] 20 | inge=inge:main 21 | ''' 22 | ) 23 | --------------------------------------------------------------------------------