-
Notifications
You must be signed in to change notification settings - Fork 1
/
plans_console.py
1863 lines (1739 loc) · 91.7 KB
/
plans_console.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# #############################################################################
#
# Plans_console.py - watch a radiolog.csv file that is being written by
# the full radiolog program, presumably running on a different computer
# writing to a shared drive that this program can see. Also, enable the
# placement of Markers for Teams when at an assignment, edit shapes and
# assignments and create debrief maps for faster access.
#
# developed for Nevada County Sheriff's Search and Rescue
# requires sartopo_python and several other python libs
#
# Attribution, feedback, bug reports and feature requests are appreciated
#
# REVISION HISTORY
#-----------------------------------------------------------------------------
# DATE | AUTHOR | NOTES
#-----------------------------------------------------------------------------
# 8/7/2020 SDL Initial released
# 6/16/2021 SDL added cut/expand/crop interface
# 2022 TMG upgraded the UI and added the debrief map
# 7/8/2023 SDL changed Med icon to not display team# on the map
# 8/27/2023 SDL fixed issue with case and not finding an edited object (part of fix is in sartopo_python)
# 10/6/2023 SDL added clue log listing and print button for clue log and assignments
# 12/17/2023 SDL added try block around getFeatures for med/assignment getObjects
# 3/10/2024 SDL fixed reload of medical icon into TmAs table
# 3/17/2024 SDL redefined check for LE callsign & trying to remove duplicate radiolog entries
# 8/17/2024 SDL bug assignment number with embedded extra spaces
# 10/20/2024 SDL add check for empty assignment letter
#
# #############################################################################
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# See included file LICENSE.txt for full license terms, also
# available at http://opensource.org/licenses/gpl-3.0.html
#
# ############################################################################
#
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from pygtail import Pygtail
import math
import os
import shutil
import glob
import regex
import time
import io
import traceback
import json
import random
import configparser
import argparse
from shapely.geometry import Polygon
from datetime import datetime
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
import subprocess
VERSION = "1.24"
sartopo_python_min_version="1.1.2"
#import pkg_resources
#sartopo_python_installed_version=pkg_resources.get_distribution("sartopo-python").version
#print("sartopo_python version:"+str(sartopo_python_installed_version))
##if pkg_resources.parse_version(sartopo_python_installed_version)<pkg_resources.parse_version(sartopo_python_min_version):
# print("ABORTING: installed sartopo_python version "+str(sartopo_python_installed_version)+ \
# " is less than minimum required version "+sartopo_python_min_version)
# exit()
import sys
sartopo_python_dir='../sartopo_python/sartopo_python'
if os.path.isdir(sartopo_python_dir):
sys.path.insert(1,sartopo_python_dir)
from sartopo_python import SartopoSession # import before logging to avoid useless numpy-not-installed message
# start logging early, to catch any messages during import of modules
import logging
import common # variables shared across multiple modules
# log filename should be <top-level-module-name>.log
# logfile=os.path.splitext(os.path.basename(sys.path[0]))[0]+'.log'
common.pcDir='C:\\PlansConsole'
common.pcLogDir=os.path.join(common.pcDir,'Logs')
common.logfile=os.path.join(common.pcLogDir,'plans_console.log')
if not os.path.isdir(common.pcLogDir):
os.makedirs(common.pcLogDir)
cleanShutdownText='Plans Console shutdown requested'
# cleanShutdownText should appear in the last five lines of the previous
# log file if it was a clean shutdown; if not, copy the file
# with a unique filename before starting the new log; use seek instead
# of readlines to reduce time and memory consumption for large log files
if os.path.exists(common.logfile):
save=False
if os.path.getsize(common.logfile)>1024:
with open(common.logfile,'rb') as f:
f.seek(-1025,2) # 1kB before the file's end
tail=f.read(1024).decode()
if cleanShutdownText not in tail:
save=True
else: # tiny file; read the whole file to see if clean shutdown line exists
with open(common.logfile,'r') as f:
if cleanShutdownText not in f.read():
save=True
if save:
shutil.copyfile(common.logfile,os.path.splitext(common.logfile)[0]+'.aborted.'+datetime.fromtimestamp(os.path.getmtime(common.logfile)).strftime('%Y-%m-%d-%H-%M-%S')+'.log')
# print by default; let the caller change this if needed
# (note, caller would need to clear all handlers first,
# per stackoverflow.com/questions/12158048)
# To redefine basicConfig, per stackoverflow.com/questions/12158048
# Remove all handlers associated with the root logger object.
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
logging.basicConfig(
level=logging.DEBUG,
datefmt='%H:%M:%S',
format='%(asctime)s [%(module)s:%(lineno)d:%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(common.logfile,'w'),
logging.StreamHandler(sys.stdout)
]
)
from specifyMap import SpecifyMapDialog
BG_GREEN = "background-color:#00bb00;"
BG_RED = "background-color:#bb0000;"
BG_GRAY = "background-color:#aaaaaa;"
# rebuild all _ui.py files from .ui files in the same directory as this script as needed
# NOTE - this will overwrite any edits in _ui.py files
for ui in glob.glob(os.path.join(os.path.dirname(os.path.realpath(__file__)),'*.ui')):
uipy=ui.replace('.ui','_ui.py')
if not (os.path.isfile(uipy) and os.path.getmtime(uipy) > os.path.getmtime(ui)):
cmd='pyuic5 -o '+uipy+' '+ui
logging.info('Building GUI file from '+os.path.basename(ui)+':')
logging.info(' '+cmd)
os.system(cmd)
# rebuild all _rc.py files from .qrc files in the same directory as this script as needed
# NOTE - this will overwrite any edits in _rc.py files
for qrc in glob.glob(os.path.join(os.path.dirname(os.path.realpath(__file__)),'*.qrc')):
rcpy=qrc.replace('.qrc','_rc.py')
if not (os.path.isfile(rcpy) and os.path.getmtime(rcpy) > os.path.getmtime(qrc)):
cmd='pyrcc5 -o '+rcpy+' '+qrc
logging.info('Building Qt Resource file from '+os.path.basename(qrc)+':')
logging.info(' '+cmd)
os.system(cmd)
from plans_console_ui import Ui_PlansConsole
from sartopo_bg import *
logging.info('PID:'+str(os.getpid()))
def genLpix(ldpi):
if ldpi<10:
ldpi=96
lpix={}
for ptSize in [1,2,3,4,6,8,9,10,11,12,14,16,18,22,24,36,48]:
lpix[ptSize]=math.floor((ldpi*ptSize)/72)
return lpix
def ask_user_to_confirm(question: str, icon: QMessageBox.Icon = QMessageBox.Question, parent: QObject = None, title = "Please Confirm") -> bool:
# don't bother taking the steps to handle moving from one screen to another of different ldpi
opts = Qt.WindowTitleHint | Qt.WindowCloseButtonHint | Qt.Dialog | Qt.MSWindowsFixedSizeDialogHint | Qt.WindowStaysOnTopHint
buttons = QMessageBox.StandardButton(QMessageBox.Yes | QMessageBox.No)
# determine logical pixel equivalents: take from parent if possible, so that the messagebox uses the same DPI as the spawning window
if parent and hasattr(parent,'ldpi') and parent.ldpi>1:
ldpi=parent.ldpi
# logging.info('using parent ldpi = '+str(ldpi))
else:
try:
ldpi=parent.window().screen().logicalDotsPerInch()
# logging.info('using lpdi of parent\'s screen = '+str(ldpi))
except:
testDialog=QDialog()
testDialog.show()
ldpi=testDialog.window().screen().logicalDotsPerInch()
testDialog.close()
del testDialog
# logging.info('using lpdi of current screen = '+str(ldpi))
lpix=genLpix(ldpi)
box = QMessageBox(icon, title, question, buttons, parent, opts)
box.setDefaultButton(QMessageBox.No)
spacer=QSpacerItem(int(300*(ldpi/96)),0,QSizePolicy.Minimum,QSizePolicy.Expanding)
layout=box.layout()
layout.addItem(spacer,layout.rowCount(),0,1,layout.columnCount())
box.setStyleSheet('''
*{
font-size:'''+str(lpix[12])+'''px;
icon-size:'''+str(lpix[36])+'''px '''+str(lpix[36])+'''px;
}''')
QCoreApplication.processEvents()
box.show()
box.raise_()
return box.exec_() == QMessageBox.Yes
## set timeout to 4 sec
def inform_user_about_issue(message: str, icon: QMessageBox.Icon = QMessageBox.Critical, parent: QObject = None, title="", timeout=4000):
# don't bother taking the steps to handle moving from one screen to another of different ldpi
opts = Qt.WindowTitleHint | Qt.WindowCloseButtonHint | Qt.Dialog | Qt.MSWindowsFixedSizeDialogHint | Qt.WindowStaysOnTopHint
if title == "":
title = "Warning" if (icon == QMessageBox.Warning) else "Error"
buttons = QMessageBox.StandardButton(QMessageBox.Ok)
# determine logical pixel equivalents: take from parent if possible, so that the messagebox uses the same DPI as the spawning window
if parent and hasattr(parent,'ldpi') and parent.ldpi>1:
ldpi=parent.ldpi
# logging.info('using parent ldpi = '+str(ldpi))
else:
try:
ldpi=parent.window().screen().logicalDotsPerInch()
# logging.info('using lpdi of parent\'s screen = '+str(ldpi))
except:
testDialog=QDialog()
testDialog.show()
ldpi=testDialog.window().screen().logicalDotsPerInch()
testDialog.close()
del testDialog
# logging.info('using lpdi of current screen = '+str(ldpi))
lpix=genLpix(ldpi)
box = QMessageBox(icon, title, message, buttons, parent, opts)
spacer=QSpacerItem(int(800*(ldpi/96)),0,QSizePolicy.Minimum,QSizePolicy.Expanding)
layout=box.layout()
layout.addItem(spacer,layout.rowCount(),0,1,layout.columnCount())
box.setStyleSheet('''
*{
font-size:'''+str(lpix[12])+'''px;
icon-size:'''+str(lpix[36])+'''px '''+str(lpix[36])+'''px;
}''')
QCoreApplication.processEvents()
box.show()
box.raise_()
if timeout:
if timeout<100:
timeout=timeout*1000 # user probably specified integer seconds
QTimer.singleShot(timeout,box.close)
box.exec_()
statusColorDict={}
statusColorDict["At IC"]=["22ff22","000000"]
statusColorDict["Available"]=["00ffff","000000"]
statusColorDict["In Transit"]=["2222ff","eeeeee"]
statusColorDict["Waiting for Transport"]=["2222ff","eeeeee"]
stateColorDict={}
stateColorDict["#ff4444"]="#eeeeee"
stateColorDict["#eeeeee"]="#ff4444"
sys.tracebacklimit = 1000
# log uncaught exceptions - https://stackoverflow.com/a/16993115/3577105
# don't try to print from inside this function, since stdout is in binary mode
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.critical('Uncaught exception', exc_info=(exc_type, exc_value, exc_traceback))
inform_user_about_issue('Uncaught exception:\n\n'+str(exc_type.__name__)+': '+str(exc_value)+'\n\nCheck log file for details including traceback. The program will continue if possible when you close this message box.', timeout=4000)
sys.excepthook = handle_exception
def sortByTitle(item):
return item["properties"]["title"]
# Working directory structure 4-8-22:
# C:\PlansConsole
# |
# |-- Logs
# | |-- plans_console.log
# | |-- [plans_console.log.err[.1-5]]
# | |-- [plans_console_aborted.<date><time>.log]
# |
# |-- Config
# | |-- plans_console.cfg
# | |-- plans_console.rc
# | |-- sts.ini
# |
# |-- save_plans_console.txt
# |
# |-- Debrief
# |-- JSON
# | |-- <incidentMapID>_<debriefMapID>.json
# |
# |-- Maps
# |-- <outingName>_<time>_<pdfID>.pdf
# (files also written to optional second PDF directory
# such as Z:\DebriefMaps, specified in plans_console.cfg)
class PlansConsole(QDialog,Ui_PlansConsole):
def __init__(self,parent):
QDialog.__init__(self)
logging.info('Plans Console Vers '+str(VERSION)+' startup at '+datetime.now().strftime("%a %b %d %Y %H:%M:%S"))
self.ldpi=1 # font size calculations (see moveEvent)
self.parent=parent
self.pcConfigDir=os.path.join(common.pcDir,'Config')
self.stsconfigpath=os.path.join(self.pcConfigDir,'sts.ini')
self.rcFileName=os.path.join(self.pcConfigDir,'plans_console.rc')
self.configFileName=os.path.join(self.pcConfigDir,'plans_console.cfg')
self.pcDataFileName=os.path.join(common.pcDir,'save_plans_console.txt')
self.ui=Ui_PlansConsole()
self.ui.setupUi(self)
# default window geometry; overridden by previous rc file
self.xd=100
self.yd=100
self.wd=1600
self.hd=1000
# self.fontSize=12
self.grid=[[0]]
self.curTeam = ""
self.curAssign = ""
self.curType = ""
self.totalRows = 0
self.totalRows2 = 0
self.x = self.xd
self.y = self.yd
self.w = self.wd
self.h = self.hd
self.debriefX=None
self.debriefY=None
self.debriefW=None
self.debriefH=None
self.fidMed = None
self.fidLE = None
self.sentMsg = []
self.FIRST_PASS = True # unset after first time thru so that warning above is only given once
self.color = ["#ffff00", "#cccccc"] # yellow, gray80
self.loadRcFile()
# main window:
# make sure x/y/w/h from resource file will fit on the available display
d=QApplication.desktop()
if self.x+self.w > d.width():
self.x=50
if self.y+self.h > d.height():
self.y=50
# try to use specified w and h; shrink if needed
if self.x+self.w > d.width():
self.w=d.availableGeometry(self).width()-100
if self.y+self.h > d.height():
self.h=d.availableGeometry(self).height()-100
# debrief dialog (in case it is opened):
# make sure x/y/w/h from resource file will fit on the available display
if self.debriefX and self.debriefY and self.debriefW and self.debriefH:
if self.debriefX+self.debriefW > d.width():
self.debriefX=50
if self.debriefY+self.debriefH > d.height():
self.debriefY=50
# try to use specified w and h; shrink if needed
if self.debriefX+self.debriefW > d.width():
self.debriefW=d.availableGeometry(self).width()-100
if self.debriefY+self.debriefH > d.height():
self.debriefH=d.availableGeometry(self).height()-100
self.setGeometry(int(self.x),int(self.y),int(self.w),int(self.h))
parser=argparse.ArgumentParser()
parser.add_argument('mapID',nargs='?',default=None) # optional incident map ID (#abcd or $abcd for now)
parser.add_argument('debriefMapID',nargs='?',default=None) # optional debrief map ID (#abcd or $abcd for now)
parser.add_argument('-sd','--syncdump',action='store_true',
help='write a sync dump file containing every "since" response; for debug only; results in a LOT of files'),
parser.add_argument('-cd','--cachedump',action='store_true',
help='write a cache dump file after every "since" response; for debug only; results in a LOT of potentially HUGE files'),
parser.add_argument('-nr','--norestore',action='store_true',
help='do not try to restore the previous session, and do not ask the user')
parser.add_argument('-nu','--nourl',action='store_true',
help='disable all interactions with SARTopo/Caltopo')
self.args=parser.parse_args()
logging.info('args:'+str(self.args))
self.readConfigFile()
self.ui.incidentLinkLight.setStyleSheet(BG_GRAY)
self.ui.debriefLinkLight.setStyleSheet(BG_GRAY)
self.setAttribute(Qt.WA_DeleteOnClose)
self.medval = ""
self.save_mod_date = 0
self.assignments = []
self.forceRescan = 0
self.feature = {}
self.feature2 = {}
# self.setStyleSheet("background-color:#d6d6d6")
self.ui.tableWidget.cellClicked.connect(self.tableCellClicked)
self.ui.OKbut.clicked.connect(self.assignTab_OK_clicked)
self.ui.doOper.clicked.connect(self.doOperClicked)
self.ui.incidentButton.clicked.connect(self.incidentButtonClicked)
self.ui.debriefButton.clicked.connect(self.debriefButtonClicked)
# self.screen().logicalDotsPerInchChanged.connect(self.lldpiChanged)
self.reloaded = False
self.incidentURL=None
self.debriefURL=None
# hardcode workarounds to avoid uncaught exceptions during save_data TMG 1-18-21
self.watchedFile='watched.csv'
self.watchedFile2='watched2.csv'
self.offsetFileName='offset.csv'
self.offsetFileName2='offset2.csv'
self.csvFiles=[]
self.csvFiles2=[]
if os.path.exists(self.pcDataFileName):
[i,d,n]=self.preview_saved_data()
if not self.args.norestore:
if not (i and n): # i and n are necessary; d is not
logging.info('Saved session file contained no useful data; not offering to restore')
else:
iTxt='Incident map = '+str(i)
dTxt='No debrief map specified\n (DMG failed or was not used)'
if d:
dTxt='Debrief map = '+str(d)+'\n (DMG sync will resume if restored)'
nSuffix=''
if n!=1:
nSuffix='s'
nTxt=str(n)+' radiolog record'+nSuffix
if ask_user_to_confirm('Should the session be restored?\n\n'+iTxt+'\n'+dTxt+'\n'+nTxt,parent=self):
# name1, done1 = QtWidgets.QInputDialog.getText(self, 'Input Dialog','Should the session be restored?')
# if "y" in name1.lower():
self.load_data()
self.reloaded = True
if not self.args.nourl and not self.reloaded:
name1=self.args.mapID
if name1:
if "#" in name1:
self.incidentURL="https://sartopo.com/m/"+name1[1:] # remove the #
elif "$" in name1:
self.incidentURL="http://localhost:8080/m/"+name1[1:] # remove the $
else:
self.incidentURL="http://192.168.1.20:8080/m/"+name1
else:
if not ask_user_to_confirm('If the map is at sartopo.com it must be in the NCSSAR account.\n Continue?',parent=self):
pass # abort
print("Exiting")
exit()
self.incidentMapDialog=SpecifyMapDialog(self,'Incident',None,self.defaultDomainAndPort)
self.incidentMapDialog.exec() # force modal
self.incidentURL=self.incidentMapDialog.url
self.incidentDomainAndPort=self.incidentMapDialog.domainAndPort
self.sts=None
self.dmg=None # DebriefMapGenerator instance
self.link=-1
self.latField = "0.0"
self.lonField = "0.0"
self.NCSO = [39.27, -121.026]
self.sinceFolder=0 # sartopo wants integer milliseconds
self.sinceMarker=0 # sartopo wants integer milliseconds
self.markerList=[] # list of all sartopo markers and their ids
# self.scl = min(self.w/self.wd, self.h/self.hd)
# self.fontSize = int(self.fontSize*self.scl)
# logging.info("Scale:"+str(self.scl))
self.updateClock()
if self.watchedDir:
logging.info('watched dir:'+str(self.watchedDir))
self.ui.notYet=QMessageBox(QMessageBox.Information,"Waiting...","No valid radiolog file was found.\nRe-scanning every few seconds...",
QMessageBox.Abort,self,Qt.WindowTitleHint|Qt.WindowCloseButtonHint|Qt.Dialog|Qt.MSWindowsFixedSizeDialogHint|Qt.WindowStaysOnTopHint)
self.ui.notYet.setStyleSheet("background-color: lightgray")
self.ui.notYet.setModal(False)
self.ui.notYet.show()
self.ui.notYet.buttonClicked.connect(self.notYetButtonClicked)
self.ui.rescanButton.clicked.connect(self.rescanButtonClicked)
self.ui.printButton.clicked.connect(self.printButtonClicked)
self.rescanTimer=QTimer(self)
self.rescanTimer.timeout.connect(self.rescan)
if not self.reloaded:
self.rescanTimer.start(2000) # do not start rescan timer if this is a reload
else:
self.ui.notYet.close() # we have csv file in reload
else:
logging.info('No watched dir specified.')
self.refreshTimer=QTimer(self)
self.refreshTimer.timeout.connect(self.refresh)
self.refreshTimer.timeout.connect(self.updateClock)
self.refreshTimer.start(3000)
self.print_refresh = 0
self.update_TmAs = 0
self.update_Tm = 0
self.flag_TmAs_getobj = False
self.flag_TmAs_Ok = False
self.since={}
self.since["Folder"]=0
self.since["Marker"]=0
self.featureListDict={}
self.featureListDict["Folder"]=[]
self.featureListDict["Marker"]=[]
self.ui.incidentMapField.setText('<None>')
self.ui.debriefMapField.setText('<None>')
if self.incidentURL:
self.ui.incidentMapField.setText(self.incidentURL)
self.tryAgain=True
while self.tryAgain:
self.createSTS()
self.save_data()
# if debrief map was specified both on command line and in restored file,
# then ignore so that the user will have to specify the debrief map in the GUI
if self.debriefURL and self.args.debriefMapID:
self.debriefMapID=None
self.debriefURL=None
if self.args.debriefMapID and self.link>-1:
name2=self.args.debriefMapID
if name2:
if "#" in name2:
self.debriefURL="https://sartopo.com/m/"+name2[1:] # remove the #
elif "$" in name2:
self.debriefURL="http://localhost:8080/m/"+name2[1:] # remove the $
else:
self.debriefURL="http://192.168.1.20:8080/m/"+name2
##### Temporarily removing debrief - using for printing
'''
if self.debriefURL:
self.debriefButtonClicked()
self.dmg.dd.ui.debriefMapField.setText(self.debriefURL)
self.ui.debriefMapField.setText(self.debriefURL)
QTimer.singleShot(1000,self.debriefButtonClicked) # raise again
else:
QTimer.singleShot(1000,self.debriefButtonClicked) # no reason not to start dmg anyway - TMG/SDL 4-9-22
'''
def printx(self): # printing clue table
### needs a rescan to be sure up to date
## presently rescan does not restore the radiolog and clue displays are correct
from reportlab.lib.units import inch
c = canvas.Canvas("report.pdf", pagesize=letter)
# move the origin up and to the left
c.translate(0.5*inch,inch)
# define a font
c.setFont("Helvetica", 8)
# choose some colors
c.setStrokeColorRGB(0,0,0) ## color for lines
c.setFillColorRGB(0.15,0.15,0.15) ## color for text
self.csvFiles2 = []
f2=glob.glob(self.watchedDir+"\\*.csv")
f2x=[x for x in f2 if regex.match('.*_clueLog.csv$',x)] # get cluelog files
f2s=sorted(f2x,key=os.path.getmtime,reverse=True)
for file in f2s:
l=[file,os.path.getsize(file),os.path.getmtime(file)]
self.csvFiles2.append(l)
self.watchedFile2=self.csvFiles2[0][0]
ioff = 0.1
Entries = []
if (self.watchedDir and self.csvFiles2!=[]):
with open(self.watchedFile2, 'r') as fid:
lines = fid.readlines()
newl = False # set to not working a newline within a string
for line in lines:
line, newl = self.fixStrg(line, newl) # fix ' and newline in string
if newl:
continue # in the middle of a multi-line description
Entries.append(line.split(','))
for entry in Entries:
if len(entry)>8:
entry=entry[:8]
if len(entry)==8:
ioff = ioff + 0.3
clueNum,msg,callsign,time,d1,d2,radioLoc,status=entry
c.drawString(0.05*inch, (8.5-ioff)*inch, clueNum)
c.drawString(0.6*inch, (8.5-ioff)*inch, time)
c.drawString(1.1*inch, (8.5-ioff)*inch, radioLoc)
c.drawString(2.0*inch, (8.5-ioff)*inch, msg)
c.drawString(0.05*inch, 8.8*inch, datetime.now().strftime("%a %b %d %Y %H:%M:%S"))
c.drawString(0.05*inch, (8.5)*inch, 'Cluenum Time Location Message')
c.showPage()
# 2nd page
if True: # indent
# define a font
c.translate(0.5*inch,inch)
# define a font
c.setFont("Helvetica", 8)
# choose some colors
c.setStrokeColorRGB(0,0,0) ## color for lines
c.setFillColorRGB(0.15,0.15,0.15) ## color for text
ioff = 0
for itm2 in range(self.ui.tableWidget_TmAs.rowCount()): # data for team assignment table
ioff = ioff + 0.3
team = self.ui.tableWidget_TmAs.item(itm2, 0).text()
c.drawString(0.5*inch, (8.5-ioff)*inch, team)
assign = self.ui.tableWidget_TmAs.item(itm2, 1).text()
c.drawString(1.5*inch, (8.5-ioff)*inch, assign)
type = self.ui.tableWidget_TmAs.item(itm2, 2).text()
c.drawString(2.5*inch, (8.5-ioff)*inch, type)
med = self.ui.tableWidget_TmAs.item(itm2, 3).text()
if med != ' ':
med = 'Yes'
c.drawString(3.5*inch, (8.5-ioff)*inch, med)
c.drawString(0.05*inch, 8.8*inch, datetime.now().strftime("%a %b %d %Y %H:%M:%S"))
c.drawString(0.05*inch, (8.5)*inch, ' Team# Assignment Type Medical')
c.showPage()
c.save()
pdfx = subprocess.Popen(["C:\Program Files\Adobe\Acrobat DC\Acrobat\Acrobat.exe", "report.pdf"])
def fixStrg(self, line, newl):
lineX = line
if newl:
pass # reading next line as part of prior line; replace
# newline with /
lineX = self.savel+lineX # concatenate lines
offset = len(self.savel) # want to continue scan after nl replacement
#newl = False # reset newl mode until a possible next nl
#print("LINE:"+str(self.savel)+":"+str(lineX)+"::")
else:
self.inStrg = False
offset= 0
#print("INTER:"+str(lineX)+":"+str(self.inStrg)+":"+str(newl)+":"+str(len(lineX)))
#
#
# CHECK -1 removal............
#
for i in range(offset,len(lineX)): # -1): # stop b4 last char REMOVE comma in a string
# also account for newline in a text string
##QQ print("ERROR:"+str(line)+":"+str(i))
if lineX[i] == '"' or self.inStrg :
#print("A:"+str(lineX[i])+":"+str(i))
if lineX[i] == ',':
lineX = lineX[:i]+';'+lineX[i+1:] # replace ',' in a string
if lineX[i] == '\n' or (newl and i == len(lineX)-2): # do not want to keep newline character
if lineX[i] == '\n': # some lines appear to have the nl (get rid of)
# others do not, so keep the character
j = 0
else:
j = 1
lineX = lineX[:i+j]+'/' # replace 'nl' in a string OR last char w/o finding nl
#print("MOD:"+str(lineX)+":"+str(i))
self.savel = lineX
newl= True
self.inStrg = True
break # at end of this line, get some more on next call
elif lineX[i] == '"' and self.inStrg:
#print("END:"+str(i))
self.inStrg = False # end of string
newl = False
continue
self.inStrg = True # only done inside a string
return(lineX, newl)
def createSTS(self):
parse=self.incidentURL.replace("http://","").replace("https://","").split("/")
domainAndPort=parse[0]
mapID=parse[-1]
syncDumpFile=None
if self.args.syncdump:
syncDumpFile='syncdump.'+mapID
logging.info('Sync dump file will be written with the response to each "since" request; each filename will begin with '+syncDumpFile)
syncDumpFile+='.txt'
cacheDumpFile=None
if self.args.cachedump:
cacheDumpFile='cachedump.'+mapID
logging.info('Cache dump file will be written after each "since" request; each filename will begin with '+cacheDumpFile)
cacheDumpFile+='.txt'
self.sts=None
box=QMessageBox(
QMessageBox.NoIcon, # other values cause the chime sound to play
'Connecting...',
'Incident Map:\n\nConnecting to '+self.incidentURL+'\n\nPlease wait...')
box.setStandardButtons(QMessageBox.NoButton)
box.show()
QCoreApplication.processEvents()
box.raise_()
logging.info("Creating SartopoSession with domainAndPort="+domainAndPort+" mapID="+mapID)
try:
if 'sartopo.com' in domainAndPort.lower():
print("Account:"+str(self.accountName))
self.sts=SartopoSession(domainAndPort=domainAndPort,mapID=mapID,
configpath=self.stsconfigpath,
account=self.accountName,
sync=False,
syncDumpFile=syncDumpFile,
cacheDumpFile=cacheDumpFile,
useFiddlerProxy=True)
else:
self.sts=SartopoSession(domainAndPort=domainAndPort,mapID=mapID,sync=False,syncDumpFile=syncDumpFile,cacheDumpFile=cacheDumpFile,useFiddlerProxy=True, syncTimeout=30)
self.link=self.sts.apiVersion
except Exception as e:
logging.warning('Exception during createSTS:\n'+str(e))
self.link=-1
finally:
box.done(0)
if self.link>-1:
logging.info('Successfully connected.')
self.ui.incidentLinkLight.setStyleSheet(BG_GREEN)
if not self.reloaded:
self.getObjects()
'''
#Z1
else:
pass
####self.rescan() #QQ ## Do a rescan here to get info updated after a reload
'''
self.tryAgain=False
else:
logging.info('Connection failed.')
self.ui.incidentLinkLight.setStyleSheet(BG_RED)
inform_user_about_issue('Link could not be established with specified incident map\n\n'+self.incidentURL+'\n\nPlease specify a valid map, or hit Cancel from the map dialog to run Plans Console with no incident map.',parent=self)
self.incidentMapDialog=SpecifyMapDialog(self,'Incident',None,domainAndPort)
if self.incidentMapDialog.exec(): # force modal
self.ui.incidentMapField.setText(self.incidentMapDialog.url)
self.ui.incidentLinkLight.setStyleSheet(BG_GRAY)
self.incidentURL=self.incidentMapDialog.url
self.incidentDomainAndPort=self.incidentMapDialog.domainAndPort
if not self.incidentURL:
self.tryAgain=False
else:
self.tryAgain=False
def getObjects(self): # run when the map has NOT been reloaded OR needs to be updated
pass # look at map to get features to load into the assignment table
print("Loading assignment table from map")
# get Medical marker information
try:
medMarkers=[f for f in self.sts.getFeatures('Marker') if f['properties'].get('marker-symbol','') == 'medevac-site']
print("updating markers")
except:
return # if timeout then just return
# get assignments with teams(s) assigned
try:
assignmentsWithNumber=[f for f in self.sts.getFeatures('Assignment') if f['properties'].get('number','') != '']
print("updating assignments with teams")
except:
return # if timeout then just return
# Need to parse title to get assignemnt and each team #
l = [] # init list of entries
for a in assignmentsWithNumber:
s = re.sub(' +', ' ',a['properties']['title'].strip()) # split at space or comma or slash (get assignment & teams)
s = re.split(r'[ ,/]', s) # split at space or comma or slash (get assignment & teams)
#s = re.split(r'[ ,/]', a['properties']['title'].strip()) # split at space or comma or slash (get assignment & teams)
print("####### "+str(s)+"::"+str(a['properties']['title'].strip())+"::")
# pop warning message that Assignment does not exist - skipping
if s[0] == '' and self.FIRST_PASS: # no assignment or assignment is in number (team) field
inform_user_about_issue("Mostlikely Assignment name, "+str(s[1])+", is in the number field, skipping")
continue
scnt = len(s)
#$#if self.flag == 1:
#$# scnt = min(scnt,2)
for k in range(0, scnt-1): # run thru team numbers (or LE callsign)
if len(s[k+1]) >= 3 and (s[k+1][0].isdigit() and s[k+1][1].isalpha() and s[k+1][2].isdigit()): # 2nd char is a digit, not LE
x = 'LE'
else:
x = a['properties']['resourceType']
Med = False
for m in medMarkers:
#print("Med Info Chk:"+str(m)+":"+str(s[k+1]+s[0]))
#if m['properties']['title'] == s[k+1] and m['properties']['description'] == s[0]: #OLD team# was displayed on the map
if m['properties']['title'] == s[k+1] and m['properties']['description'] == s[k+1]+s[0]:
#print("Found")
Med = True # will get Medical info from the Marker
if Med: self.medval = " X"
else: self.medval = " " # need at least a space so that it is not empty
l.append([s[k+1], s[0], x, self.medval])
l.sort(key = lambda g: g[0], reverse = True) # sort by 1st element, team #
self.FIRST_PASS = False # set after first time thru so that warning above is only given once
for el in l:
self.ui.tableWidget_TmAs.insertRow(0)
self.ui.tableWidget_TmAs.setItem(0, 0, QtWidgets.QTableWidgetItem(el[0]))
self.ui.tableWidget_TmAs.setItem(0, 1, QtWidgets.QTableWidgetItem(el[1]))
self.ui.tableWidget_TmAs.setItem(0, 2, QtWidgets.QTableWidgetItem(el[2]))
self.ui.tableWidget_TmAs.setItem(0, 3, QtWidgets.QTableWidgetItem(el[3]))
# set type to Unk if not type is unknown from map info
def addMarker(self):
folders=self.sts.getFeatures("Folder")
self.fidX = True # set to something other than None for following test
if not folders:
self.fidX = self.sts.addFolder("X") # add unused folder for following test
print("StatusAddFolder"+str(self.fidX))
if self.fidX == None: # could not add
inform_user_about_issue("Mostlikely this session is not connected to the map for write access. Check that the proper account is being used.")
return
logging.info('addMarker folders:'+str(folders))
fid=False # used to help get display of first marker of type Med or LE, see below
for folder in folders:
if folder["properties"]["title"]=="Medical":
self.fidMed=folder["id"]
fid = True # use here or below?
if folder["properties"]["title"]=="LE":
self.fidLE=folder["id"]
fid = True # use here or below?
#if not self.fidMed:
# self.fidMed = self.sts.addFolder("Medical")
#if not self.fidLE:
# self.fidLE = self.sts.addFolder("LE")
## icons
if self.medval == " X":
if not self.fidMed:
self.fidMed = self.sts.addFolder("Medical")
fid = True
markr = "medevac-site" # medical +
clr = "FF0000"
#rval=self.sts.addMarker(self.latField,self.lonField,self.curTeam,self.curAssign, \ # OLD: team# was displayed on the map
rval=self.sts.addMarker(self.latField,self.lonField,self.curTeam,self.curTeam+self.curAssign, \
clr,markr,None,self.fidMed)
if fid: # temporary fix NOW (10/25/2023) does not seem to help
print("At reADD marker")
time.sleep(4) # the delay, delete and redo seems to get display of marker
self.delMarker() # removes most recent
rval=self.sts.addMarker(self.latField,self.lonField,self.curTeam,self.curTeam+self.curAssign, \
clr,markr,None,self.fidMed)
elif self.curType == "LE": # law enforcement
if not self.fidLE:
self.fidLE = self.sts.addFolder("LE")
fid = True
markr = "icon-ERJ4011P-24-0.5-0.5-ff" # red dot with blue circle
clr = "FF0000"
rval=self.sts.addMarker(self.latField,self.lonField,self.curTeam, \
self.curAssign,clr,markr,None,self.fidLE)
if fid: # temporary fix NOW (10/25/2023) does not seem to help
print("At reADD marker")
time.sleep(4) # the delay, delete and redo seems to get display of marker
self.delMarker()
rval=self.sts.addMarker(self.latField,self.lonField,self.curTeam, \
self.curAssign,clr,markr,None,self.fidLE)
else:
pass #X# don't place marker for searcher
#X# markr = "hiking" # default
#X# clr = "FFFF00"
rval = "X" # place holder
logging.info("In addMarker:"+self.curTeam)
## also add team number to assignment
rval2 = self.sts.mapData['state']['features']
numbr = ""
for props in rval2:
lettr = props['properties'].get('letter')
if lettr is None: continue
if lettr == self.curAssign:
numbr = props['properties'].get('number')
if numbr=='':
numbr=self.curTeam
else:
numbr += " "+self.curTeam # set the team# and resource from table entry
rval2=self.sts.editFeature(className='Assignment',letter=self.curAssign.upper(),properties={'number':numbr, \
'resourceType':self.curType})
if rval2 == False:
inform_user_about_issue('Could not edit map object, probably do not have access to EDIT this map.',parent=self)
logging.info("RVAL rtn:"+str(rval)+' : '+str(rval2))
def delMarker(self):
rval = self.sts.getFeatures("Folder") # get Folders
rval2 = self.sts.getFeatures("Marker")
##print("Folders:"+json.dumps(rval))
fidLE = None
fidMed = None
for self.feature2 in rval:
if self.feature2['properties'].get("title") == 'LE': # find LE folder Match
fidLE=self.feature2.get("id")
if self.feature2['properties'].get("title") == 'Medical': # find Medical folder Match
fidMed=self.feature2.get("id")
if fidLE != None or fidMed != None:
logging.info("id:"+str(fidLE))
##print("Marker:"+json.dumps(rval2))
# get Markers
for self.feature2 in rval2:
if (self.feature2['properties'].get('folderId') == fidLE or self.feature2['properties'].get('folderId') == fidMed) and \
self.feature2['properties'].get('title').upper() == self.curTeam.upper(): # both folder and Team match
logging.info("Marker ID:"+self.feature2['id']+" of team: "+self.curTeam)
rval3 = self.sts.delMarker(self.feature2['id'])
# remove the team number from any assignments that contain it
assignmentsWithThisNumber=[f for f in self.sts.getFeatures('Assignment') if self.curTeam.upper() in f['properties'].get('number','').upper()]
for a in assignmentsWithThisNumber:
n=a['properties']['number']
pe=a['properties']['previousEfforts']
logging.info('changing assignment "'+a['properties']['title']+'": old number = "'+n+'"')
nList=n.upper().split()
if self.curTeam.upper() in nList:
nList.remove(self.curTeam.upper())
else:
return
n=' '.join(nList)
logging.info(' new number = "'+n+'"')
pe += ' T'+self.curTeam+datetime.now().strftime("-%d%b%y_%H%M") # append info to previousEfforts field
self.sts.editFeature(id=a['id'],properties={'number':n,'previousEfforts':pe}) # removes team# from assignment
###@@@@###
##print("RestDel:"+json.dumps(rval3,indent=2))
# check for Medical marker and also remove
if self.ui.Med.isChecked():
pass # remove marker from map and from Medical folder
for md in rval:
if md['properties'].get("title") == 'Medical': # find Medical folder match
fid = md.get('id')
for md in rval2: # match both folder and Team
if md['properties'].get('folderId') == fid and md['properties'].get('title').upper() == self.curTeam.upper():
rval4 = self.sts.delMarker(md['id'])
## APPEARS to not be used
def updateFeatureList(self,featureClass,filterFolderId=None):
# unfiltered feature list should be kept as an object;
# filtered feature list (i.e. combobox items) should be recalculated here on each call
logging.info("updateFeatureList called: "+featureClass+" filterFolderId="+str(filterFolderId))
if self.sts and self.link>0:
rval=self.sts.getFeatures(featureClass,self.since[featureClass])
self.since[featureClass]=int(time.time()*1000) # sartopo wants integer milliseconds
logging.info("At sts check")
if rval:
logging.info("rval:"+str(rval))
for feature in rval:
for oldFeature in self.featureListDict[featureClass]:
if feature["id"]==oldFeature["id"]:
self.featureListDict[featureClass].remove(oldFeature)
self.featureListDict[featureClass].append(feature)
self.featureListDict[featureClass].sort(key=sortByTitle)
# recreate the filtered list regardless of whether there were new features in rval
items=[]
for feature in self.featureListDict[featureClass]:
id=feature.get("id",0)
prop=feature.get("properties",{})
name=prop.get("title","UNNAMED")
add=True
if filterFolderId:
fid=prop.get("folderId",0)
if fid!=filterFolderId:
add=False
logging.info(" filtering out feature:"+str(id))
if add:
logging.info(" adding feature:"+str(id))
if featureClass=="Folder":
items.append([name,id])
else:
items.append([name,[id,prop]])
else:
logging.info("no return data, i.e. no new features of this class since the last check")
else:
logging.info("No map link has been established yet. Could not get Folder features.")
self.featureListDict[featureClass]=[]
self.since[featureClass]=0
items=[]
logging.info(" unfiltered list:"+str(self.featureListDict[featureClass]))
logging.info(" filtered list:"+str(items))
def readConfigFile(self):
# create the file (and its directory) if it doesn't already exist
dir=os.path.dirname(self.configFileName)
if not os.path.exists(self.configFileName):
logging.info("Config file "+self.configFileName+" not found.")
if not os.path.isdir(dir):
try:
logging.info("Creating config dir "+dir)
os.makedirs(dir)
except:
logging.error("ERROR creating directory "+dir+" for config file.")
try:
defaultConfigFileName=os.path.join(os.path.dirname(os.path.realpath(__file__)),"default.cfg")
logging.info("Copying default config file "+defaultConfigFileName+" to "+self.configFileName)
shutil.copyfile(defaultConfigFileName,self.configFileName)
except:
logging.error("ERROR copying the default config file to the local config path.")
# specify defaults here
# self.watchedDir="Z:\\"
logging.info(' Reading config file '+self.configFileName)
# configFile=QFile(self.configFileName)
self.config=configparser.ConfigParser()
self.config.read(self.configFileName)
if 'Plans_console' not in self.config.sections():
# if not configFile.open(QFile.ReadOnly|QFile.Text):