├── .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 |
--------------------------------------------------------------------------------