js-mw-api 2.5.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
js_mw_api/__init__.py ADDED
File without changes
js_mw_api/mwapi.py ADDED
@@ -0,0 +1,1226 @@
1
+ import requests
2
+ from datetime import datetime, timedelta
3
+ import os
4
+ import json
5
+ from bs4 import BeautifulSoup
6
+ import pandas as pd
7
+ import re
8
+ import urllib.parse
9
+ from cryptography.fernet import Fernet
10
+ import numpy as np
11
+
12
+ # TODO: Adjust functions to use MorawareSession.session as a common requests session, rather than creating new sessions for each request. This will allow for better session management and less overhead. Although this can cause issues with multithreading, so will need to be tested.
13
+ # Can be done by optionally passing the session to the functions, and using it in the requests.get/post calls.
14
+
15
+ DEFAULTMORAWARECREDS = {'user': None,
16
+ 'pwd': None}
17
+
18
+ JOBCARD_FIELDMAPPING = {
19
+ 'Notes' : '149',
20
+ 'Mitre' : '580',
21
+ 'Tap' : '103',
22
+ 'Hob' : '104',
23
+ 'Waterjet' : '645',
24
+ 'GlueUp' : '1093',
25
+ 'Edge' : '100',
26
+ 'Qtd_TI' : '1177',
27
+ 'FORM_IDENTIFIER' : 'CUSTOMER AND SITE DETAILS'}
28
+
29
+ CLIENT = None
30
+
31
+
32
+ def get_env_variable(key, default=None):
33
+ return os.environ.get(key, default)
34
+
35
+ # Get the client, default creds and field mappings from the environment variables.
36
+ CLIENT = get_env_variable('MORAWARE_CLIENT')
37
+ JOBCARD_FIELDMAPPING = json.loads(get_env_variable('JOBCARD_FIELDMAPPING', '{}'))
38
+ DEFAULTMORAWARECREDS = json.loads(get_env_variable('DEFAULTMORAWARECREDS', '{}'))
39
+
40
+
41
+ #############################################################################################################################################################################
42
+ ################################################################################# Functions #################################################################################
43
+ #############################################################################################################################################################################
44
+
45
+ ############################################################################## General Functions ############################################################################
46
+
47
+ def mw_login_cookie(mw_cookie):
48
+ return mw_cookie.mw_cookie if hasattr(mw_cookie, 'mw_cookie') else mw_cookie
49
+
50
+ def Give_Key():
51
+ '''
52
+ Generates a key for use in encrypting and decrypting strings.
53
+ '''
54
+ return Fernet.generate_key()
55
+
56
+ def encrypt_string(string, key):
57
+ '''
58
+ Encrypts a string using a key.
59
+ Args:
60
+ string (str): The string to encrypt.
61
+ key (str): The key to use for encryption.
62
+ Returns:
63
+ str: The encrypted string.
64
+ '''
65
+ cipher_suite = Fernet(key)
66
+ return cipher_suite.encrypt(string.encode()).decode()
67
+
68
+ def decrypt_string(string, key):
69
+ '''
70
+ Decrypts a string using a key.
71
+ Args:
72
+ string (str): The string to decrypt.
73
+ key (str): The key to use for decryption.
74
+ Returns:
75
+ str: The decrypted string.
76
+ '''
77
+ cipher_suite = Fernet(key)
78
+ return cipher_suite.decrypt(string.encode()).decode()
79
+
80
+ def convert_float(value):
81
+ if isinstance(value, float):
82
+ if np.isnan(value): return ''
83
+ elif np.isinf(value): return True
84
+ if isinstance(value, str) and value.upper() in ('TRUE', 'FALSE'): return value == 'TRUE'
85
+ return value
86
+
87
+ def convert_date(input_string, desired_format):
88
+ if not input_string: return input_string
89
+ for fmt in ('%d/%m/%Y', '%Y-%m-%d', '%d-%m-%Y', '%Y/%m/%d'):
90
+ try:
91
+ dt = datetime.strptime(input_string, fmt)
92
+ return dt.strftime(desired_format)
93
+ except: pass
94
+ print('Date Conversion Failed')
95
+ return input_string
96
+
97
+ def Give_24hrTime(time):
98
+ if not time: return time
99
+ try:
100
+ return datetime.strptime(time, '%I:%M%p').strftime('%H:%M')
101
+ except:
102
+ print('Time Conversion Failed')
103
+ return time
104
+
105
+ def Encode_Dict_to_String(dict_to_encode):
106
+ '''
107
+ Encode a dictionary to a string for use in Moraware updates.
108
+ Gives the P0 field for creating a job from a quote.
109
+ '''
110
+ encoded_items = [f'"{k}":{v}' if isinstance(v, int) else f'"{k}":"{v}"' for k, v in dict_to_encode.items()]
111
+ return '{' + ','.join(encoded_items) + '}'
112
+
113
+
114
+
115
+ ############################################################################### Moraware Classes ###############################################################################
116
+
117
+ class LoggingSession(requests.Session):
118
+ def __init__(self, manager):
119
+ super().__init__()
120
+ self.last_request_time = None
121
+ self.manager = manager
122
+ def request(self, *args, **kwargs):
123
+ if kwargs.get('JS_CORE'): kwargs.pop('JS_CORE') #Can implement other ways to handle different JS_CORE values
124
+ else: self.manager.refresh() #Because JS_Core app is inteded to manage it's own session refreshes.
125
+ self.last_request_time = self.manager.active = datetime.now()
126
+ response = super().request(*args, **kwargs)
127
+ return response
128
+
129
+
130
+ class MorawareSession:
131
+ '''
132
+ Create a Moraware login session, or fetch login session data from json file in this directory.
133
+ If no session data is found, or the session data is expired, a new session is created.
134
+ Will be defualt use seed data including credentials and decryption key.
135
+ Otherwise will use entered credetntials and look for a json file with the client name and user name.
136
+ Can quickly and easily be converted to and from json for storage - with the key for encryption.
137
+
138
+ Attributes:
139
+ session (requests.Session): the session object for the Moraware login.
140
+ mw_cookie (dict): the cookie data for the Moraware login.
141
+ expiry (datetime): the expiry time for the Moraware login.
142
+ sessionheaders (dict): the headers for the Moraware login
143
+ client (str): the client name for the Moraware login.
144
+ user (str): the user name for the Moraware login.
145
+ active (datetime): the last time the session was used.
146
+
147
+ Methods:
148
+ LoginProcess: logs in to Moraware and updates the session data.
149
+ expired: returns True if the session has expired
150
+ refresh: refreshes the session if it is close to expiry.
151
+ to_json: returns the session data as a json string.
152
+ from_json: creates a MorawareSession object from a json string.
153
+
154
+ '''
155
+ loginform = {'action': 'login',
156
+ 'LOGIN': 'Sign In',
157
+ }
158
+ name = 'Moraware'
159
+ user = ''
160
+
161
+ def __init__(self, client=CLIENT, UserCreds={}, Session=None, SeedData=None, key=None, **kwargs):
162
+ if SeedData:
163
+ self.client = SeedData.get('client', CLIENT)
164
+ self.endpoint = f'https://{self.client}.moraware.net/'
165
+ self.user = SeedData.get('user', '')
166
+ self.mw_cookie = SeedData.get('mw_cookie', None)
167
+ self.expiry = SeedData.get('expiry', datetime.now())
168
+ self.active = SeedData.get('active', datetime.now())
169
+ self.sessionheaders = SeedData.get('sessionheaders', {})
170
+ pwd = decrypt_string(SeedData.get('pwd', ''), key) if SeedData.get('pwd') else ''
171
+ self.loginform.update( {'user': SeedData.get('user', ''), 'pwd': pwd} )
172
+
173
+ else:
174
+ self.client = client
175
+ self.endpoint = f'https://{client}.moraware.net/'
176
+ self.loginform.update(UserCreds)
177
+ self.user = UserCreds.get('user', '')
178
+ cookiedat = {}
179
+ if os.path.exists(f'MWS_{client}_{self.user}.json'):
180
+ with open(f'MWS_{client}_{self.user}.json', 'r') as jf: cookiedat = json.load(jf)
181
+
182
+ self.mw_cookie = cookiedat.get('mw_cookie')
183
+ self.expiry = datetime.fromisoformat(cookiedat.get('expiry', '1900-01-01T00:00:00'))
184
+ self.active = datetime.fromisoformat(cookiedat.get('active', '1900-01-01T00:00:00'))
185
+ self.sessionheaders = cookiedat.get('sessionheaders')
186
+
187
+ self.session = Session if Session else LoggingSession(self)
188
+ if self.mw_cookie:
189
+ self.session.cookies.update(self.mw_cookie)
190
+ self.session.headers.update(self.sessionheaders)
191
+
192
+ if not self.mw_cookie or datetime.now() > self.expiry:
193
+ self.LoginProcess()
194
+
195
+ def LoginProcess(self):
196
+ print('Logging in to Moraware...')
197
+ s = self.session
198
+ s.cookies.clear()
199
+ rsp = s.post(self.endpoint, data=self.loginform, JS_CORE='login')
200
+ #read the response page for successful login
201
+ page = BeautifulSoup(rsp.content, 'html.parser')
202
+ if 'sign in' in page.title.text.lower(): raise Exception('Login Failed')
203
+
204
+ self.sessionheaders = dict(s.headers)
205
+ self.expiry = datetime.now() + timedelta(minutes=50)
206
+ self.mw_cookie = s.cookies.get_dict()
207
+ self.active = datetime.now()
208
+ with open(f'MWS_{self.client}_{self.user}.json', 'w') as jsonpointer:
209
+ json.dump({'mw_cookie': self.mw_cookie,
210
+ 'expiry' : self.expiry.isoformat(),
211
+ 'active' : self.active.isoformat(),
212
+ 'user': self.user,
213
+ 'sessionheaders': self.sessionheaders}, jsonpointer)
214
+ return self.mw_cookie
215
+
216
+ def refresh(self):
217
+ lr = self.session.last_request_time
218
+ if not lr: lr = self.active
219
+ if datetime.now() - lr > timedelta(minutes=55):
220
+ self.LoginProcess()
221
+ #return (True, 'Initialised')
222
+ return {'active': self.active, 'status': 'Initialised'}
223
+ elif datetime.now() - lr > timedelta(minutes=45):
224
+ self.session.get(self.endpoint, JS_CORE='refresh')
225
+ self.expiry = datetime.now() + timedelta(minutes=55)
226
+ return {'active': self.active, 'status': 'Refreshed'}
227
+ return {'active': self.active, 'status': 'Active'}
228
+
229
+ def expired(self):
230
+ lr = self.session.last_request_time
231
+ if not lr: lr = self.active
232
+ if datetime.now() - lr > timedelta(minutes=55):
233
+ return True
234
+
235
+ def __str__(self):
236
+ return f'Moraware Session: {self.client}/{self.user}/{self.mw_cookie}'
237
+
238
+ def to_json(self, key=None):
239
+ return json.dumps({'mw_cookie': self.mw_cookie,
240
+ 'expiry' : self.expiry.isoformat(),
241
+ 'active' : self.active.isoformat(),
242
+ 'sessionheaders': self.sessionheaders,
243
+ 'client': self.client,
244
+ 'user': self.user,
245
+ 'pwd': encrypt_string(self.loginform['pwd'], key) if key else ''})
246
+
247
+
248
+ @classmethod
249
+ def from_json(cls, jsondata, key=None):
250
+ data = json.loads(jsondata)
251
+ if data.get('expiry'):
252
+ data['expiry'] = datetime.fromisoformat(data['expiry'])
253
+ if data.get('active'):
254
+ data['active'] = datetime.fromisoformat(data['active'])
255
+ return cls(SeedData=data, key=key)
256
+
257
+
258
+ class MorawareConnection:
259
+ '''
260
+ Class for managing client Moraware data.
261
+ This class stores IDs for various Moraware data, and fetches them if they are not present in the data dictionary, and updates the data dictionary.
262
+ Intended primarily for managing the data dictionary for a single client, and providing that data to other classes.
263
+ Can be converted to and from json for storage.
264
+ Can create a Moraware Session object inside itself to manage the login.
265
+
266
+ Attributes:
267
+ session (MorawareSession): the session object for the Moraware login.
268
+ client (str): the client name for the Moraware login.
269
+ Data (dict): the data dictionary for the client.
270
+
271
+ Methods:
272
+ data_check: checks if a key is in the data dictionary.
273
+ data_update: updates the data dictionary with a key and value.
274
+ data_check_update: checks if a key is in the data dictionary, and if not, fetches the data and updates the dictionary.
275
+ to_json: returns the session data as a json string.
276
+ from_json: creates a MorawareConnection object from a json string.
277
+ check_refresh_session: checks if the session is expired, and refreshes it if necessary.
278
+ GetQuote: fetches a Moraware Quote object.
279
+
280
+ '''
281
+ def __init__(self, Session=None, **kwargs):
282
+ if Session:
283
+ self.session = Session
284
+ client = Session.client
285
+ else:
286
+ client = kwargs.get('client', CLIENT)
287
+ self.session = MorawareSession(**kwargs)
288
+ self.client = client
289
+
290
+ self.Data = {}
291
+ if os.path.exists(f'{client}_MCD.json'):
292
+ with open(f'{client}_MCD.json', 'r') as f:
293
+ self.Data = json.load(f)
294
+ if 'data' in kwargs:
295
+ self.Data.update(kwargs['data'])
296
+ with open(f'{client}_MCD.json', 'w') as f:
297
+ json.dump(self.Data, f)
298
+ self.check_refresh_session()
299
+
300
+ def __str__(self):
301
+ return f'Moraware Connection: {self.client} - {self.session}'
302
+
303
+ @property
304
+ def mw_cookie(self):
305
+ return self.session.mw_cookie
306
+
307
+ def data_check(self, key):
308
+ if key not in self.Data:
309
+ return False
310
+ return True
311
+
312
+ def data_update(self, key, value):
313
+ self.Data[key] = value
314
+ with open(f'{self.client}_MCD.json', 'w') as f:
315
+ json.dump(self.Data, f)
316
+
317
+ def data_check_update(self, keys):
318
+ func = {'StatusIDs': GetActivityStatusIDs,
319
+ 'ActivityIDs': GetActivityIDs,
320
+ 'ProductIDs': GetALLInventoryIDs,
321
+ #'JobCardFieldMapping':
322
+ }
323
+ if not ( isinstance(keys, list) or isinstance(keys, tuple) ): keys = [keys]
324
+ for key in keys:
325
+ if not self.Data.get(key):
326
+ if key in func: self.data_update(key, func[key](self.session, self.client))
327
+ else: raise Exception(f'Key {key} not found in data dictionary, and no function to fetch it.')
328
+
329
+ def Give(self, key):
330
+ self.data_check_update(key)
331
+ return self.Data[key]
332
+
333
+ def to_json(self, key=None):
334
+ return json.dumps({'client': self.client,
335
+ 'data': self.Data,
336
+ 'session': self.session.to_json(key)})
337
+
338
+ @classmethod
339
+ def from_json(cls, jsondata, key=None):
340
+ data = json.loads(jsondata)
341
+ return cls(Session=MorawareSession.from_json(data['session'], key), **data)
342
+
343
+ def check_refresh_session(self, force=None):
344
+ if self.session.expired() or force:
345
+ self.session.LoginProcess()
346
+
347
+ def GetQuote(self, QuoteID):
348
+ self.check_refresh_session()
349
+ return MorawareQuote(QuoteID, self)
350
+
351
+ def GetView(self, url, headers=None, IDCol_Index=[0], TableType=None):
352
+ self.check_refresh_session()
353
+ return Fetch_View(url, self.mw_cookie, headers, IDCol_Index, TableType)
354
+
355
+ def GetJob(self, JobID):
356
+ self.check_refresh_session()
357
+ return MorawareJob(self, JobID)
358
+
359
+
360
+
361
+
362
+ class MorawareQuote:
363
+ '''
364
+ A Moraware Quote Object.
365
+ '''
366
+ def __init__(self, QuoteID, mw_Connect):
367
+ self.QuoteID = QuoteID
368
+ self.QuoteData = GetQuoteDetails(QuoteID, mw_Connect.mw_cookie, mw_Connect.client)
369
+ self.mw_Connect = mw_Connect
370
+
371
+ def CreateJob(self, JobName):
372
+ '''
373
+ Create a Job from the Quote (conduct normal new-job procedures).
374
+ Send back signal to MorawareConnection object to check to required resources, and call fetching of those resources if necessary.
375
+ '''
376
+ #mw_cookie = self.mw_Connect.mw_cookie
377
+ self.QuoteData = CreateJobFromQuote(JobName, self.QuoteID, self.mw_Connect.mw_cookie, self.QuoteData, self.mw_Connect.client)
378
+ job = MorawareJob(self.mw_Connect, self.QuoteData['Job ID'])
379
+ formfields = self.mw_Connect.Give('JobCardFieldMapping')
380
+ NewJob_Updates(job, self, formfields)
381
+ return job
382
+
383
+
384
+ class MorawareJob:
385
+ '''
386
+ A Moraware Job object
387
+ '''
388
+ def __init__(self, mw_Connect, JobID):
389
+ self.JobID = JobID
390
+ self.mw_Connect = mw_Connect
391
+ self.JobData = GetJobDetails(mw_Connect, JobID)
392
+
393
+ def UpdateActivity(self, ActivityType, Updates):
394
+ '''
395
+ Update an activity in the job.
396
+ '''
397
+ return UpdateJobActivity(self.JobID, ActivityType, Updates, self)
398
+
399
+ def UpdateJobForm(self, formid, fields):
400
+ '''
401
+ Update a Job Form on the job.
402
+ '''
403
+ fields.update({'P0': formid})
404
+ return UpdateMoraware(self.JobID, 'Job_UpdateJobForm', 'job', fields, self.mw_Connect.mw_cookie, self.mw_Connect.client)
405
+
406
+ def CreateActivity(self, **Updates):
407
+ '''
408
+ Create an activity in the job.
409
+ '''
410
+ return CreateJobActivity(mw_Connect=self.mw_Connect, JobID=self.JobID, **Updates)
411
+
412
+ def AllocateMaterial(self, ActivityID, MaterialData):
413
+ '''
414
+ Allocate material to the job.
415
+ '''
416
+ return AllocateMaterial(JobID=self.JobID,
417
+ ActivityID=ActivityID,
418
+ MaterialData=MaterialData,
419
+ mw_cookie=self.mw_Connect.mw_cookie,
420
+ client=self.mw_Connect.client)
421
+
422
+
423
+
424
+ ############################################################################### Moraware Update Function ###############################################################################
425
+
426
+ def UpdateMoraware(Serial, FormType='Job_UpdateJobActivities', endpoint='job', fields={}, mw_cookie=None, client=CLIENT):
427
+ '''
428
+ Publishes an update form to Moraware.
429
+ Update form is populated with the fields dictionary.
430
+ fields are urlencoded and formatted to match the Moraware form.
431
+ Example:
432
+ P0 : '[str_or_int]'
433
+
434
+ Args:
435
+ Serial: Serial number of the object to update (job, inventory, quote etc).
436
+ FormType: Type of form to update. This will also set the category for the update.
437
+ endpoint: Endpoint for the update.
438
+ fields: Dictionary containing the fields to update.
439
+ mw_cookie: Login cookie for Moraware.
440
+
441
+ Returns:
442
+ response: Response from the Moraware server.
443
+ '''
444
+ mw_cookie = mw_login_cookie(mw_cookie)
445
+ payload = {
446
+ 'C': 'mjtrs1',
447
+ 'X': 1,
448
+ 'cuid': 24,
449
+ 'F': FormType}
450
+ for key, value in fields.items():
451
+ if isinstance(value, str): fields[key] = f'[{urllib.parse.quote(value)}]'
452
+ else: fields[key] = f'[{value}]'
453
+ payload.update(fields)
454
+ ts = int(datetime.now().timestamp() * 1000000)
455
+ response = requests.post(f'https://{client}.moraware.net/sys/{endpoint}/{Serial}', params={'U': ts}, data=payload, cookies=mw_cookie)
456
+ print(response.content)
457
+ if 'MW_RS_ErrorResponse' in response.text: raise Exception(f'Error updating Moraware: {response.text}')
458
+ return response
459
+
460
+
461
+
462
+ ######################################################################### Moraware Connection Data Fetch Functions ######################################################################
463
+
464
+ def GetActivityStatusIDs(mw_cookie, client=CLIENT):
465
+ '''
466
+ Fetches the activity status IDs from Moraware.
467
+ '''
468
+ mw_cookie = mw_login_cookie(mw_cookie)
469
+ url = f'https://{client}.moraware.net/sys/settings/jobactivitystatuses/'
470
+ Activities = {}
471
+ Statuss = requests.get(url, cookies=mw_cookie)
472
+ soup = BeautifulSoup(Statuss.content, 'html.parser')
473
+ table = soup.find('table', {'id': 'ActivityStatusesBody'})
474
+ rows = table.find_all('tr')
475
+ for i,row in enumerate(rows):
476
+ if i == 0: continue
477
+ td_elements = row.find_all('td')
478
+ if td_elements:
479
+ mjtclick_attr = td_elements[0].get('mjtclick', '')
480
+ start = mjtclick_attr.find(',') + 1
481
+ end = mjtclick_attr.find(')', start)
482
+ ActID = mjtclick_attr[start:end].strip()
483
+ ActName = td_elements[0].text.strip()
484
+ Activities[ActName] = ActID
485
+ return Activities
486
+
487
+ def GetActivityIDs(mw_cookie, client=CLIENT):
488
+ '''
489
+ Fetches the activity Type IDs from Moraware.
490
+ '''
491
+ mw_cookie = mw_login_cookie(mw_cookie)
492
+ url = f'https://{client}.moraware.net/sys/settings/jobactivitytypes/'
493
+ Activities = {}
494
+ acts = requests.get(url, cookies=mw_cookie)
495
+ soup = BeautifulSoup(acts.content, 'html.parser')
496
+ table = soup.find('table', {'id': 'ActivityTypesBody'})
497
+ rows = table.find_all('tr')
498
+ for row in rows:
499
+ #if the row isn't of class oddRow or evenRow, then ignore it.
500
+ if not row.get('class') or row.get('class')[0] not in ('oddRow', 'evenRow'): continue
501
+ Act_element = row.find_all('td')[0]
502
+ ActID = Act_element['mjtclick'].split(',')[1].strip()
503
+ ActName = Act_element.text.strip()
504
+ Activities[ActName] = ActID
505
+ return Activities
506
+
507
+ def Get_InvProductNamesID(mw_cookie, client=CLIENT):
508
+ '''
509
+ Fetches the inventory product IDs from Moraware.
510
+ '''
511
+ mw_cookie = mw_login_cookie(mw_cookie)
512
+ url = f'https://{client}.moraware.net/sys/settings/purchaseproducts/'
513
+ inv = requests.get(url, cookies=mw_cookie)
514
+ soup = BeautifulSoup(inv.content, 'html.parser')
515
+ table = soup.find('table', {'id': 'ProductsBody'})
516
+ rows = table.find_all('tr')
517
+ headers = [hd.text.encode('ascii', 'ignore').decode() for hd in rows[0].find_all('td')]
518
+ for q, header in enumerate(headers):
519
+ if 'attribute' in header.lower():
520
+ AttrHeaders = [f'{header} - {i+1}' for i in range(len(rows[1].find_all('td')))]
521
+ break
522
+ headers = headers[:q] + AttrHeaders + headers[q+1:] + ['Product ID']
523
+ prod_data = []
524
+ for row in rows[2:]:
525
+ cells = row.find_all('td')
526
+ if not cells: continue
527
+ line = {headers[i]: cell.text for i, cell in enumerate(cells)}
528
+ prod_page = row.find('a')['href']
529
+ Prod_ID = prod_page.split('/')[-1]
530
+ line['Product ID'] = Prod_ID
531
+ line['url'] = f'https://{client}.moraware.net{prod_page}'
532
+ prod_data.append(line)
533
+ return prod_data
534
+
535
+ def Get_InvProductAttrListIDs(ProductID, mw_cookie, client=CLIENT):
536
+ '''
537
+ Fetches the inventory product attribute list IDs from Moraware.
538
+ '''
539
+ mw_cookie = mw_login_cookie(mw_cookie)
540
+ url = f'https://{client}.moraware.net/sys/settings/purchaseproduct/{ProductID}/'
541
+ AttrList = {}
542
+ inv = requests.get(url, cookies=mw_cookie)
543
+ soup = BeautifulSoup(inv.content, 'html.parser')
544
+ table = soup.find('table', {'id': 'attrScrollBody'})
545
+ if not table: return {}
546
+ rows = table.find_all('tr')
547
+ for row in rows:
548
+ if not row.find('a'): continue
549
+ AttrListID = row.find('a')['href'].split('/')[-1]
550
+ AttrName = row.find('a').text
551
+ AttrList[AttrName] = AttrListID
552
+ return AttrList
553
+
554
+ def Get_InvProductAttrIDs(attrListID, mw_cookie, client=CLIENT):
555
+ '''
556
+ Fetches the inventory product attribute IDs from Moraware.
557
+ '''
558
+ mw_cookie = mw_login_cookie(mw_cookie)
559
+ attributes = []
560
+ i = 1
561
+ PrevTopRow = None
562
+ while True:
563
+ url = f'https://{client}.moraware.net/sys/settings/purchaseproductattr/{attrListID}?&avtpage={i}'
564
+ Products = requests.get(url, cookies=mw_cookie)
565
+ soup = BeautifulSoup(Products.content, 'html.parser')
566
+ table = soup.find('table', {'id': 'avtScrollBody'})
567
+ if not table: return []
568
+ table_rows = table.find_all('tr')[1:]
569
+ if PrevTopRow == table_rows[0]: break
570
+ PrevTopRow = table_rows[0]
571
+ for row in table_rows:
572
+ attrElement = row.find(class_='linkedCell')
573
+ if not attrElement: continue
574
+ attrID = attrElement['mjtclick'].split(',')[1].strip(')')
575
+ attrName = attrElement.text
576
+ attributes.append({'ID': attrID, 'Name': attrName})
577
+ i += 1
578
+ return attributes
579
+
580
+ def Get_InventoryID_Data(ProductID, mw_cookie, client=CLIENT):
581
+ '''
582
+ Fetches Inventory Attribute Data from Moraware for a particular product.
583
+ '''
584
+ mw_cookie = mw_login_cookie(mw_cookie)
585
+ ProductAttributes = Get_InvProductAttrListIDs(ProductID, mw_cookie, client)
586
+ for attrList, ID in ProductAttributes.items():
587
+ ProductAttributes[attrList] = Get_InvProductAttrIDs(ID, mw_cookie, client)
588
+ return ProductAttributes
589
+
590
+ def GetALLInventoryIDs(mw_cookie, client=CLIENT):
591
+ '''
592
+ Fetches all inventory data from Moraware.
593
+ '''
594
+ mw_cookie = mw_login_cookie(mw_cookie)
595
+ result = {}
596
+ products = Get_InvProductNamesID(mw_cookie, client)
597
+ print(f'Found {len(products)} products')
598
+ for i,product in enumerate(products):
599
+ print(f'Fetching {i+1} of {len(products)}')
600
+ namekey = f"{product['Product Family']}{product['Product Line']}{product['Product']}"
601
+ result[namekey] = product
602
+ result[namekey]['_ATTR'] = Get_InventoryID_Data(product['Product ID'], mw_cookie, client)
603
+ return result
604
+
605
+
606
+
607
+ ########################################################################### Report and View Fetch Functions ###########################################################################
608
+
609
+ def Fetch_View(url, mw_cookie, headers=None, IDCol_Index=[0], TableType=None):
610
+ '''
611
+ Fetches a Moraware page and returns it as a list of dictionaries.
612
+
613
+ TODO:
614
+ Complete the type checking for the function.
615
+ Switch to using MorawareSession.session for the requests. Pros: Better session management, less overhead. Cons: Multithreading issues.
616
+
617
+ Args:
618
+ url (str): The url of the Moraware page to fetch.
619
+ LoginSession (MorawareSession): The login session to use for fetching the page. DEPRECATED
620
+ mw_cookie (dict): The cookie data for the Moraware login.
621
+ headers (list): The headers for the table.
622
+ IDCol_Index (list): The index of the ID columns in the table.
623
+ TableType (str): The type of table to fetch.
624
+
625
+ Returns:
626
+ list: A list of dictionaries containing the data from the table.
627
+ '''
628
+ if not headers: print('WARNING: Headers not provided for MWFetch. Parsing headers from the page.')
629
+ views_dict = {'quotes' : 'Quotes',
630
+ 'report' : 'Report',
631
+ 'jobs' : 'Jobs',
632
+ 'pos' : 'POs',
633
+ 'inventory' : 'Products',
634
+ 'settings' : None,
635
+ 'accounts' : 'Customers',
636
+ 'jobactivitytypes': 'ActivityTypes',
637
+ 'jobactivitystatuses': 'ActivityStatuses',
638
+ 'assignees' : 'Assignees',
639
+ 'jobfields' : 'JobAttributes',
640
+ 'jobissuecategories': 'IssueCategories'}
641
+ if not TableType:
642
+ view_type = url.split('sys/')[1].split('/')[0].split('?')[0]
643
+ if view_type == 'settings':
644
+ view_type = url.split('settings/')[1].split('/')[0].split('?')[0]
645
+ if view_type not in views_dict: raise Exception('View Type Not Found')
646
+ TableType = views_dict[view_type]
647
+
648
+ rsp = requests.get(url, cookies=mw_cookie) #### s.get(url)
649
+ soup = BeautifulSoup(rsp.content, 'html.parser')
650
+ #Check if the login page is in the response.<title>Moraware Sign in - Moraware Systemize</title>
651
+ if 'sign in' in soup.title.text.lower(): raise Exception('Login Failed')
652
+ if "No Data Available. Press the 'Customize..' button above to change the report parameters." in rsp.text: return []
653
+ table = soup.find( 'table', {'id': f'{TableType}Body'} )
654
+ rows = table.find_all('tr')
655
+ if not headers: headers = [th.text.split('\xa0')[0].strip() for th in rows[0].find_all('td')]
656
+ data = []
657
+ for row in rows[1:]:
658
+ cells = row.find_all('td')
659
+ datarow = {headers[i]: td.text.strip() for i, td in enumerate(cells)}
660
+ for i in IDCol_Index:
661
+ #i = idc - 1
662
+ if i > len(cells): continue
663
+ hd = headers[i]
664
+ try: datarow[f'{hd} ID'] = cells[i].find('a').get('href').split('/')[-1]
665
+ except: datarow[f'{hd} ID'] = 'ID Not Found'
666
+ data.append(datarow)
667
+ return data
668
+
669
+ def Fetch_Report(url, mw_cookie=None, headers=None, IDCol_Index=[0]):
670
+ return Fetch_View(url, mw_cookie, headers, IDCol_Index, 'Report')
671
+
672
+ def FetchNewQuotes(mw_cookie, client=CLIENT):
673
+ url = f'https://{client}.moraware.net/sys/quotes?&view=0&sort=d3&cols=QN1,QA39,CN1,QN2,QN7,QN8,QN4,QN5,QN6,QA15&pageSize=9000&filters=2|3:0:j3:9:j3;1,1,2,,,!5|11:0:q3:9:q3;4,1,3,365,,'
674
+ return Fetch_View(url, mw_cookie)
675
+
676
+
677
+
678
+ ############################################################################### Quote Fetch Functions ###############################################################################
679
+
680
+ def GetQuoteDetails(SN, mw_cookie, client=CLIENT):
681
+ mw_cookie = mw_login_cookie(mw_cookie)
682
+ url = f'https://{client}.moraware.net/sys/quote/{SN}'
683
+ QuoteDetails = requests.get(url, cookies=mw_cookie)
684
+ soup = BeautifulSoup(QuoteDetails.content, 'html.parser')
685
+ main_table = soup.find('table', {'class': 'pageInfoCenteredJustifiedTable'})
686
+ rows = main_table.find_all('tr')
687
+ QuoteMainData = {'_QuoteID': SN}
688
+ for row in rows:
689
+ cols = row.find_all('td')
690
+ colsb = ['\n'.join([st for st in ele.stripped_strings]) for ele in cols]
691
+ QuoteMainData[colsb[0].strip(':')] = colsb[1]
692
+
693
+ quotelines_table = soup.find('table', {'class': 'quoteLinesTable'})
694
+ rows = quotelines_table.find_all('tr')
695
+ QuoteLines = []
696
+ for row in rows:
697
+ cols = row.find_all('td')
698
+ rowdata = []
699
+ for ele in cols:
700
+ el = ele.text.strip().replace('\uf111','').replace('×', 'x')
701
+ if el: rowdata.append(el)
702
+ QuoteLines.append(rowdata)
703
+
704
+ QuoteLineData = []
705
+ for i,line in enumerate(QuoteLines):
706
+ if not line: continue
707
+ if i==0: QuoteLineData.append( ['Quoted', ''.join(line)] )
708
+ elif 'Measurement' in line[0].title():
709
+ Pieces = []
710
+ QuoteLineData.append( ['Measurements', ''.join(line).replace('\xa0', ' ')] )
711
+ for piece in line[0].split(','):
712
+ piece = piece.replace('Measurement\xa0- ', '').strip()
713
+ if 'Sq Mtr' in piece:
714
+ piece = piece.split('[')[0].strip()
715
+ if piece: Pieces.append(piece)
716
+ if Pieces: QuoteLineData.append(['Pieces', Pieces])
717
+ elif 'Benchtop' in line[0].title():
718
+ #Get the squaremetre from the next line, eg: '[Qty: 1.0000] [3.5153 Sq Mtr] [$0/Sq Mtr]'
719
+ sqm = QuoteLines[i+1][0].split('[')[2].split(']')[0].split(' ')[0]
720
+ QuoteLineData.append(['Material', line[0], sqm])
721
+ elif 'Includes' in line[0].title():
722
+ #Get the quantity of the item to incliude from next linme, eg: ['[Qty: 1.0000] [$870/Each]']
723
+ qty = QuoteLines[i+1][0].split('[')[1].split(']')[0].split(' ')[1]
724
+ QuoteLineData.append(['Includes', line[0], qty])
725
+ elif 'Edge Finish' in line[0].title():
726
+ #Get the lineal metreage from the following line, eg: ['[Qty: 1.0000] [1500m...5/Lin Mtr]']
727
+ linm = QuoteLines[i+1][0].split('[')[2].split(']')[0].split(' ')[0]
728
+ QuoteLineData.append(['Edge Finish', line[0], linm])
729
+ elif 'Waterjet' in line[0].title():
730
+ QuoteLineData.append(['Waterjet', line[0]])
731
+ QuoteMainData['QuoteLines'] = QuoteLineData
732
+ return QuoteMainData
733
+
734
+ def Give_NewJob_JobCard_Fields(QuoteData, fieldmapping=JOBCARD_FIELDMAPPING):
735
+ '''
736
+ Give the fields for updating a new job jobcard with initial quote data.
737
+ Defaults to field IDs for environment Job Card.
738
+ '''
739
+ fields = []
740
+ edge = ''
741
+ NoteField = ''
742
+ if QuoteData.get('Built Edge'): fields.append( ( fieldmapping['GlueUp'], '1' ) )
743
+ for line in QuoteData['QuoteLines']:
744
+ if line[0] == 'Pieces': NoteField += f'> Pieces: {len(line[1])}\n'
745
+ elif line[0] == 'Quoted': NoteField += ' '.join(line[1:]) + '\n'
746
+ else: NoteField += '> ' + ' '.join(line[1:]) + '\n'
747
+
748
+ if line [0] == 'Includes':
749
+ if 'Tap' in line[1]: fields.append( ( fieldmapping['Tap'], '1' ) )
750
+ if 'Hob' in line[1]: fields.append( ( fieldmapping['Hob'], '1' ) )
751
+ elif line[0] == 'Edge Finish':
752
+ if edge: edge += '\n'
753
+ edge += ' '.join(line[1:])
754
+ if 'Mitre' in line[1]: fields.append( ( fieldmapping['Mitre'], '1' ) )
755
+ elif 'Waterjet' in line[0]: fields.append( ( fieldmapping['Waterjet'], '1' ) )
756
+ elif line[0] == 'Quoted': fields.append( ( fieldmapping['Qtd_TI'], ' '.join(line[1:]) ) )
757
+ fields.append( ( fieldmapping['Notes'], NoteField ) )
758
+ fields.append( ( fieldmapping['Edge'], edge ) )
759
+
760
+ P_fields = list(zip(*fields))
761
+ P1 = '?'.join(P_fields[0])
762
+ P2 = '?'.join(P_fields[1])
763
+ UpdateDict = {}
764
+ for i in range(0, 6): UpdateDict[f'P{i}'] = ''
765
+ UpdateDict.update( {'P1': P1,
766
+ 'P2': P2,
767
+ 'P4': -1 })
768
+ return UpdateDict
769
+
770
+
771
+
772
+ ############################################################################### Job Fetch Functions ###############################################################################
773
+
774
+ def GetSomeSoup(jid, mw_cookie, client=CLIENT):
775
+ mw_cookie = mw_login_cookie(mw_cookie)
776
+ joburl = f'https://{client}.moraware.net/sys/job/{jid}'
777
+ job = requests.get(joburl, cookies=mw_cookie)
778
+ soup = BeautifulSoup(job.content, 'html.parser')
779
+ return soup
780
+
781
+ def GetJobActivities(JobSoup=None, **args):
782
+ if not JobSoup: JobSoup = GetSomeSoup(args.get('jid'), args.get('mw_cookie'))
783
+ table = JobSoup.find('table', {'id': 'ActivitiesBody'})
784
+ #Give the table headers
785
+ ActivityTableHead = JobSoup.find('table', {'id': 'ActivitiesHead'})
786
+ ActivityTableHeaders = []
787
+ for ath in ActivityTableHead.text.split('\n'):
788
+ if ath:
789
+ cleaned_cell = re.sub(r'[^\x00-\x7F]+', '', ath)
790
+ ActivityTableHeaders.append(cleaned_cell)
791
+
792
+ rows = table.find_all('tr')
793
+ Activities = []
794
+ for i,row in enumerate(rows): #Getting Activity ID and Materials
795
+ if i == 0: continue
796
+ ActID = row['mjtdrop'].split('\'')[1]
797
+ rowdetails = row.find_all('td')
798
+ Actname = rowdetails[0].text
799
+ mci = ActivityTableHeaders.index('Material')
800
+ ActivityMaterials = extract_slab_details( rowdetails[mci] )
801
+ Activities.append({ 'Activity Type': Actname, 'Materials': ActivityMaterials, 'ActivityID': ActID })
802
+
803
+ df = pd.read_html(str(table))[0]
804
+ adata = pd.Series.tolist(df)
805
+ MWJobDat = []
806
+ for i,row in enumerate(adata):
807
+ newrow = []
808
+ for j,cell in enumerate(row):
809
+ cell = convert_float(cell)
810
+ cleaned_cell = re.sub(r'[^\x00-\x7F]+', '', cell)
811
+ if cleaned_cell == 'Add Material': cleaned_cell = None
812
+ if 'date' in ActivityTableHeaders[j].lower() and cleaned_cell:
813
+ try: int(cleaned_cell[0])
814
+ except: cleaned_cell = cleaned_cell.split(' ')[1].strip()
815
+ newrow.append(cleaned_cell)
816
+ MWJobDat.append(newrow)
817
+
818
+ JobData = []
819
+ headers = ActivityTableHeaders
820
+ for i,row in enumerate(MWJobDat):
821
+ Activity = {}
822
+ for j,cell in enumerate(row): Activity[headers[j]] = cell
823
+ Activity.update(Activities[i])
824
+ JobData.append(Activity)
825
+ return JobData
826
+
827
+ def GetJobCard(JobSoup=None, fieldmapping=JOBCARD_FIELDMAPPING, **args):
828
+ if not JobSoup: JobSoup = GetSomeSoup(args.get('jid'), args.get('mw_cookie'))
829
+ JobForms = JobSoup.find_all('table', {'class': 'detail-form'})
830
+ JCID = fieldmapping['FORM_IDENTIFIER']
831
+ JobCard = [form for form in JobForms if JCID in form.text]
832
+ if not JobCard: return None
833
+ JobCard = JobCard[0]
834
+ formid = JobCard['id'].split('tblJobForm')[-1]
835
+ JobData = {}
836
+ header_divs = JobCard.find_all('div', style=re.compile(r';font-size:10pt;font-weight:bold;'))
837
+ for header in header_divs:
838
+ header_text = header.text.strip().strip(':').strip()
839
+ JobData[header_text] = '\n'.join( header.find_next('div').stripped_strings )
840
+ JobData.update({'JobCardID': formid})
841
+ return JobData
842
+
843
+ def extract_slab_details(cell):
844
+ if cell.text.strip('\uf055') == 'Add Material': return []
845
+ Materials = cell.find_all('a')
846
+ slab_details = []
847
+ for material in Materials:
848
+ mt = material.find('div', class_='jobActMaterialCellContent')
849
+ outername = mt.find('span')
850
+ material_type = ''.join([str(content) for content in outername.contents if not content.name])
851
+ serial_number_specs = material.find_all('div', class_='serialNumberSpec')
852
+ for spec in serial_number_specs:
853
+ # Slab Quantity SQM
854
+ square_metre_info = spec.find('span', {'class': 'quantitySpec'}).text
855
+ square_metre_match = re.search(r"(\d+\.\d+)\s*Sq Mtr", square_metre_info)
856
+ quantity = square_metre_match.group(1) if square_metre_match else 0
857
+ # Slab Serial Number
858
+ serial_number = spec.find('span', class_='jobSerialNumber').text.strip()
859
+ # Slab System ID
860
+ if serial_number == '(Unserialized)': system_id = None
861
+ else:
862
+ span_with_onmousedown = spec.find('span', {'onmousedown': True})
863
+ onmousedown_attr = span_with_onmousedown['onmousedown']
864
+ match = re.search(r"JobActivitySerialNumberMouseDown\(event, \d+, \d+, \"(\d+)\"", onmousedown_attr)
865
+ system_id = match.group(1) if match else ''
866
+
867
+ slab_details.append({
868
+ 'SQM': quantity,
869
+ 'ExtSN': serial_number,
870
+ 'MorawareInternalID': system_id,
871
+ 'material_type': material_type
872
+ })
873
+ return slab_details
874
+
875
+ def GetJobDetails(mw_Connect, SN):
876
+ jcfm = mw_Connect.Give('JobCardFieldMapping')
877
+ client = mw_Connect.client
878
+ mw_cookie = mw_Connect.mw_cookie
879
+ url = f'https://{client}.moraware.net/sys/job/{SN}'
880
+ JobPage = requests.get(url, cookies=mw_cookie)
881
+ soup = BeautifulSoup(JobPage.content, 'html.parser')
882
+
883
+ JobName = ' - '.join( soup.find('title').text.split(' - ')[:-2] ).strip()
884
+ Job_Card = GetJobCard(soup, fieldmapping=jcfm)
885
+ Activities = GetJobActivities(soup)
886
+
887
+ return {'url' : url,
888
+ 'jid': SN,
889
+ 'JobPage' : JobPage,
890
+ 'soup' : soup,
891
+ 'JobName' : JobName,
892
+ 'JobCard' : Job_Card,
893
+ 'Activities' : Activities}
894
+
895
+
896
+
897
+ ############################################################################### Job Update Functions ###############################################################################
898
+
899
+ def UpdateJobActivity(jid, Activity, Updates, MWJob):#, mw_Connect):
900
+ '''
901
+ Initiate a job update in Moraware.
902
+ Uses a MorawareJob object containing all job data and mw_Connect object containing the Moraware connection objects.
903
+ Activity argument should be the activity to be updated.
904
+ Updates argument should be a dictionary containing the updates to be made.
905
+ ie: 'Install', {'Status': 'Completed', 'Start Date': '2021-06-01', 'Assigned To': 'John Doe', 'Notes': 'Completed the install.'}
906
+ Use the MWSession class to get the StatusIDs and Inventory Locations as class variables for every session.
907
+ Start Date should be in the format 'YYYY-MM-DD' (otherwise will assume US format).
908
+ Names of activities and statuses should be exact and match shown in Moraware.
909
+
910
+ Args:
911
+ jid: Job ID
912
+ Activity: Activity to be updated.
913
+ Updates: Dictionary containing the updates to be made.
914
+ MWJob: MorawareJob object containing all job data.
915
+ mw_Connect: Moraware connection object.
916
+
917
+ Returns:
918
+ rsp: Response from the Moraware server.
919
+ '''
920
+ mw_Connect = MWJob.mw_Connect
921
+ StatusIDs = mw_Connect.Give('StatusIDs')
922
+ mw_cookie = mw_Connect.mw_cookie
923
+ client = mw_Connect.client
924
+ JobData = MWJob.JobData['Activities']
925
+ p_mapping = {
926
+ 'ActivityID': 'P0',
927
+ 'Status': 'P1',
928
+ 'Start Date': 'P2',
929
+ 'Sched Time': 'P3',
930
+ 'Duration': 'P4',
931
+ 'Assigned To': 'P5',
932
+ 'Notes': 'P6'}
933
+ ActionedActivity = {act['Activity Type']: act for act in JobData}[Activity]
934
+ ActionedActivity.update(Updates)
935
+ MW_Act_Dat = ActionedActivity.copy()
936
+ if MW_Act_Dat['Status'] == 'Auto-Schedule' and 'Start Date' in Updates and 'Status' not in Updates:
937
+ Updates['Status'] = 'Estimate'
938
+ MW_Act_Dat['Status'] = StatusIDs[MW_Act_Dat['Status']]
939
+ MW_Act_Dat['Start Date'] = convert_date(MW_Act_Dat['Start Date'], '%Y-%m-%d')
940
+ MW_Act_Dat['Sched Time'] = Give_24hrTime(MW_Act_Dat['Sched Time'])
941
+
942
+ #Creating the update dictionary, updating with existing data.
943
+ UpdateDict = {}
944
+ for i in range(0, 11): UpdateDict[f'P{i}'] = ''
945
+ UpdateDict.update( { p_mapping[k]:v for k,v in MW_Act_Dat.items() if k in p_mapping } )
946
+
947
+ #Setting Status update to StatusID, adding the updates to the update dict.
948
+ if 'Status' in Updates: Updates['Status'] = StatusIDs[Updates['Status']]
949
+ UpdateDict.update( { p_mapping[k]:v for k,v in Updates.items() if k in p_mapping } )
950
+
951
+ #This tells Moraware which fields are being modified, even if false information is in other fields, it will only update the fields that are flagged as modified.
952
+ UpdateDict['P9'] = '%2C'.join( [ '1' if key in Updates else '0' for key in p_mapping.keys() ] + ['0'] )
953
+ return UpdateMoraware(Serial=jid,
954
+ FormType='Job_UpdateJobActivities',
955
+ endpoint='job',
956
+ fields=UpdateDict,
957
+ mw_cookie=mw_cookie,
958
+ client=client
959
+ )
960
+
961
+ def CreateJobFromQuote(JobName, QuoteID, mw_cookie, QuoteData, client=CLIENT):
962
+ '''
963
+ Create a job from a quote in Moraware.
964
+ Only creates the job, does not update any fields.
965
+ '''
966
+ mw_cookie = mw_login_cookie(mw_cookie)
967
+ DecodedString = {'quoteId': QuoteID,
968
+ 'jobName': JobName,
969
+ 'jobTemplateId': '1', #Change this after creating new jobs card template.
970
+ 'jobProcessId': 1, #Need a flag in the quote for Commercial, Residential, Remakes etc quotes.
971
+ 'salespersonId': '',
972
+ 'useDefaultSalespersonId': 1}
973
+ EncodedString = Encode_Dict_to_String(DecodedString)
974
+ rsp = UpdateMoraware(Serial=QuoteID,
975
+ FormType='Quote2_CreateJobFromQuote2',
976
+ endpoint='quote',
977
+ fields={'P0': EncodedString},
978
+ mw_cookie=mw_cookie,
979
+ client=client
980
+ )
981
+ if 'MW_RS_ErrorResponse' in rsp.text: raise Exception(f'Error creating job from quote: {rsp.text}')
982
+ jid = eval(rsp.text)['jobId']
983
+ QuoteData.update({'Job ID': jid})
984
+ return QuoteData
985
+
986
+ def NewJob_Updates(MWJob, MWQuote, FormFields):
987
+ '''
988
+ New version of the JobCreate_update function.
989
+ Utilises the MorawareJob, MorawareQuote and MorawareConnection classes.
990
+ Performs initial job updates for setting up a new job from a quote.
991
+ '''
992
+ QuoteData = MWQuote.QuoteData
993
+ jid = MWJob.JobID
994
+ template = datetime.strptime(QuoteData['Template Date'], '%d/%m/%Y')
995
+ rsp = MWJob.UpdateActivity('Template', {'Start Date': template.strftime('%Y-%m-%d'), 'Status': 'Estimate'})
996
+ rsp = MWJob.UpdateActivity('Deposit Inv Sent', {'Status': 'Estimate'})
997
+ #raise Exception('Test End')
998
+ JobCardFields = Give_NewJob_JobCard_Fields(QuoteData, fieldmapping=FormFields)
999
+ jcid = MWJob.JobData['JobCard']['JobCardID']
1000
+ rsp = MWJob.UpdateJobForm(jcid, JobCardFields)
1001
+
1002
+ def AllocateMaterial(JobID, ActivityID, MaterialData, mw_cookie, client=CLIENT):
1003
+ '''
1004
+ Allocate material to a job.
1005
+
1006
+ Args:
1007
+ JobID: The Job ID to allocate the material to.
1008
+ ActivityID: The Activity ID to allocate the material to.
1009
+ MaterialData: Dictionary containing the material data to allocate.
1010
+ mw_cookie: Login cookie for Moraware.
1011
+ client: Moraware client name.
1012
+
1013
+ Returns:
1014
+ rsp: Response from the Moraware server.
1015
+ '''
1016
+ MaterialData['ActivityID'] = ActivityID
1017
+ if not 'UnserQtyAlloc' in MaterialData: MaterialData['UnserQtyAlloc'] = '0'
1018
+ material_alloc_mapping = {
1019
+ 'ActivityID': 'P0',
1020
+ 'ProductID': 'P1',
1021
+ 'AttrID1': 'P2',
1022
+ 'AttrID2': 'P3',
1023
+ 'AttrID3': 'P4',
1024
+ 'AttrID4': 'P5',
1025
+ 'AttrID5': 'P6',
1026
+ 'UnserQtyAlloc': 'P7',
1027
+ }
1028
+ form = 'Job_CreateAndAllocateJobActivityProduct'
1029
+ fields = {v:MaterialData.get(k, '') for k,v in material_alloc_mapping.items()}
1030
+ fields['P8'] = f'?|{MaterialData["SN_ID"]}|{MaterialData["SN_Display"]}|{MaterialData["SQM_Allocating"]}|Sq%20Mtr|{MaterialData["SQM_OG"]}'
1031
+ rsp = UpdateMoraware(JobID, form, fields=fields, mw_cookie=mw_cookie, client=client)
1032
+ if 'MW_RS_ErrorResponse' in rsp.text:
1033
+ if 'variant id=' in rsp.text:
1034
+ pass
1035
+ return rsp
1036
+
1037
+ def CreateJob(JobName, mw_cookie, AccountID='', TemplateID='1', ProcessID='1', client=CLIENT):
1038
+ '''
1039
+ Create a job in Moraware.
1040
+ '''
1041
+ mw_cookie = mw_login_cookie(mw_cookie)
1042
+ fields = {
1043
+ 'P0': JobName,
1044
+ 'P1': AccountID,
1045
+ 'P2': TemplateID,
1046
+ 'P3': ProcessID,
1047
+ 'P4': '',
1048
+ 'P5': '1'
1049
+ }
1050
+ rsp = UpdateMoraware(Serial=ProcessID,
1051
+ FormType='Job_CreateJob',
1052
+ endpoint='jobs',
1053
+ fields=fields,
1054
+ mw_cookie=mw_cookie,
1055
+ client=client)
1056
+ return rsp
1057
+
1058
+ def CreateJobActivity(mw_Connect, JobID, Status='Estimate', **Updates):
1059
+ '''
1060
+ Create a job activity in Moraware.
1061
+
1062
+ Args:
1063
+ JobID: The Job ID to create the activity for.
1064
+ mw_Connect: Moraware connection object.
1065
+ ActivityType: The Activity Type by Display Name.
1066
+ ActivityID: The Activity Type by ID.
1067
+ Status: Status of the activity by name (default: Estimate).
1068
+ StatusID: Status of the activity by ID (default: Estimate).
1069
+ StartDate: Start date of the activity (default: unscheduled).
1070
+ ScheduledTime: Scheduled time of the activity (default: unscheduled).
1071
+ Duration: Duration of the activity (default: empty).
1072
+ Assignee: Activity assigned to by name (default: unassigned).
1073
+ Notes: Notes for the activity.
1074
+
1075
+ Returns:
1076
+ rsp: Response from the Moraware server.
1077
+ '''
1078
+ mw_cookie = mw_Connect.mw_cookie
1079
+ client = mw_Connect.client
1080
+ StatusIDs = mw_Connect.Give('StatusIDs')
1081
+ ActivityIDs = mw_Connect.Give('ActivityIDs')
1082
+ Updates['JobID'] = JobID
1083
+ if not 'StatusID' in Updates: Updates['StatusID'] = StatusIDs[Status]
1084
+ if not 'ActivityID' in Updates: Updates['ActivityID'] = ActivityIDs[Updates['ActivityType']]
1085
+ if Updates.get('StartDate'): Updates['StartDate'] = convert_date(Updates['StartDate'], '%Y-%m-%d')
1086
+
1087
+ field_mapping = {
1088
+ 'JobID': 'P0',
1089
+ 'ActivityID': 'P1',
1090
+ 'StatusID': 'P2',
1091
+ 'StartDate': 'P3',
1092
+ 'ScheduledTime': 'P4',
1093
+ 'Duration': 'P5',
1094
+ 'Assignee': 'P6',
1095
+ 'Notes': 'P7'
1096
+ }
1097
+ fields = {f'P{i}' : '' for i in range(11)}
1098
+ fields.update({ field_mapping[k]:v for k,v in Updates.items() if k in field_mapping })
1099
+ rsp = UpdateMoraware(Serial=JobID,
1100
+ FormType='Job_CreateJobActivity',
1101
+ endpoint='job',
1102
+ fields=fields,
1103
+ mw_cookie=mw_cookie,
1104
+ client=client)
1105
+ return rsp
1106
+
1107
+
1108
+
1109
+ ############################################################################### Inventory Materials ###############################################################################
1110
+
1111
+ def Give_BIN(mw_Connect, binconfig, client=CLIENT):
1112
+ mw_cookie = mw_Connect.mw_cookie
1113
+ binsheets = Fetch_View( url=binconfig['binsList'], mw_cookie=mw_cookie, headers=['name', 'account'] )
1114
+ bins = {b['name']:b for b in binsheets}
1115
+
1116
+ binName = binconfig.get('binName', f"{datetime.now().strftime('%B %Y')} BIN Movements")
1117
+ if binName in bins:
1118
+ current_bin = bins[binName]
1119
+ bin_id = current_bin.get('name ID')
1120
+ else:
1121
+ print(f'Creating BIN Movement Job: {binName}')
1122
+ rsp = CreateJob(JobName=binName,
1123
+ mw_cookie=mw_cookie,
1124
+ AccountID=binconfig['accountID'],
1125
+ TemplateID=binconfig['templateID'],
1126
+ ProcessID=binconfig['processID'],
1127
+ client=client)
1128
+ bin_id = rsp.text
1129
+ print(f'Created BIN Movement Job: {binName} with ID {bin_id}')
1130
+ bin_job = MorawareJob(mw_Connect, bin_id)
1131
+ bin_job.total_slabs_allocated = 0
1132
+ for activity in bin_job.JobData['Activities']:
1133
+ bin_job.total_slabs_allocated += len(activity['Materials'])
1134
+ return bin_job
1135
+
1136
+ def slabdata_from_entry(entry, ProductIDs):
1137
+ '''
1138
+ Get the slab data from an inventory list - for example the bin list.
1139
+
1140
+ Args:
1141
+ entry: The entry from the inventory list.
1142
+ ProductIDs: The product IDs from the inventory.
1143
+
1144
+ Returns:
1145
+ SlabData: The slab data for the entry.
1146
+ '''
1147
+ namekey = f"{entry['Product Family']}{entry['Product Line']}{entry['Product']}"
1148
+ if namekey not in ProductIDs:
1149
+ print(f"Product not found in Inventory Data: {namekey}")
1150
+ return None
1151
+ FullStoneName = entry['Product Variant'] # Full name of the stone variant, including all attributes.
1152
+ FullStoneName.replace(entry['Product'], '').strip() # Remove the product from the variant name.
1153
+ VariantData = ProductIDs[namekey] # Attribute lists for this product.
1154
+ AttrIDs = []
1155
+ for attrCat, attrList in VariantData['_ATTR'].items(): # Dict of { attrCategory(colour, thickness, etc): [attr1, attr2, ...] }
1156
+ attrmatchlen = 0 #Give the best match for the attribute.
1157
+ match_attr = None
1158
+ for val in attrList:
1159
+ if val['Name'] in FullStoneName: #Checking if the attribute name is in the variant name.
1160
+ if len(val['Name']) > attrmatchlen: #Checking how much of the attribute name is in the variant name.
1161
+ match_attr = val
1162
+ attrmatchlen = len(val['Name']) #Updating the best match.
1163
+ if match_attr: #If a match is found, add the attribute ID to the list and remove the attribute name from the variant name.
1164
+ AttrIDs.append(match_attr['ID'])
1165
+ FullStoneName = FullStoneName.replace(match_attr['Name'], '').strip()
1166
+ else:
1167
+ print(f'Attribute not found for {namekey}: {attrCat} - {FullStoneName}')
1168
+
1169
+ SlabData = {
1170
+ 'ProductID': VariantData['Product ID'],
1171
+ 'SN_ID': entry['Serial Number ID'],
1172
+ 'SN_Display': entry['Serial Number'],
1173
+ 'SQM_Allocating': entry['Available Qty'],
1174
+ 'SQM_OG': entry['Available Qty']
1175
+ }
1176
+ for i,attr in enumerate(AttrIDs): SlabData[f'AttrID{i+1}'] = attr
1177
+ return SlabData
1178
+
1179
+ def doBin(mw_Connect, binconfig):
1180
+ client = mw_Connect.client
1181
+ ind = 1
1182
+ binName = binconfig.get('binName', f"{datetime.now().strftime('%B %Y')} BIN Movements")
1183
+
1184
+ def bin_full_check(bin_job): return bin_job.total_slabs_allocated >= 100
1185
+ def next_bin():
1186
+ nonlocal ind
1187
+ ind += 1
1188
+ binconfig['binName'] = f"{binName} - {ind}"
1189
+ return Give_BIN(binconfig=binconfig, mw_Connect=mw_Connect, client=client)
1190
+ def Give_binActivity(bin_job):
1191
+ for activity in bin_job.JobData['Activities']:
1192
+ if activity['Activity Type'] == 'Stone Reservations & Adjustments' and activity['Start Date'] == datetime.now().strftime('%d/%m/%Y'):
1193
+ return activity['ActivityID']
1194
+ datestring = datetime.now().strftime('%Y-%m-%d')
1195
+ print(f'Creating BIN Movement Activity: {datestring}')
1196
+ rsp = bin_job.CreateActivity(ActivityType='Stone Reservations & Adjustments', StartDate=datestring)
1197
+ return rsp.text
1198
+
1199
+ binnableList = binconfig['binnable']
1200
+ InvV = mw_Connect.Give('ProductIDs')
1201
+ binslabs = mw_Connect.GetView(binnableList)
1202
+ binnable = []
1203
+ for binned in binslabs:
1204
+ SlabData = slabdata_from_entry(binned, InvV)
1205
+ if SlabData: binnable.append(SlabData)
1206
+ if not binnable: return None
1207
+
1208
+ bin_job = Give_BIN(binconfig=binconfig, mw_Connect=mw_Connect, client=client)
1209
+ while bin_full_check(bin_job):
1210
+ bin_job = next_bin()
1211
+ actid = Give_binActivity(bin_job)
1212
+
1213
+ print(f'Allocating {len(binnable)} slabs to {bin_job.JobData["JobName"]}')
1214
+ while len(binnable) > 0:
1215
+ print(f' Remaining: {len(binnable)} - Current Bin: {bin_job.JobData["JobName"]}')
1216
+ if bin_full_check(bin_job):
1217
+ while bin_full_check(bin_job):
1218
+ bin_job = next_bin()
1219
+ actid = Give_binActivity(bin_job)
1220
+ else:
1221
+ slab = binnable.pop()
1222
+ bin_job.AllocateMaterial(actid, slab)
1223
+ bin_job.total_slabs_allocated += 1
1224
+ return bin_job
1225
+
1226
+
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: js_mw_api
3
+ Version: 2.5.0.3
4
+ Summary: Moraware API Wrapper for Python
5
+ Project-URL: Homepage, https://github.com/JensenSystems/JS_Moraware
6
+ Project-URL: Issues, https://github.com/JensenSystems/JS_Moraware/issues
7
+ Author-email: Tyler Jensen <Tyler@JensenSystems.co.nz>
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # JS_Moraware
14
+ Interaction with Moraware using Python
15
+
16
+ Intended to be the FINAL PUBLIC published repo for JS-related libraries.
17
+ Will include JS_Moraware and optionally other JS code (unless they're published separately).
18
+
19
+ Version no# matches my private library and is an exact clone (copy-paste) besides anonymised.
@@ -0,0 +1,5 @@
1
+ js_mw_api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ js_mw_api/mwapi.py,sha256=NlUVVYjq1Qn-U8JDjQ2IZyPwywI_Xkoe_-cH2T6nmRE,54205
3
+ js_mw_api-2.5.0.3.dist-info/METADATA,sha256=rvvBR-vw6SKpijNc3K_FPQzyTO9FMSDwrctjCi5VVx4,768
4
+ js_mw_api-2.5.0.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
+ js_mw_api-2.5.0.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any