diff --git a/CrossMgrVideo/AddPhotoHeader.py b/CrossMgrVideo/AddPhotoHeader.py index a31a4d251..d4de86001 100644 --- a/CrossMgrVideo/AddPhotoHeader.py +++ b/CrossMgrVideo/AddPhotoHeader.py @@ -86,7 +86,7 @@ def setDrawResources( dc, w, h ): drawResources.labelHeight = int(drawResources.bibHeight * 1.25 + 0.5) + int(drawResources.fontHeight * 1.25 + 0.5) -def AddPhotoHeader( bitmap, bib=None, ts=None, raceSeconds=None, first_name='', last_name='', team='', race_name='', kmh=None, mph=None ): +def AddPhotoHeader( bitmap, bib=None, ts=None, raceSeconds=None, first_name='', last_name='', machine='', team='', race_name='', kmh=None, mph=None ): global drawResources if bitmap is None: @@ -124,6 +124,7 @@ def AddPhotoHeader( bitmap, bib=None, ts=None, raceSeconds=None, first_name='', if mph: tsTxt += ', {:.2f}mph'.format(mph) if isinstance(mph, float) else str(kmh) nameTxt = ' '.join( n for n in (first_name, last_name) if n ) + nameTxt = ' - '.join( n for n in (nameTxt, machine) if n ) frameWidth = 4 borderWidth = 1 diff --git a/CrossMgrVideo/CrossMgrVideoHtml/PhotoPage.html b/CrossMgrVideo/CrossMgrVideoHtml/PhotoPage.html index faa50610f..9782f7ec0 100644 --- a/CrossMgrVideo/CrossMgrVideoHtml/PhotoPage.html +++ b/CrossMgrVideo/CrossMgrVideoHtml/PhotoPage.html @@ -128,7 +128,7 @@ matched = []; if( search_text ) { - const attrs = ['bib', 'first_name', 'last_name', 'team']; + const attrs = ['bib', 'first_name', 'last_name', 'machine', 'team']; for( const info of photo_info ) { for( const a of attrs ) { if( (info[a] + '').toLowerCase().includes( search_text ) ) { @@ -147,7 +147,7 @@ table.appendChild( thead ); let tr = document.createElement( 'tr' ); thead.appendChild( tr ); - const headers = ['', 'Bib', 'First Name', 'Last Name', 'Team', 'Time', 'Time of Day', 'Race']; + const headers = ['', 'Bib', 'First Name', 'Last Name', 'Machine', 'Team', 'Time', 'Time of Day', 'Race']; for( const h of headers ) { let th = document.createElement('th'); th.appendChild( document.createTextNode(h) ); @@ -155,7 +155,7 @@ } let tbody = document.createElement( 'tbody' ); table.appendChild( tbody ); - const attrs = ['bib', 'first_name', 'last_name', 'team', 'raceSeconds', 'ts', 'race_name']; + const attrs = ['bib', 'first_name', 'last_name', 'machine', 'team', 'raceSeconds', 'ts', 'race_name']; for( let i = 0; i < matched.length; ++i ) { const info = matched[i]; let tr = document.createElement( 'tr' ); @@ -272,7 +272,7 @@
CrossMgr     -      +      Click and Drag in Photo to Zoom.    
diff --git a/CrossMgrVideo/CrossMgrVideoHtml/main.html b/CrossMgrVideo/CrossMgrVideoHtml/main.html index 5fb3f8010..09481d01b 100644 --- a/CrossMgrVideo/CrossMgrVideoHtml/main.html +++ b/CrossMgrVideo/CrossMgrVideoHtml/main.html @@ -533,9 +533,9 @@ trigger_div.scrollTop = trigger_div.scrollHeight; } -const headers = ['', 'Time', 'Bib', 'Last', 'First', 'Team', 'Wave', 'Race', 'Frames', 'View', 'Note']; -const fields = ['close', 'ts', 'bib', 'last_name', 'first_name', 'team', 'wave', 'race_name', 'frames', 'view', 'note']; -const numeric = [false, false, true, false, false, false, false, false, true, true, false]; +const headers = ['', 'Time', 'Bib', 'Last', 'First', 'Machine', 'Team', 'Wave', 'Race', 'Frames', 'View', 'Note']; +const fields = ['close', 'ts', 'bib', 'last_name', 'first_name', 'machine', 'team', 'wave', 'race_name', 'frames', 'view', 'note']; +const numeric = [false, false, true, false, false, false, false, false, false, true, true, false]; function showTriggers( triggers ) { playStop(); diff --git a/CrossMgrVideo/Database.py b/CrossMgrVideo/Database.py index 9f7a76eb2..ec1bc280b 100644 --- a/CrossMgrVideo/Database.py +++ b/CrossMgrVideo/Database.py @@ -60,7 +60,7 @@ def default_str( d ): class Database: triggerFieldsAll = ( - 'id','ts','s_before','s_after','ts_start','closest_frames','bib','first_name','last_name','team','wave','race_name', + 'id','ts','s_before','s_after','ts_start','closest_frames','bib','first_name','last_name','machine','team','wave','race_name', 'note','kmh','frames', 'finish_direction', 'zoom_frame', 'zoom_x', 'zoom_y', 'zoom_width', 'zoom_height', @@ -71,7 +71,7 @@ class Database: triggerFieldsInput = tuple( triggerFieldsAllSet - {'id', 'note', 'kmh', 'frames', 'finish_direction', 'zoom_frame', 'zoom_x', 'zoom_y', 'zoom_width', 'zoom_height',}) # Fields to compare for equality of triggers. triggerFieldsUpdate = ('wave','race_name',) - triggerEditFields = ('bib', 'first_name', 'last_name', 'team', 'wave', 'race_name', 'note',) + triggerEditFields = ('bib', 'first_name', 'last_name', 'machine', 'team', 'wave', 'race_name', 'note',) @staticmethod def isValidDatabase( fname ): @@ -126,6 +126,8 @@ def __init__( self, fname=None, initTables=True, fps=30 ): cols = cur.fetchall() if cols: col_names = {col[1] for col in cols} + if 'machine' not in col_names: + self.conn.execute( 'ALTER TABLE trigger ADD COLUMN machine TEXT DEFAULT ""' ) if 'note' not in col_names: self.conn.execute( 'ALTER TABLE trigger ADD COLUMN note TEXT DEFAULT ""' ) if 'kmh' not in col_names: @@ -169,6 +171,7 @@ def __init__( self, fname=None, initTables=True, fps=30 ): ('bib', 'INTEGER', 'ASC', None), ('first_name', 'TEXT', 'ASC', None), ('last_name', 'TEXT', 'ASC', None), + ('machine', 'TEXT', 'ASC', None), ('team', 'TEXT', 'ASC', None), ('wave', 'TEXT', 'ASC', None), ('race_name', 'TEXT', 'ASC', None), diff --git a/CrossMgrVideo/MainWin.py b/CrossMgrVideo/MainWin.py index 1900a8c90..0a86586a8 100644 --- a/CrossMgrVideo/MainWin.py +++ b/CrossMgrVideo/MainWin.py @@ -30,6 +30,7 @@ from time import sleep import numpy as np from queue import Queue, Empty +import ast from datetime import datetime, timedelta, time @@ -630,11 +631,12 @@ def __init__( self, parent, id = wx.ID_ANY, title='', size=(1000,800) ): images.extend( [self.sm_up, self.sm_dn] ) self.triggerList.SetSmallImages( images ) - self.fieldCol = {f:c for c, f in enumerate('ts bib name team wave race_name frames view kmh mph note'.split())} - headers = ['Time', 'Bib', 'Name', 'Team', 'Wave', 'Race', 'Frames', 'View', 'km/h', 'mph', 'Note'] + self.fieldCol = {f:c for c, f in enumerate('ts bib name machine team wave race_name frames view kmh mph note'.split())} + self.fieldHeaders = ['Time', 'Bib', 'Name', 'Machine', 'Team', 'Wave', 'Race', 'Frames', 'View', 'km/h', 'mph', 'Note'] formatRightHeaders = {'Bib','Frames','km/h','mph'} formatMiddleHeaders = {'View',} - for i, h in enumerate(headers): + self.hiddenTriggerCols = [] + for i, h in enumerate(self.fieldHeaders): if h in formatRightHeaders: align = wx.LIST_FORMAT_RIGHT elif h in formatMiddleHeaders: @@ -649,6 +651,7 @@ def __init__( self, parent, id = wx.ID_ANY, title='', size=(1000,800) ): self.triggerList.Bind( wx.EVT_LIST_ITEM_RIGHT_CLICK, self.onTriggerRightClick ) self.triggerList.Bind( wx.EVT_LIST_KEY_DOWN, self.onTriggerKey ) #self.triggerList.Bind( wx.EVT_LIST_DELETE_ITEM, self.onTriggerDelete ) + self.triggerList.Bind( wx.EVT_LIST_COL_RIGHT_CLICK, self.onTriggerColumnRightClick ) vsTriggers = wx.BoxSizer( wx.VERTICAL ) vsTriggers.Add( hsDate ) @@ -1043,7 +1046,7 @@ def write_photos( dirname, infoList ): tsBest, jpgBest = GlobalDatabase().getBestTriggerPhoto( info['id'] ) if jpgBest is None: continue - args = {k:info[k] for k in ('ts', 'bib', 'first_name', 'last_name', 'team', 'race_name', 'kmh')} + args = {k:info[k] for k in ('ts', 'bib', 'first_name', 'last_name', 'machine', 'team', 'race_name', 'kmh')} try: args['raceSeconds'] = (info['ts'] - info['ts_start']).total_seconds() except Exception: @@ -1059,7 +1062,7 @@ def write_photos( dirname, infoList ): info['first_name'], ) ) - comment = json.dumps( {k:info[k] for k in ('bib', 'first_name', 'last_name', 'team', 'race_name')} ) + comment = json.dumps( {k:info[k] for k in ('bib', 'first_name', 'last_name', 'machine', 'team', 'race_name')} ) try: with open(os.path.join(dirname, fname), 'wb') as f: f.write( AddExifToJpeg(jpg, info['ts'], comment) ) @@ -1132,7 +1135,7 @@ def publish_web_photos( dirname, infoList, singleFile ): if jpgBest is None: continue - args = {k:info[k] for k in ('ts', 'bib', 'first_name', 'last_name', 'team', 'race_name', 'kmh')} + args = {k:info[k] for k in ('ts', 'bib', 'first_name', 'last_name', 'machine', 'team', 'race_name', 'kmh')} try: args['raceSeconds'] = (info['ts'] - info['ts_start']).total_seconds() except Exception: @@ -1140,7 +1143,7 @@ def publish_web_photos( dirname, infoList, singleFile ): if isinstance(args['kmh'], str): args['kmh'] = float( '0' + re.sub( '[^0-9.]', '', args['kmh'] ) ) - comment = json.dumps( {k:info[k] for k in ('bib', 'first_name', 'last_name', 'team', 'race_name')} ) + comment = json.dumps( {k:info[k] for k in ('bib', 'first_name', 'last_name', 'machine', 'team', 'race_name')} ) jpg = CVUtil.bitmapToJPeg( AddPhotoHeader(CVUtil.jpegToBitmap(jpgBest), **args) ) jpg = AddExifToJpeg( jpg, info['ts'], comment ) @@ -1184,7 +1187,11 @@ def getTriggerRowFromID( self, id ): def updateTriggerColumnWidths( self ): for c in range(self.triggerList.GetColumnCount()): - self.triggerList.SetColumnWidth(c, wx.LIST_AUTOSIZE_USEHEADER if c != self.iNoteCol else 100 ) + if not c in self.hiddenTriggerCols: + self.triggerList.SetColumnWidth(c, wx.LIST_AUTOSIZE_USEHEADER if c != self.iNoteCol else 100 ) + else: + #if column is hidden, just set the width to zero + self.triggerList.SetColumnWidth( c, 0 ) def updateTriggerRow( self, row, fields ): ''' Update the row in the UI only. ''' @@ -1307,6 +1314,7 @@ def updateSnapshot( self, t, f ): 'bib': self.snapshotCount, 'first_name': '', 'last_name': 'Snapshot', + 'machine': '', 'team': '', 'wave': '', 'race_name': '', @@ -1602,6 +1610,29 @@ def onTriggerEdit( self, event ): self.iTriggerSelect = event.Index self.doTriggerEdit() + def onTriggerColumnRightClick( self, event ): + # Create and display a popup menu of columns on right-click event + menu = wx.Menu() + menu.SetTitle( 'Show/Hide columns' ) + for c in range(self.triggerList.GetColumnCount()): + menuItem = menu.AppendCheckItem( wx.ID_ANY, self.fieldHeaders[c] ) + self.Bind(wx.EVT_MENU, self.onToggleTriggerColumn) + if not c in self.hiddenTriggerCols: + menu.Check( menuItem.GetId(), True ) + self.PopupMenu(menu) + menu.Destroy() + + def onToggleTriggerColumn( self, event ): + #find the column number + label = event.GetEventObject().FindItemById(event.GetId()).GetItemLabel() + c = self.fieldHeaders.index(label) + #add or remove from hidden columns and update width + if c in self.hiddenTriggerCols: + self.hiddenTriggerCols.remove( c ) + else: + self.hiddenTriggerCols.append( c ) + self.updateTriggerColumnWidths() + def showMessages( self ): while True: message = self.messageQ.get() @@ -1750,6 +1781,7 @@ def processRequests( self ): 'bib': msg.get('bib', 99999), 'first_name': msg.get('first_name','') or msg.get('firstName',''), 'last_name': msg.get('last_name','') or msg.get('lastName',''), + 'machine': msg.get('machine',''), 'team': msg.get('team',''), 'wave': msg.get('wave',''), 'race_name': msg.get('race_name','') or msg.get('raceName',''), @@ -1880,6 +1912,7 @@ def writeOptions( self ): self.config.Write( 'SecondsBefore', '{:.3f}'.format(self.tdCaptureBefore.total_seconds()) ) self.config.Write( 'SecondsAfter', '{:.3f}'.format(self.tdCaptureAfter.total_seconds()) ) self.config.WriteFloat( 'ZoomMagnification', self.finishStrip.GetZoomMagnification() ) + self.config.Write( 'HiddenTriggerCols', repr(self.hiddenTriggerCols) ) self.config.WriteInt( 'ClosestFrames', self.autoCaptureClosestFrames ) self.config.Flush() @@ -1900,6 +1933,7 @@ def readOptions( self ): except Exception: pass self.finishStrip.SetZoomMagnification( self.config.ReadFloat('ZoomMagnification', 0.5) ) + self.hiddenTriggerCols = ast.literal_eval( self.config.Read( 'HiddenTriggerCols', '[]' ) ) self.autoCaptureClosestFrames = self.config.ReadInt( 'ClosestFrames', 0 ) def getCameraInfo( self ): diff --git a/CrossMgrVideo/helptxt/QuickStart.md b/CrossMgrVideo/helptxt/QuickStart.md index e71efb59d..443607870 100644 --- a/CrossMgrVideo/helptxt/QuickStart.md +++ b/CrossMgrVideo/helptxt/QuickStart.md @@ -211,7 +211,9 @@ Window containing the triggers. * Left-click: shows the photos associated with the trigger. * Right-click: brings up an Edit... and Delete... menu. -* Double-click: brings up the Edit window. This allows you to change the Bib, First, Last, Team, Wave, Race and Note of the trigger. +* Double-click: brings up the Edit window. This allows you to change the Bib, First, Last, Machine, Team, Wave, Race and Note of the trigger. + +Columns can be hidden or shown by right-clicking on the header row. Triggers can be created manually with __Snapshot__, __Auto Capture__, __Capture__ or when connected to CrossMgr, from CrossMgr entries. CrossMgr can be configured to create a trigger for every entry, or just the last entry in the race (see CrossMgr help for details on the Camera setup). diff --git a/FtpWriteFile.py b/FtpWriteFile.py index 553ef79bf..2a5c554df 100644 --- a/FtpWriteFile.py +++ b/FtpWriteFile.py @@ -4,7 +4,9 @@ import os import sys import webbrowser +import ftplib import ftputil +import ftputil.session import paramiko from urllib.parse import quote import datetime @@ -21,10 +23,10 @@ def lineno(): return inspect.currentframe().f_back.f_lineno class CallCloseOnExit: - def __enter__(self, obj): + def __init__(self, obj): self.obj = obj - return obj - + def __enter__(self): + return self.obj def __exit__(self, exc_type, exc_val, exc_tb): self.obj.close() @@ -55,8 +57,35 @@ def sftp_mkdir_p( sftp, remote_directory ): # Create new dirs starting from the last one that existed. for i in range( i_dir_last, len(dirs_exist) ): sftp.mkdir( '/'.join(dirs_exist[:i+1]) ) - -def FtpWriteFile( host, user='anonymous', passwd='anonymous@', timeout=30, serverPath='.', fname='', useSftp=False, sftpPort=22, callback=None ): + + +class FtpWithPort(ftplib.FTP): + def __init__(self, host, user, passwd, port, timeout): + #Act like ftplib.FTP's constructor but connect to another port. + ftplib.FTP.__init__(self) + #self.set_debuglevel(2) + self.connect(host, port, timeout) + self.login(user, passwd) + +class FtpsWithPort(ftplib.FTP_TLS): + def __init__(self, host, user, passwd, port, timeout): + ftplib.FTP_TLS.__init__(self) + #self.set_debuglevel(2) + self.connect(host, port, timeout) + self.auth() + self.login(user, passwd) + #Switch to secure data connection. + self.prot_p() + + def ntransfercmd(self, cmd, rest=None): + conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) + if self._prot_p: + conn = self.context.wrap_socket(conn, + server_hostname=self.host, + session=self.sock.session) #reuse ssl session + return conn, size + +def FtpWriteFile( host, port, user='anonymous', passwd='anonymous@', timeout=30, serverPath='.', fname='', protocol='FTP', callback=None ): if isinstance(fname, str): fname = [fname] @@ -64,7 +93,7 @@ def FtpWriteFile( host, user='anonymous', passwd='anonymous@', timeout=30, serve # Normalize serverPath. serverPath = serverPath.strip().replace('\\', '/').rstrip('/') - if not useSftp: + if protocol != 'SFTP': # Stops ftputils from going into an infinite loop by removing leading slashes.. serverPath = serverPath.lstrip('/').lstrip('\\') @@ -85,22 +114,22 @@ def FtpWriteFile( host, user='anonymous', passwd='anonymous@', timeout=30, serve return ''' - if useSftp: + if protocol == 'SFTP': with CallCloseOnExit(paramiko.SSHClient()) as ssh: ssh.set_missing_host_key_policy( paramiko.AutoAddPolicy() ) ssh.load_system_host_keys() - ssh.connect( host, sftpPort, username, passwd ) + ssh.connect( host, port, user, passwd ) with CallCloseOnExit(ssh.open_sftp()) as sftp: sftp_mkdir_p( sftp, serverPath ) for i, f in enumerate(fname): sftp.put( - filePath, + f, serverPath + '/' + os.path.basename(f), SftpCallback( callback, f, i ) if callback else None ) - else: - with ftputil.FTPHost( host, user, passwd ) as ftp_host: + elif protocol == 'FTPS': + with ftputil.FTPHost(host, user, passwd, port, timeout, session_factory=FtpsWithPort) as ftp_host: ftp_host.makedirs( serverPath, exist_ok=True ) for i, f in enumerate(fname): ftp_host.upload_if_newer( @@ -108,6 +137,18 @@ def FtpWriteFile( host, user='anonymous', passwd='anonymous@', timeout=30, serve serverPath + '/' + os.path.basename(f), (lambda byteStr, fname=f, i=i: callback(byteStr, fname, i)) if callback else None ) + ftp_host.close() + + else: #default to unencrypted FTP + with ftputil.FTPHost(host, user, passwd, port, timeout, session_factory=FtpWithPort) as ftp_host: + ftp_host.makedirs( serverPath, exist_ok=True ) + for i, f in enumerate(fname): + ftp_host.upload_if_newer( + f, + serverPath + '/' + os.path.basename(f), + (lambda byteStr, fname=f, i=i: callback(byteStr, fname, i)) if callback else None + ) + ftp_host.close() def FtpIsConfigured(): with Model.LockRace() as race: @@ -126,10 +167,11 @@ def FtpUploadFile( fname=None, callback=None ): params = { 'host': getattr(race, 'ftpHost', '').strip().strip('\t'), # Fix cut and paste problems. + 'port': getattr(race, 'ftpPort', 21), 'user': getattr(race, 'ftpUser', ''), 'passwd': getattr(race, 'ftpPassword', ''), 'serverPath': getattr(race, 'ftpPath', ''), - 'useSftp': getattr(race, 'useSftp', False), + 'protocol': getattr(race, 'ftpProtocol', 'FTP'), 'fname': fname or [], 'callback': callback, } @@ -350,8 +392,8 @@ def getTitleTextSize( font ): #------------------------------------------------------------------------------------------------ -ftpFields = ['ftpHost', 'ftpPath', 'ftpPhotoPath', 'ftpUser', 'ftpPassword', 'useSftp', 'ftpUploadDuringRace', 'urlPath', 'ftpUploadPhotos'] -ftpDefaults = ['', '', '', 'anonymous', 'anonymous@', False, False, 'http://', False] +ftpFields = ['ftpHost', 'ftpPort', 'ftpPath', 'ftpPhotoPath', 'ftpUser', 'ftpPassword', 'ftpUploadDuringRace', 'urlPath', 'ftpUploadPhotos'] +ftpDefaults = ['', 21, '', '', 'anonymous', 'anonymous@', False, 'http://', False] def GetFtpPublish( isDialog=True ): ParentClass = wx.Dialog if isDialog else wx.Panel @@ -364,11 +406,17 @@ def __init__( self, parent, id=wx.ID_ANY, uploadNowButton=True ): else: super().__init__( parent, id ) + self.protocol = 'FTP' + fgs = wx.FlexGridSizer(vgap=4, hgap=4, rows=0, cols=2) fgs.AddGrowableCol( 1, 1 ) - self.useSftp = wx.CheckBox( self, label=_("Use SFTP Protocol (on port 22)") ) + self.useFtp = wx.RadioButton( self, label=_("FTP (unencrypted)"), style = wx.RB_GROUP ) + self.useFtps = wx.RadioButton( self, label=_("FTPS (FTP with TLS)") ) + self.useSftp = wx.RadioButton( self, label=_("SFTP (SSH file transfer)") ) + self.Bind( wx.EVT_RADIOBUTTON,self.onSelectProtocol ) self.ftpHost = wx.TextCtrl( self, size=(256,-1), style=wx.TE_PROCESS_ENTER, value='' ) + self.ftpPort = wx.lib.intctrl.IntCtrl( self, size=(256,-1), style=wx.TE_PROCESS_ENTER ) self.ftpPath = wx.TextCtrl( self, size=(256,-1), style=wx.TE_PROCESS_ENTER, value='' ) self.ftpUploadPhotos = wx.CheckBox( self, label=_("Upload Photos to Path") ) self.ftpUploadPhotos.Bind( wx.EVT_CHECKBOX, self.ftpUploadPhotosChanged ) @@ -393,12 +441,20 @@ def __init__( self, parent, id=wx.ID_ANY, uploadNowButton=True ): self.cancelBtn = wx.Button( self, wx.ID_CANCEL ) self.Bind( wx.EVT_BUTTON, self.onCancel, self.cancelBtn ) + fgs.Add( wx.StaticText( self, label = _("Protocol")), flag=wx.ALIGN_RIGHT|wx.ALIGN_CENTRE_VERTICAL ) + fgs.Add( self.useFtp, 1, flag=wx.TOP|wx.ALIGN_LEFT) fgs.AddSpacer( 16 ) - fgs.Add( self.useSftp ) + fgs.Add( self.useFtps, 1, flag=wx.TOP|wx.ALIGN_LEFT) + fgs.AddSpacer( 16 ) + fgs.Add( self.useSftp, 1, flag=wx.TOP|wx.ALIGN_LEFT) + fgs.Add( wx.StaticText( self, label = _("Host Name")), flag=wx.ALIGN_RIGHT|wx.ALIGN_CENTRE_VERTICAL ) fgs.Add( self.ftpHost, 1, flag=wx.TOP|wx.ALIGN_LEFT|wx.EXPAND ) + fgs.Add( wx.StaticText( self, label = _("Port")), flag=wx.ALIGN_RIGHT|wx.ALIGN_CENTRE_VERTICAL ) + fgs.Add( self.ftpPort, 1, flag=wx.TOP|wx.ALIGN_LEFT|wx.EXPAND ) + fgs.Add( wx.StaticText( self, label = _("Upload files to Path")), flag=wx.ALIGN_RIGHT|wx.ALIGN_CENTRE_VERTICAL ) fgs.Add( self.ftpPath, 1, flag=wx.EXPAND ) @@ -459,6 +515,17 @@ def __init__( self, parent, id=wx.ID_ANY, uploadNowButton=True ): fgs.AddSpacer( 4 ) self.SetSizerAndFit( fgs ) fgs.Fit( self ) + + def onSelectProtocol( self, event ): + if self.useSftp.GetValue(): + self.protocol = 'SFTP' + self.ftpPort.SetValue(22) + elif self.useFtps.GetValue(): + self.protocol = 'FTPS' + self.ftpPort.SetValue(21) + else: + self.protocol = 'FTP' + self.ftpPort.SetValue(21) def onFtpTest( self, event ): self.commit() @@ -531,6 +598,7 @@ def refresh( self ): else: for f, v in zip(ftpFields, ftpDefaults): getattr(self, f).SetValue( getattr(race, f, v) ) + self.protocol = getattr(race, 'ftpProtocol', '') self.urlPathChanged() self.ftpUploadPhotosChanged() @@ -540,6 +608,7 @@ def commit( self ): if race: for f in ftpFields: setattr( race, f, getattr(self, f).GetValue() ) + setattr( race, 'ftpProtocol', self.protocol) race.urlFull = self.urlFull.GetLabel() race.setChanged() diff --git a/MainWin.py b/MainWin.py index d4f3bd783..26f3507a9 100644 --- a/MainWin.py +++ b/MainWin.py @@ -1862,8 +1862,8 @@ def getBasePayload( self, publishOnly=True ): payload = {} payload['raceName'] = os.path.basename(self.fileName or '')[:-4] - iTeam = ReportFields.index('Team') - payload['infoFields'] = ReportFields[:iTeam] + ['Name'] + ReportFields[iTeam:] + iMachine = ReportFields.index('Machine') + payload['infoFields'] = ReportFields[:iMachine] + ['Name'] + ReportFields[iMachine:] payload['organizer'] = getattr(race, 'organizer', '') payload['reverseDirection'] = getattr(race, 'reverseDirection', False) diff --git a/Properties.py b/Properties.py index aea8a5cd8..ea5c4a11f 100644 --- a/Properties.py +++ b/Properties.py @@ -825,13 +825,13 @@ def __init__( self, parent, id=wx.ID_ANY, testCallback=None, ftpCallback=None ): self.ftpCallback = ftpCallback if ftpCallback: - ftpBtn = wx.ToggleButton( self, label=_('Configure Ftp') ) + ftpBtn = wx.ToggleButton( self, label=_('Configure FTP') ) ftpBtn.Bind( wx.EVT_TOGGLEBUTTON, ftpCallback ) else: ftpBtn = None explain = [ - wx.StaticText(self,label=_('Choose File Formats to Publish. Select Ftp option to upload files to Ftp server.')), + wx.StaticText(self,label=_('Choose File Formats to Publish. Select FTP option to upload files to (S)FTP server.')), ] font = explain[0].GetFont() fontUnderline = wx.FFont( font.GetPointSize(), font.GetFamily(), flags=wx.FONTFLAG_BOLD ) @@ -839,7 +839,7 @@ def __init__( self, parent, id=wx.ID_ANY, testCallback=None, ftpCallback=None ): fgs = wx.FlexGridSizer( cols=4, rows=0, hgap=0, vgap=1 ) self.widget = [] - headers = [_('Format'), _('Ftp'), _('Note'), ''] + headers = [_('Format'), _('FTP'), _('Note'), ''] for h in headers: st = wx.StaticText(self, label=h) st.SetFont( fontUnderline ) @@ -1273,7 +1273,7 @@ def __init__( self, parent, id=wx.ID_ANY, addEditButton=True ): ('raceOptionsProperties', RaceOptionsProperties, _('Race Options') ), ('rfidProperties', RfidProperties, _('RFID') ), ('webProperties', WebProperties, _('Web') ), - ('ftpProperties', FtpProperties, _('FTP') ), + ('ftpProperties', FtpProperties, _('(S)FTP') ), ('batchPublishProperties', BatchPublishProperties, _('Batch Publish') ), ('gpxProperties', GPXProperties, _('GPX') ), ('notesProperties', NotesProperties, _('Notes') ), diff --git a/ReadSignOnSheet.py b/ReadSignOnSheet.py index 5b18d01f0..087277338 100644 --- a/ReadSignOnSheet.py +++ b/ReadSignOnSheet.py @@ -34,6 +34,7 @@ Fields = [ _('Bib#'), _('LastName'), _('FirstName'), + _('Machine'), _('Team'), _('City'), _('State'), _('Prov'), _('StateProv'), _('Nat.'), _('Category'), _('EventCategory'), _('Age'), _('Gender'), diff --git a/Results.py b/Results.py index 53ba84176..6b5d31c49 100644 --- a/Results.py +++ b/Results.py @@ -174,6 +174,7 @@ def __init__( self, parent, id = wx.ID_ANY ): self.labelGrid.DisableDragRowSize() # put a tooltip on the cells in a column self.labelGrid.GetGridWindow().Bind(wx.EVT_MOTION, self.onMouseOver) + # self.lapGrid = ColGrid.ColGrid( self.splitter, style=wx.BORDER_SUNKEN ) self.lapGrid.SetRowLabelSize( 0 ) @@ -195,6 +196,7 @@ def __init__( self, parent, id = wx.ID_ANY ): self.Bind( wx.grid.EVT_GRID_CELL_RIGHT_CLICK, self.doRightClick ) self.lapGrid.Bind( wx.grid.EVT_GRID_LABEL_LEFT_CLICK, self.doLabelClick ) self.labelGrid.Bind( wx.grid.EVT_GRID_LABEL_LEFT_CLICK, self.doLabelClick ) + self.labelGrid.Bind( wx.grid.EVT_GRID_LABEL_RIGHT_CLICK, self.onLabelColumnRightClick ) bs = wx.BoxSizer(wx.VERTICAL) #bs.Add(self.hbs) @@ -386,6 +388,31 @@ def doRightClick( self, event ): self.PopupMenu( menu ) except Exception as e: Utils.writeLog( 'Results:doRightClick: {}'.format(e) ) + + def onLabelColumnRightClick( self, event ): + # Create and display a popup menu of columns on right-click event + menu = wx.Menu() + menu.SetTitle( 'Show/Hide columns' ) + for c in range( self.labelGrid.GetNumberCols() ): + menuItem = menu.AppendCheckItem( wx.ID_ANY, self.labelGrid.GetColLabelValue( c ) ) + self.Bind( wx.EVT_MENU, self.onToggleLabelColumn ) + if self.labelGrid.IsColShown( c ): + menu.Check( menuItem.GetId(), True ) + self.PopupMenu(menu) + menu.Destroy() + + def onToggleLabelColumn( self, event ): + #find the column number + colLabels = [] + for c in range( self.labelGrid.GetNumberCols() ): + colLabels.append( self.labelGrid.GetColLabelValue( c ) ) + label = event.GetEventObject().FindItemById( event.GetId() ).GetItemLabel() + c = colLabels.index( label ) + with Model.LockRace() as race: + if self.labelGrid.IsColShown( c ): + self.labelGrid.HideCol( c ) + else: + self.labelGrid.ShowCol( c ) def OnPopupCorrect( self, event ): CorrectNumber( self, self.entry ) diff --git a/SendPhotoRequests.py b/SendPhotoRequests.py index 2794f904f..63417c0f3 100644 --- a/SendPhotoRequests.py +++ b/SendPhotoRequests.py @@ -41,7 +41,7 @@ def getRequest( race, dirName, bib, raceSeconds, externalInfo ): info['wave'] = category.fullname if category else '' try: riderInfo = externalInfo[bib] - for a, b in (('firstName', 'FirstName'), ('lastName','LastName'), ('team', 'Team')): + for a, b in (('firstName', 'FirstName'), ('lastName','LastName'), ('machine', 'Machine'), ('team', 'Team')): try: info[a] = riderInfo[b] except KeyError: diff --git a/SeriesMgr/Aliases.py b/SeriesMgr/Aliases.py index 8ab16b6bd..18999c941 100644 --- a/SeriesMgr/Aliases.py +++ b/SeriesMgr/Aliases.py @@ -1,9 +1,9 @@ import wx -import os -import sys -import SeriesModel -import Utils from AliasGrid import AliasGrid +from AliasesName import AliasesName +from AliasesLicense import AliasesLicense +from AliasesMachine import AliasesMachine +from AliasesTeam import AliasesTeam def normalizeText( text ): return ', '.join( [t.strip() for t in text.split(',')][:2] ) @@ -11,111 +11,60 @@ def normalizeText( text ): class Aliases(wx.Panel): def __init__(self, parent): wx.Panel.__init__(self, parent) + self.notebook = wx.Notebook( self ) + self.notebook.Bind( wx.EVT_NOTEBOOK_PAGE_CHANGED, self.onPageChanging ) - text = ( - 'Name Aliases match different name spellings to the same participant.\n' - 'This can be more convenient than editing race results when the same participant has resullts under different names.\n' - '\n' - 'To create a name Alias, first press the "Add Reference Name" button.\n' - 'The first column is the name that will appear in Results.' - 'Then, add Aliases in the next column separated by ";" (semicolons). These are the alternate spellings of the name.\n' - 'SeriesMgr will match all Aliases to the Reference Name in the Results.\n' - '\n' - 'For example, Reference Name="Bell, Robert", Aliases="Bell, Bobby; Bell, Bob". Results for the alternate spellings will appear as "Bell, Robert".\n' - 'Accents and upper/lower case are ignored.\n' - '\n' - 'You can Copy-and-Paste names from the Results without retyping them. Right-click and Copy the name in the Results page,' - 'then Paste the name into the Reference Name or Alias field.\n' - 'Aliases will not be applied until you press the "Refresh" button on the Results screen (or reload).\n' - 'This allows you to configure many Aliases without having to wait for the Results update after each change.\n' - ) + # Add all the pages to the notebook. + self.pages = [] + + def addPage( page, name ): + self.notebook.AddPage( page, name ) + self.pages.append( page ) + + self.attrClassName = [ + [ 'aliasesName', AliasesName, 'Name Aliases' ], + [ 'licenseAliases', AliasesLicense, 'License Aliases' ], + [ 'machineAliases', AliasesMachine, 'Machine Aliases' ], + [ 'teamAliases', AliasesTeam, 'Team Aliases' ], + ] - self.explain = wx.StaticText( self, label=text ) - self.explain.SetFont( wx.Font((0,15), wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False) ) + for i, (a, c, n) in enumerate(self.attrClassName): + setattr( self, a, c(self.notebook) ) + addPage( getattr(self, a), n ) - self.addButton = wx.Button( self, label='Add Reference Name' ) - self.addButton.Bind( wx.EVT_BUTTON, self.onAddButton ) + self.notebook.SetSelection( 0 ) - headerNames = ('Name (Last, First)','Aliases separated by ";"') - self.itemCur = None - self.grid = AliasGrid( self ) - self.grid.CreateGrid( 0, len(headerNames) ) - self.grid.SetRowLabelSize( 64 ) - for col in range(self.grid.GetNumberCols()): - self.grid.SetColLabelValue( col, headerNames[col] ) - - sizer = wx.BoxSizer(wx.VERTICAL) - sizer.Add(self.explain, 0, flag=wx.ALL, border=4 ) - sizer.Add(self.addButton, 0, flag=wx.ALL, border = 4) - sizer.Add(self.grid, 1, flag=wx.EXPAND|wx.ALL, border = 4) - self.SetSizer(sizer) - - def onAddButton( self, event ): - defaultText = '' + sizer = wx.BoxSizer( wx.VERTICAL ) + sizer.Add( self.notebook, 1, flag=wx.EXPAND ) + self.SetSizer( sizer ) - # Initialize the name from the clipboard. - if wx.TheClipboard.Open(): - do = wx.TextDataObject() - if wx.TheClipboard.GetData(do): - defaultText = do.GetText() - wx.TheClipboard.Close() - - self.grid.AppendRows( 1 ) - self.grid.SetCellValue( self.grid.GetNumberRows()-1, 0, defaultText ) - self.grid.AutoSize() - self.GetSizer().Layout() - self.grid.MakeCellVisible( self.grid.GetNumberRows()-1, 0 ) - - def getName( self, s ): - name = [t.strip() for t in s.split(',')[:2]] - if not name or not any(name): - return None - name.extend( [''] * (2 - len(name)) ) - return tuple( name ) + wx.CallAfter( self.Refresh ) + def refreshCurrentPage( self ): + self.callPageRefresh( self.notebook.GetSelection() ) + def refresh( self ): - model = SeriesModel.model + self.refreshCurrentPage() + + def callPageRefresh( self, i ): + try: + self.pages[i].refresh() + except (AttributeError, IndexError) as e: + pass - Utils.AdjustGridSize( self.grid, rowsRequired=len(model.references) ) - for row, (reference, aliases) in enumerate(model.references): - self.grid.SetCellValue( row, 0, '{}, {}'.format(*reference) ) - self.grid.SetCellValue( row, 1, '; '.join('{}, {}'.format(*a) for a in aliases) ) - - self.grid.AutoSize() - self.GetSizer().Layout() - def commit( self ): - references = [] + self.callPageCommit( self.notebook.GetSelection() ) - self.grid.SaveEditControlValue() - for row in range(self.grid.GetNumberRows()): - reference = self.getName( self.grid.GetCellValue( row, 0 ) ) - if reference: - aliases = [a.strip() for a in self.grid.GetCellValue(row, 1).split(';')] - aliases = [self.getName(a) for a in aliases if a] - aliases = [a for a in aliases if a] - references.append( (reference, aliases) ) + def callPageCommit( self, i ): + try: + self.pages[i].commit() + except (AttributeError, IndexError) as e: + pass - references.sort() + def onPageChanging( self, event ): + self.callPageCommit( event.GetOldSelection() ) + self.callPageRefresh( event.GetSelection() ) + event.Skip() # Required to properly repaint the screen. - model = SeriesModel.model - model.setReferences( references ) - -#---------------------------------------------------------------------------- - -class AliasesFrame(wx.Frame): - def __init__(self): - wx.Frame.__init__(self, None, title="Aliases Test", size=(800,600) ) - self.panel = Aliases(self) - self.Show() - -if __name__ == "__main__": - app = wx.App(False) - model = SeriesModel.model - model.setReferences( [ - [('Bell', 'Robert'), [('Bell', 'Bobby'), ('Bell', 'Bob'), ('Bell', 'B')]], - [('Sitarski', 'Stephen'), [('Sitarski', 'Steve'), ('Sitarski', 'Steven')]], - ] ) - frame = AliasesFrame() - frame.panel.refresh() - app.MainLoop() + def getGrid( self ): + return self.pages[self.notebook.GetSelection()].getGrid() diff --git a/SeriesMgr/AliasesLicense.py b/SeriesMgr/AliasesLicense.py index 0ac9c2d4a..bdd958eb5 100644 --- a/SeriesMgr/AliasesLicense.py +++ b/SeriesMgr/AliasesLicense.py @@ -88,6 +88,8 @@ def commit( self ): model = SeriesModel.model model.setReferenceLicenses( references ) + def getGrid( self ): + return self.grid #---------------------------------------------------------------------------- class AliasesLicenseFrame(wx.Frame): diff --git a/SeriesMgr/AliasesMachine.py b/SeriesMgr/AliasesMachine.py new file mode 100644 index 000000000..8dfaacccf --- /dev/null +++ b/SeriesMgr/AliasesMachine.py @@ -0,0 +1,110 @@ +import wx +import os +import sys +import SeriesModel +import Utils +from AliasGrid import AliasGrid + +class AliasesMachine(wx.Panel): + def __init__(self, parent): + wx.Panel.__init__(self, parent) + + text = ( + 'Machine Aliases match alternate machine names to the same machine.\n' + 'This can be more convenient than editing race results when the same machine appears with a different spelling.\n' + '\n' + 'To create a machine Alias, first press the "Add Reference Machine" button.\n' + 'The first column is the Machine that will appear in Results.\n' + 'The second column are the Machine Aliases, separated by ";". These are the alternate machine names.\n' + 'SeriesMgr will match all aliased Machines to the Reference Machine in the Results.\n' + '\n' + 'For example, Reference Machine="ICE Trike", AliasesMachine="Trice; ICE". Results for the alternate Machines will appear as "ICE Trike".\n' + '\n' + 'You can Copy-and-Paste Machines from the Results without retyping them. Right-click and Copy the name in the Results page,' + 'then Paste the Machine into a Reference Machine or Alias field.\n' + 'Aliased Machines will not be applied until you press the "Refresh" button on the Results screen (or reload).\n' + 'This allows you to configure many Machines without having to wait for the Results update after each change.\n' + ) + + self.explain = wx.StaticText( self, label=text ) + self.explain.SetFont( wx.Font((0,15), wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False) ) + + self.addButton = wx.Button( self, label='Add Reference Machine' ) + self.addButton.Bind( wx.EVT_BUTTON, self.onAddButton ) + + headerNames = ('Machine','Aliases separated by ";"') + self.itemCur = None + self.grid = AliasGrid( self ) + self.grid.CreateGrid( 0, len(headerNames) ) + self.grid.SetRowLabelSize( 64 ) + for col in range(self.grid.GetNumberCols()): + self.grid.SetColLabelValue( col, headerNames[col] ) + + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self.explain, 0, flag=wx.ALL, border=4 ) + sizer.Add(self.addButton, 0, flag=wx.ALL, border = 4) + sizer.Add(self.grid, 1, flag=wx.EXPAND|wx.ALL, border = 4) + self.SetSizer(sizer) + + def onAddButton( self, event ): + defaultText = '' + + # Initialize the team from the clipboard. + if wx.TheClipboard.Open(): + do = wx.TextDataObject() + if wx.TheClipboard.GetData(do): + defaultText = do.GetText() + wx.TheClipboard.Close() + + self.grid.AppendRows( 1 ) + self.grid.SetCellValue( self.grid.GetNumberRows()-1, 0, defaultText ) + self.grid.AutoSize() + self.GetSizer().Layout() + self.grid.MakeCellVisible( self.grid.GetNumberRows()-1, 0 ) + + def refresh( self ): + model = SeriesModel.model + + Utils.AdjustGridSize( self.grid, rowsRequired=len(model.referenceMachines) ) + for row, (reference, aliases) in enumerate(model.referenceMachines): + self.grid.SetCellValue( row, 0, reference ) + self.grid.SetCellValue( row, 1, '; '.join(aliases) ) + + self.grid.AutoSize() + self.GetSizer().Layout() + + def commit( self ): + references = [] + + self.grid.SaveEditControlValue() + for row in range(self.grid.GetNumberRows()): + reference = self.grid.GetCellValue( row, 0 ).strip() + if reference: + aliases = [a.strip() for a in self.grid.GetCellValue(row, 1).split(';')] + references.append( (reference, sorted( a for a in aliases if a )) ) + + references.sort() + + model = SeriesModel.model + model.setReferenceMachines( references ) + + def getGrid( self ): + return self.grid +#---------------------------------------------------------------------------- + +class AliasesMachineFrame(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="Machine Aliases Test", size=(800,600) ) + self.panel = AliasesMachine(self) + self.Show() + +if __name__ == "__main__": + app = wx.App(False) + model = SeriesModel.model + model.setReferenceMachines( [ + ['ICE Trike', ['ICE', 'I.C.E.', 'Trice']], + ['Windcheetah', ['Windy', 'Speedy']], + ] ) + frame = AliasesMachineFrame() + frame.panel.refresh() + app.MainLoop() diff --git a/SeriesMgr/AliasesName.py b/SeriesMgr/AliasesName.py new file mode 100644 index 000000000..9bfee8e8e --- /dev/null +++ b/SeriesMgr/AliasesName.py @@ -0,0 +1,123 @@ +import wx +import os +import sys +import SeriesModel +import Utils +from AliasGrid import AliasGrid + +def normalizeText( text ): + return ', '.join( [t.strip() for t in text.split(',')][:2] ) + +class AliasesName(wx.Panel): + def __init__(self, parent): + wx.Panel.__init__(self, parent) + + text = ( + 'Name Aliases match different name spellings to the same participant.\n' + 'This can be more convenient than editing race results when the same participant has resullts under different names.\n' + '\n' + 'To create a name Alias, first press the "Add Reference Name" button.\n' + 'The first column is the name that will appear in Results.' + 'Then, add Aliases in the next column separated by ";" (semicolons). These are the alternate spellings of the name.\n' + 'SeriesMgr will match all Aliases to the Reference Name in the Results.\n' + '\n' + 'For example, Reference Name="Bell, Robert", Aliases="Bell, Bobby; Bell, Bob". Results for the alternate spellings will appear as "Bell, Robert".\n' + 'Accents and upper/lower case are ignored.\n' + '\n' + 'You can Copy-and-Paste names from the Results without retyping them. Right-click and Copy the name in the Results page,' + 'then Paste the name into the Reference Name or Alias field.\n' + 'Aliases will not be applied until you press the "Refresh" button on the Results screen (or reload).\n' + 'This allows you to configure many Aliases without having to wait for the Results update after each change.\n' + ) + + self.explain = wx.StaticText( self, label=text ) + self.explain.SetFont( wx.Font((0,15), wx.FONTFAMILY_SWISS, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL, False) ) + + self.addButton = wx.Button( self, label='Add Reference Name' ) + self.addButton.Bind( wx.EVT_BUTTON, self.onAddButton ) + + headerNames = ('Name (Last, First)','Aliases separated by ";"') + self.itemCur = None + self.grid = AliasGrid( self ) + self.grid.CreateGrid( 0, len(headerNames) ) + self.grid.SetRowLabelSize( 64 ) + for col in range(self.grid.GetNumberCols()): + self.grid.SetColLabelValue( col, headerNames[col] ) + + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self.explain, 0, flag=wx.ALL, border=4 ) + sizer.Add(self.addButton, 0, flag=wx.ALL, border = 4) + sizer.Add(self.grid, 1, flag=wx.EXPAND|wx.ALL, border = 4) + self.SetSizer(sizer) + + def onAddButton( self, event ): + defaultText = '' + + # Initialize the name from the clipboard. + if wx.TheClipboard.Open(): + do = wx.TextDataObject() + if wx.TheClipboard.GetData(do): + defaultText = do.GetText() + wx.TheClipboard.Close() + + self.grid.AppendRows( 1 ) + self.grid.SetCellValue( self.grid.GetNumberRows()-1, 0, defaultText ) + self.grid.AutoSize() + self.GetSizer().Layout() + self.grid.MakeCellVisible( self.grid.GetNumberRows()-1, 0 ) + + def getName( self, s ): + name = [t.strip() for t in s.split(',')[:2]] + if not name or not any(name): + return None + name.extend( [''] * (2 - len(name)) ) + return tuple( name ) + + def refresh( self ): + model = SeriesModel.model + + Utils.AdjustGridSize( self.grid, rowsRequired=len(model.references) ) + for row, (reference, aliases) in enumerate(model.references): + self.grid.SetCellValue( row, 0, '{}, {}'.format(*reference) ) + self.grid.SetCellValue( row, 1, '; '.join('{}, {}'.format(*a) for a in aliases) ) + + self.grid.AutoSize() + self.GetSizer().Layout() + + def commit( self ): + references = [] + + self.grid.SaveEditControlValue() + for row in range(self.grid.GetNumberRows()): + reference = self.getName( self.grid.GetCellValue( row, 0 ) ) + if reference: + aliases = [a.strip() for a in self.grid.GetCellValue(row, 1).split(';')] + aliases = [self.getName(a) for a in aliases if a] + aliases = [a for a in aliases if a] + references.append( (reference, aliases) ) + + references.sort() + + model = SeriesModel.model + model.setReferences( references ) + + def getGrid( self ): + return self.grid +#---------------------------------------------------------------------------- + +class AliasesNameFrame(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="Name Aliases Test", size=(800,600) ) + self.panel = AliasesName(self) + self.Show() + +if __name__ == "__main__": + app = wx.App(False) + model = SeriesModel.model + model.setReferences( [ + [('Bell', 'Robert'), [('Bell', 'Bobby'), ('Bell', 'Bob'), ('Bell', 'B')]], + [('Sitarski', 'Stephen'), [('Sitarski', 'Steve'), ('Sitarski', 'Steven')]], + ] ) + frame = AliasesNameFrame() + frame.panel.refresh() + app.MainLoop() diff --git a/SeriesMgr/AliasesTeam.py b/SeriesMgr/AliasesTeam.py index 553fa7f11..9b7cccb52 100644 --- a/SeriesMgr/AliasesTeam.py +++ b/SeriesMgr/AliasesTeam.py @@ -88,6 +88,9 @@ def commit( self ): model = SeriesModel.model model.setReferenceTeams( references ) + def getGrid( self ): + return self.grid + #---------------------------------------------------------------------------- class AliasesTeamFrame(wx.Frame): diff --git a/SeriesMgr/ExportGrid.py b/SeriesMgr/ExportGrid.py index 48c2416d1..bf2d92c48 100644 --- a/SeriesMgr/ExportGrid.py +++ b/SeriesMgr/ExportGrid.py @@ -51,8 +51,8 @@ class ExportGrid: def __init__( self, title, grid ): self.title = title self.grid = grid - self.colnames = [grid.GetColLabelValue(c) for c in range(grid.GetNumberCols())] - self.data = [ [grid.GetCellValue(r, c) for r in range(grid.GetNumberRows())] for c in range(grid.GetNumberCols()) ] + self.colnames = [grid.GetColLabelValue(c) for c in range(grid.GetNumberCols()) if grid.IsColShown(c)] + self.data = [ [grid.GetCellValue(r, c) for r in range(grid.GetNumberRows())] for c in range(grid.GetNumberCols()) if grid.IsColShown(c)] self.fontName = 'Helvetica' self.fontSize = 16 diff --git a/SeriesMgr/FieldMap.py b/SeriesMgr/FieldMap.py index e85fc03aa..489c2d58f 100644 --- a/SeriesMgr/FieldMap.py +++ b/SeriesMgr/FieldMap.py @@ -112,6 +112,10 @@ def get_name_from_alias( self, alias ): ('Team','Team Name','TeamName','Rider Team','Club','Club Name','ClubName','Rider Club','Rider Club/Team',), "Team", ), + ('machine', + ('Machine','Machine Name','MachineName','Rider Machine','Cycle','Cycle Name','CycleName','Rider Cycle','Bike','Bike Name','BikeName','Rider Bike','Vehicle','Vehicle Name','VehicleName','Rider Vehicle','HPV','Car','Velo','Velomobile','Wheelchair','Fiets','Rad'), + "Machine", + ), ('discipline', ('Discipline',), "Discipline", diff --git a/SeriesMgr/FileTrie.py b/SeriesMgr/FileTrie.py index 1007d4253..eedc3ba24 100644 --- a/SeriesMgr/FileTrie.py +++ b/SeriesMgr/FileTrie.py @@ -42,7 +42,7 @@ def add( self, p ): if c not in node: node[c] = {} node = node[c] - + def best_match( self, p ): path = [] node = self.node @@ -65,14 +65,17 @@ def best_match( self, p ): path.reverse() if re.match( '^[a-zA-Z]:$', path[0] ): path[0] += '\\' - + # *nix paths should start with a '/' + if path[0] == '': + path[0] = '/' return os.path.join( *path ) if __name__ == '__main__': ft = FileTrie() for i in range(5): ft.add( r'c:\Projects\CrossMgr\SeriesMgr\test{}'.format(i) ) - ft.add( r'c:\Projects\CrossMgr\CrossMgrImpinj\test{}'.format(0) ) + ft.add( r'c:\Projects\CrossMgr\CrossMgrImpinj\test{}'.format(0) ) + ft.add( r'/home/Projects/CrossMgr/CrossMgrImpinj/test{}'.format(0) ) print( ft.best_match( '/home/Projects/CrossMgr/SeriesMgr/test2' ) ) print( ft.best_match( '/home/Projects/CrossMgr/CrossMgrImpinj/test0' ) ) print( ft.best_match( 'test4' ) ) diff --git a/SeriesMgr/FtpWriteFile.py b/SeriesMgr/FtpWriteFile.py index a592bdbbf..c60f0a693 100644 --- a/SeriesMgr/FtpWriteFile.py +++ b/SeriesMgr/FtpWriteFile.py @@ -3,6 +3,7 @@ import os import sys import ftplib +import paramiko import datetime import threading import webbrowser @@ -13,43 +14,110 @@ def lineno(): """Returns the current line number in our program.""" return inspect.currentframe().f_back.f_lineno - -def FtpWriteFile( host, user = 'anonymous', passwd = 'anonymous@', timeout = 30, serverPath = '.', fileName = '', file = None ): - ftp = ftplib.FTP( host, timeout = timeout ) - ftp.login( user, passwd ) - if serverPath and serverPath != '.': - ftp.cwd( serverPath ) - fileOpened = False - if file is None: - file = open(fileName, 'rb') - fileOpened = True - ftp.storbinary( 'STOR {}'.format(os.path.basename(fileName)), file ) - ftp.quit() - if fileOpened: - file.close() -def FtpWriteHtml( html_in ): +class CallCloseOnExit: + def __init__(self, obj): + self.obj = obj + def __enter__(self): + return self.obj + def __exit__(self, exc_type, exc_val, exc_tb): + self.obj.close() + +class FtpWithPort(ftplib.FTP): + def __init__(self, host, user, passwd, port, timeout): + #Act like ftplib.FTP's constructor but connect to another port. + ftplib.FTP.__init__(self) + #self.set_debuglevel(2) + self.connect(host, port, timeout) + self.login(user, passwd) + +class FtpsWithPort(ftplib.FTP_TLS): + def __init__(self, host, user, passwd, port, timeout): + ftplib.FTP_TLS.__init__(self) + #self.set_debuglevel(2) + self.connect(host, port, timeout) + self.auth() + self.login(user, passwd) + #Switch to secure data connection. + self.prot_p() + + def ntransfercmd(self, cmd, rest=None): + conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) + if self._prot_p: + conn = self.context.wrap_socket(conn, + server_hostname=self.host, + session=self.sock.session) # reuse ssl session + return conn, size + +def FtpWriteFile( host, port, user = 'anonymous', passwd = 'anonymous@', timeout = 30, serverPath = '.', fileName = '', file = None, protocol='FTP'): + if protocol == 'SFTP': + with CallCloseOnExit(paramiko.SSHClient()) as ssh: + ssh.set_missing_host_key_policy( paramiko.AutoAddPolicy() ) + ssh.load_system_host_keys() + ssh.connect( host, port, user, passwd ) + + with CallCloseOnExit(ssh.open_sftp()) as sftp: + fileOpened = False + if file is None: + file = open(fileName, 'rb') + fileOpened = True + sftp.putfo( + file, + serverPath + os.path.basename(fileName) + ) + if fileOpened: + file.close() + elif protocol == 'FTPS': + ftps = FtpsWithPort( host, user, passwd, port, timeout) + if serverPath and serverPath != '.': + ftps.cwd( serverPath ) + fileOpened = False + if file is None: + file = open(fileName, 'rb') + fileOpened = True + ftps.storbinary( 'STOR {}'.format(os.path.basename(fileName)), file ) + ftps.quit() + if fileOpened: + file.close() + else: #default to unencrypted FTP + ftp = FtpWithPort( host, user, passwd, port, timeout) + if serverPath and serverPath != '.': + ftp.cwd( serverPath ) + fileOpened = False + if file is None: + file = open(fileName, 'rb') + fileOpened = True + ftp.storbinary( 'STOR {}'.format(os.path.basename(fileName)), file ) + ftp.quit() + if fileOpened: + file.close() + +def FtpWriteHtml( html_in, team = False ): Utils.writeLog( 'FtpWriteHtml: called.' ) modelFileName = Utils.getFileName() if Utils.getFileName() else 'Test.smn' - fileName = os.path.basename( os.path.splitext(modelFileName)[0] + '.html' ) + fileName = os.path.basename( os.path.splitext(modelFileName)[0] + ('-Team.html' if team else '.html') ) defaultPath = os.path.dirname( modelFileName ) with open(os.path.join(defaultPath, fileName), 'w') as fp: fp.write( html_in ) model = SeriesModel.model host = getattr( model, 'ftpHost', '' ) + port = getattr( model, 'ftpPort', 21 ) user = getattr( model, 'ftpUser', '' ) passwd = getattr( model, 'ftpPassword', '' ) serverPath = getattr( model, 'ftpPath', '' ) + protocol = getattr( model, 'ftpProtocol', 'FTP' ) with open( os.path.join(defaultPath, fileName), 'rb') as file: try: FtpWriteFile( host = host, + port = port, user = user, passwd = passwd, serverPath = serverPath, fileName = fileName, - file = file ) + file = file, + protocol = protocol) except Exception as e: Utils.writeLog( 'FtpWriteHtml Error: {}'.format(e) ) return e @@ -59,17 +127,26 @@ def FtpWriteHtml( html_in ): #------------------------------------------------------------------------------------------------ class FtpPublishDialog( wx.Dialog ): - fields = ['ftpHost', 'ftpPath', 'ftpUser', 'ftpPassword', 'urlPath'] - defaults = ['', '', 'anonymous', 'anonymous@' 'http://'] + fields = ['ftpHost', 'ftpPort', 'ftpPath', 'ftpUser', 'ftpPassword', 'urlPath'] + defaults = ['', 21, '', 'anonymous', 'anonymous@', 'http://'] + team = False - def __init__( self, parent, html, id = wx.ID_ANY ): - super().__init__( parent, id, "Ftp Publish Results", + def __init__( self, parent, html, team = False, id = wx.ID_ANY ): + super().__init__( parent, id, "(S)FTP Publish Results", style=wx.DEFAULT_DIALOG_STYLE|wx.TAB_TRAVERSAL ) self.html = html + self.team = team + self.protocol = 'FTP' + bs = wx.GridBagSizer(vgap=0, hgap=4) + self.useFtp = wx.RadioButton( self, label=_("FTP (unencrypted)"), style = wx.RB_GROUP ) + self.useFtps = wx.RadioButton( self, label=_("FTPS (FTP with TLS)") ) + self.useSftp = wx.RadioButton( self, label=_("SFTP (SSH file transfer)") ) + self.Bind( wx.EVT_RADIOBUTTON,self.onSelectProtocol ) self.ftpHost = wx.TextCtrl( self, size=(256,-1), style=wx.TE_PROCESS_ENTER, value='' ) + self.ftpPort = wx.lib.intctrl.IntCtrl( self, size=(256,-1), style=wx.TE_PROCESS_ENTER ) self.ftpPath = wx.TextCtrl( self, size=(256,-1), style=wx.TE_PROCESS_ENTER, value='' ) self.ftpUser = wx.TextCtrl( self, size=(256,-1), style=wx.TE_PROCESS_ENTER, value='' ) self.ftpPassword = wx.TextCtrl( self, size=(256,-1), style=wx.TE_PROCESS_ENTER|wx.TE_PASSWORD, value='' ) @@ -81,10 +158,32 @@ def __init__( self, parent, html, id = wx.ID_ANY ): row = 0 border = 8 - bs.Add( wx.StaticText( self, label=_("Ftp Host Name:")), pos=(row,0), span=(1,1), border = border, + + bs.Add( wx.StaticText( self, label=_("Protocol:")), pos=(row,0), span=(1,1), border = border, + flag=wx.LEFT|wx.TOP|wx.ALIGN_RIGHT|wx.ALIGN_CENTRE_VERTICAL ) + bs.Add( self.useFtp, pos=(row,1), span=(1,1), border = border, flag=wx.RIGHT|wx.TOP|wx.ALIGN_LEFT ) + + row += 1 + + bs.Add( self.useFtps, pos=(row,1), span=(1,1), border = border, flag=wx.RIGHT|wx.TOP|wx.ALIGN_LEFT ) + + row += 1 + + bs.Add( self.useSftp, pos=(row,1), span=(1,1), border = border, flag=wx.RIGHT|wx.TOP|wx.ALIGN_LEFT ) + + row += 1 + + bs.Add( wx.StaticText( self, label=_("Host Name:")), pos=(row,0), span=(1,1), border = border, flag=wx.LEFT|wx.TOP|wx.ALIGN_RIGHT|wx.ALIGN_CENTRE_VERTICAL ) bs.Add( self.ftpHost, pos=(row,1), span=(1,1), border = border, flag=wx.RIGHT|wx.TOP|wx.ALIGN_LEFT ) + + row += 1 + + bs.Add( wx.StaticText( self, label=_("Port:")), pos=(row,0), span=(1,1), border = border, + flag=wx.LEFT|wx.TOP|wx.ALIGN_RIGHT|wx.ALIGN_CENTRE_VERTICAL ) + bs.Add( self.ftpPort, pos=(row,1), span=(1,1), border = border, flag=wx.RIGHT|wx.TOP|wx.ALIGN_LEFT ) + row += 1 bs.Add( wx.StaticText( self, label=_("Path on Host to Write HTML:")), pos=(row,0), span=(1,1), border = border, flag=wx.LEFT|wx.TOP|wx.ALIGN_RIGHT|wx.ALIGN_CENTRE_VERTICAL ) @@ -122,6 +221,17 @@ def __init__( self, parent, html, id = wx.ID_ANY ): self.CentreOnParent(wx.BOTH) self.SetFocus() + def onSelectProtocol( self, event ): + if self.useSftp.GetValue(): + self.protocol = 'SFTP' + self.ftpPort.SetValue(22) + elif self.useFtps.GetValue(): + self.protocol = 'FTPS' + self.ftpPort.SetValue(21) + else: + self.protocol = 'FTP' + self.ftpPort.SetValue(21) + def urlPathChanged( self, event = None ): url = self.urlPath.GetValue() fileName = Utils.getFileName() @@ -130,7 +240,7 @@ def urlPathChanged( self, event = None ): else: if not url.endswith( '/' ): url += '/' - fileName = os.path.basename( os.path.splitext(fileName)[0] + '.html' ) + fileName = os.path.basename( os.path.splitext(fileName)[0] + ( '-Team.html' if self.team else '.html') ) url += fileName self.urlFull.SetLabel( url ) @@ -142,6 +252,7 @@ def refresh( self ): else: for f, v in zip(FtpPublishDialog.fields, FtpPublishDialog.defaults): getattr(self, f).SetValue( getattr(model, f, v) ) + self.protocol = getattr(model, 'ftpProtocol', '') self.urlPathChanged() def setModelAttr( self ): @@ -149,14 +260,17 @@ def setModelAttr( self ): model = SeriesModel.model for f in FtpPublishDialog.fields: value = getattr(self, f).GetValue() - if getattr(model, f, None) != value: + if getattr( model, f, None ) != value: setattr( model, f, value ) model.setChanged() + if getattr( model, 'ftpProtocol', None ) != self.protocol: + setattr( model, 'ftpProtocol', self.protocol) + model.setChanged() model.urlFull = self.urlFull.GetLabel() def onOK( self, event ): self.setModelAttr() - e = FtpWriteHtml( self.html ) + e = FtpWriteHtml( self.html, self.team ) if e: Utils.MessageOK( self, 'FTP Publish: {}'.format(e), 'FTP Publish Error' ) else: diff --git a/SeriesMgr/GetModelInfo.py b/SeriesMgr/GetModelInfo.py index 3db617aaf..4b314dc13 100644 --- a/SeriesMgr/GetModelInfo.py +++ b/SeriesMgr/GetModelInfo.py @@ -4,7 +4,7 @@ import datetime import operator import itertools -from collections import defaultdict, namedtuple +from collections import defaultdict, namedtuple, Counter import trueskill @@ -75,7 +75,7 @@ def safe_upper( f ): return f class RaceResult: - def __init__( self, firstName, lastName, license, team, categoryName, raceName, raceDate, raceFileName, bib, rank, raceOrganizer, + def __init__( self, firstName, lastName, license, machine, team, categoryName, raceName, raceDate, raceFileName, bib, rank, raceOrganizer, raceURL=None, raceInSeries=None, tFinish=None, tProjected=None, primePoints=0, timeBonus=0, laps=1, pointsInput=None ): self.firstName = str(firstName or '') self.lastName = str(lastName or '') @@ -85,6 +85,8 @@ def __init__( self, firstName, lastName, license, team, categoryName, raceName, self.license = int(self.license) self.license = str(self.license) + self.machine = str(machine or '') + self.team = str(team or '') self.categoryName = str(categoryName or '') @@ -108,6 +110,7 @@ def __init__( self, firstName, lastName, license, team, categoryName, raceName, self.upgradeFactor = 1 self.upgradeResult = False + @property def teamIsValid( self ): @@ -136,7 +139,7 @@ def full_name( self ): return ', '.join( [name for name in [self.lastName.upper(), self.firstName] if name] ) def __repr__( self ): - return ', '.join( '{}'.format(p) for p in [self.full_name, self.license, self.categoryName, self.raceName, self.raceDate] if p ) + return ', '.join( '{}'.format(p) for p in [self.full_name, self.license, self.machine, self.categoryName, self.raceName, self.raceDate] if p ) def ExtractRaceResults( r ): if os.path.splitext(r.fileName)[1] == '.cmn': @@ -155,6 +158,7 @@ def toInt( n ): def ExtractRaceResultsExcel( raceInSeries ): getReferenceName = SeriesModel.model.getReferenceName getReferenceLicense = SeriesModel.model.getReferenceLicense + getReferenceMachine = SeriesModel.model.getReferenceMachine getReferenceTeam = SeriesModel.model.getReferenceTeam excel = GetExcelReader( raceInSeries.getFileName() ) @@ -185,6 +189,7 @@ def ExtractRaceResultsExcel( raceInSeries ): 'firstName': str(f('first_name','')).strip(), 'lastName' : str(f('last_name','')).strip(), 'license': str(f('license_code','')).strip(), + 'machine': str(f('machine','')).strip(), 'team': str(f('team','')).strip(), 'categoryName': f('category_code',None), 'laps': f('laps',1), @@ -229,13 +234,19 @@ def ExtractRaceResultsExcel( raceInSeries ): try: info['lastName'], info['firstName'] = name.split(',',1) except: - pass + # Failing that, split on the last space character. + try: + info['firstName'], info['lastName'] = name.rsplit(' ',1) + except: + # If there are no spaces to split on, treat the entire name as lastName. This is fine. + info['lastName'] = name if not info['firstName'] and not info['lastName']: continue info['lastName'], info['firstName'] = getReferenceName(info['lastName'], info['firstName']) info['license'] = getReferenceLicense(info['license']) + info['machine'] = getReferenceMachine(info['machine']) info['team'] = getReferenceTeam(info['team']) # If there is a bib it must be numeric. @@ -266,7 +277,6 @@ def ExtractRaceResultsExcel( raceInSeries ): # Check if this is a team-only sheet. raceInSeries.pureTeam = ('team' in fm and not any(n in fm for n in ('name', 'last_name', 'first_name', 'license'))) raceInSeries.resultsType = SeriesModel.Race.TeamResultsOnly if raceInSeries.pureTeam else SeriesModel.Race.IndividualAndTeamResults - return True, 'success', raceResults def FixExcelSheetLocal( fileName, race ): @@ -304,6 +314,7 @@ def ExtractRaceResultsCrossMgr( raceInSeries ): getReferenceName = SeriesModel.model.getReferenceName getReferenceLicense = SeriesModel.model.getReferenceLicense + getReferenceMachine = SeriesModel.model.getReferenceMachine getReferenceTeam = SeriesModel.model.getReferenceTeam Finisher = Model.Rider.Finisher @@ -333,7 +344,7 @@ def ExtractRaceResultsCrossMgr( raceInSeries ): 'raceURL': raceURL, 'raceInSeries': raceInSeries, } - for fTo, fFrom in [('firstName', 'FirstName'), ('lastName', 'LastName'), ('license', 'License'), ('team', 'Team')]: + for fTo, fFrom in [('firstName', 'FirstName'), ('lastName', 'LastName'), ('license', 'License'), ('machine', 'Machine'), ('team', 'Team')]: info[fTo] = getattr(rr, fFrom, '') if not info['firstName'] and not info['lastName']: @@ -342,6 +353,7 @@ def ExtractRaceResultsCrossMgr( raceInSeries ): info['categoryName'] = category.fullname info['lastName'], info['firstName'] = getReferenceName(info['lastName'], info['firstName']) info['license'] = getReferenceLicense(info['license']) + info['machine'] = getReferenceMachine(info['machine']) info['team'] = getReferenceTeam(info['team']) info['laps'] = rr.laps @@ -470,6 +482,8 @@ def GetCategoryResults( categoryName, raceResults, pointsForRank, useMostEventsC riderTeam = defaultdict( lambda : '' ) riderUpgrades = defaultdict( lambda : [False] * len(races) ) riderNameLicense = {} + riderMachines = defaultdict( lambda : [''] * len(races) ) + def asInt( v ): return int(v) if int(v) == v else v @@ -485,7 +499,27 @@ def FixUpgradeFormat( riderUpgrades, riderResults ): v = riderResults[rider][i] riderResults[rider][i] = tuple([upgradeFormat.format(v[0] if v[0] else '')] + list(v[1:])) +<<<<<<< HEAD + def TidyMachinesList (riderMachines): + # Format list of unique machines used by each rider in frequency order + for rider, machines in riderMachines.items(): + #remove Nones and empty strings + while('None' in machines): + machines.remove('None') + while('' in machines): + machines.remove('') + #sort by frequency + counts = Counter(machines) + machines = sorted(machines, key=counts.get, reverse=True) + #remove duplicates + machines = list(dict.fromkeys(machines)) + #overwrite + riderMachines[rider] = machines + + riderResults = defaultdict( lambda : [(0,999999,0,0)] * len(races) ) # (points, rr.rank, primePoints, 0) for each result. default rank 999999 for missing result. +======= riderResults = defaultdict( lambda : [(0,SeriesModel.rankDidNotParticipate,0,0)] * len(races) ) # (points, rr.rank, primePoints, 0) for each result. +>>>>>>> 3db22bd07cf085fdbb096acced6cfee6bc63c48b riderFinishes = defaultdict( lambda : [None] * len(races) ) if scoreByTime: @@ -509,6 +543,8 @@ def FixUpgradeFormat( riderUpgrades, riderResults ): continue rider = rr.key() riderNameLicense[rider] = (rr.full_name, rr.license) + if rr.machine and rr.machine != '0': + riderMachines[rider][raceSequence[rr.raceInSeries]] = rr.machine if rr.team and rr.team != '0': riderTeam[rider] = rr.team riderResults[rider][raceSequence[rr.raceInSeries]] = ( @@ -519,7 +555,7 @@ def FixUpgradeFormat( riderUpgrades, riderResults ): riderUpgrades[rider][raceSequence[rr.raceInSeries]] = rr.upgradeResult riderPlaceCount[rider][(raceGrade[rr.raceFileName],rr.rank)] += 1 riderEventsCompleted[rider] += 1 - + # Adjust for the best times. if bestResultsToConsider > 0: for rider, finishes in riderFinishes.items(): @@ -546,10 +582,13 @@ def FixUpgradeFormat( riderUpgrades, riderResults ): leaderEventsCompleted = riderEventsCompleted[leader] riderGap = { r : riderTFinish[r] - leaderTFinish if riderEventsCompleted[r] == leaderEventsCompleted else None for r in riderOrder } riderGap = { r : formatTimeGap(gap) if gap else '' for r, gap in riderGap.items() } + + # Tidy up the machines list for display + TidyMachinesList( riderMachines ) # List of: - # lastName, firstName, license, team, tTotalFinish, [list of (points, position) for each race in series] - categoryResult = [list(riderNameLicense[rider]) + [riderTeam[rider], formatTime(riderTFinish[rider],True), riderGap[rider]] + [riderResults[rider]] for rider in riderOrder] + # lastName, firstName, license, [list of machines], team, tTotalFinish, [list of (points, position) for each race in series] + categoryResult = [list(riderNameLicense[rider]) + [riderMachines[rider], riderTeam[rider], formatTime(riderTFinish[rider],True), riderGap[rider]] + [riderResults[rider]] for rider in riderOrder] return categoryResult, races, GetPotentialDuplicateFullNames(riderNameLicense) elif scoreByPercent: @@ -576,6 +615,8 @@ def FixUpgradeFormat( riderUpgrades, riderResults ): continue rider = rr.key() riderNameLicense[rider] = (rr.full_name, rr.license) + if rr.machine and rr.machine != '0': + riderMachines[rider][raceSequence[rr.raceInSeries]] = rr.machine if rr.team and rr.team != '0': riderTeam[rider] = rr.team percent = min( 100.0, (tFastest / tFinish) * 100.0 if tFinish > 0.0 else 0.0 ) * (rr.upgradeFactor if rr.upgradeResult else 1) @@ -612,10 +653,13 @@ def FixUpgradeFormat( riderUpgrades, riderResults ): leaderPercentTotal = riderPercentTotal[leader] riderGap = { r : leaderPercentTotal - riderPercentTotal[r] for r in riderOrder } riderGap = { r : percentFormat.format(gap) if gap else '' for r, gap in riderGap.items() } - + + # Tidy up the machines list for display + TidyMachinesList( riderMachines ) + # List of: - # lastName, firstName, license, team, totalPercent, [list of (percent, position) for each race in series] - categoryResult = [list(riderNameLicense[rider]) + [riderTeam[rider], percentFormat.format(riderPercentTotal[rider]), riderGap[rider]] + [riderResults[rider]] for rider in riderOrder] + # lastName, firstName, license, [list of machines], team, totalPercent, [list of (percent, position) for each race in series] + categoryResult = [list(riderNameLicense[rider]) + [riderMachines[rider], riderTeam[rider], percentFormat.format(riderPercentTotal[rider]), riderGap[rider]] + [riderResults[rider]] for rider in riderOrder] return categoryResult, races, GetPotentialDuplicateFullNames(riderNameLicense) elif scoreByTrueSkill: @@ -637,6 +681,8 @@ def formatRating( rating ): for rr in raceResults: rider = rr.key() riderNameLicense[rider] = (rr.full_name, rr.license) + if rr.machine and rr.machine != '0': + riderMachines[rider][raceSequence[rr.raceInSeries]] = rr.machine if rr.team and rr.team != '0': riderTeam[rider] = rr.team if rr.rank != SeriesModel.rankDNF: @@ -686,10 +732,13 @@ def formatRating( rating ): races.reverse() for results in riderResults.values(): results.reverse() + + # Tidy up the machines list for display + TidyMachinesList( riderMachines ) # List of: - # lastName, firstName, license, team, points, [list of (points, position) for each race in series] - categoryResult = [list(riderNameLicense[rider]) + [riderTeam[rider], riderPoints[rider], riderGap[rider]] + [riderResults[rider]] for rider in riderOrder] + # lastName, firstName, license, [list of machines], team, points, [list of (points, position) for each race in series] + categoryResult = [list(riderNameLicense[rider]) + [riderMachines[rider], riderTeam[rider], riderPoints[rider], riderGap[rider]] + [riderResults[rider]] for rider in riderOrder] return categoryResult, races, GetPotentialDuplicateFullNames(riderNameLicense) else: # Score by points. @@ -698,6 +747,8 @@ def formatRating( rating ): for rr in raceResults: rider = rr.key() riderNameLicense[rider] = (rr.full_name, rr.license) + if rr.machine and rr.machine != '0': + riderMachines[rider][raceSequence[rr.raceInSeries]] = rr.machine if rr.team and rr.team != '0': riderTeam[rider] = rr.team primePoints = rr.primePoints if considerPrimePointsOrTimeBonus else 0 @@ -765,9 +816,12 @@ def formatRating( rating ): for results in riderResults.values(): results.reverse() + # Tidy up the machines list for display + TidyMachinesList( riderMachines ) + # List of: - # lastName, firstName, license, team, points, [list of (points, position) for each race in series] - categoryResult = [list(riderNameLicense[rider]) + [riderTeam[rider], riderPoints[rider], riderGap[rider]] + [riderResults[rider]] for rider in riderOrder] + # lastName, firstName, license, [list of machines], team, points, [list of (points, position) for each race in series] + categoryResult = [list(riderNameLicense[rider]) + [riderMachines[rider], riderTeam[rider], riderPoints[rider], riderGap[rider]] + [riderResults[rider]] for rider in riderOrder] return categoryResult, races, GetPotentialDuplicateFullNames(riderNameLicense) #------------------------------------------------------------------------------------------------ diff --git a/SeriesMgr/MainWin.py b/SeriesMgr/MainWin.py index 835c06c75..53d4ce02c 100644 --- a/SeriesMgr/MainWin.py +++ b/SeriesMgr/MainWin.py @@ -39,8 +39,6 @@ from TeamResults import TeamResults from CategorySequence import CategorySequence from Aliases import Aliases -from AliasesLicense import AliasesLicense -from AliasesTeam import AliasesTeam from Options import Options from Errors import Errors from Printing import SeriesMgrPrintout @@ -263,9 +261,7 @@ def addPage( page, name ): [ 'points', Points, 'Scoring Criteria' ], [ 'categorySequence',CategorySequence, 'Category Options' ], [ 'upgrades', Upgrades, 'Upgrades' ], - [ 'aliases', Aliases, 'Name Aliases' ], - [ 'licenseAliases', AliasesLicense, 'License Aliases' ], - [ 'teamAliases', AliasesTeam, 'Team Aliases' ], + [ 'aliases', Aliases, 'Aliases' ], [ 'options', Options, 'Options' ], [ 'errors', Errors, 'Errors' ], ] diff --git a/SeriesMgr/Races.py b/SeriesMgr/Races.py index ce48d6b24..34f931304 100644 --- a/SeriesMgr/Races.py +++ b/SeriesMgr/Races.py @@ -61,7 +61,7 @@ def __init__(self, parent): self.grid.SetColAttr( self.TeamPointsCol, attr ) attr = gridlib.GridCellAttr() - attr.SetReadOnly( True ) + #attr.SetReadOnly( True ) self.grid.SetColAttr( self.RaceCol, attr ) attr = gridlib.GridCellAttr() @@ -212,11 +212,12 @@ def commit( self ): pname = self.grid.GetCellValue( row, self.PointsCol ) pteamname = self.grid.GetCellValue( row, self.TeamPointsCol ) or None grade = self.grid.GetCellValue(row, self.GradeCol).strip().upper()[:1] + raceName = self.grid.GetCellValue( row, self.RaceCol ).strip() if not (grade and ord('A') <= ord(grade) <= ord('Z')): grade = 'A' if not fileName or not pname: continue - raceList.append( (fileName, pname, pteamname, grade) ) + raceList.append( (fileName, pname, pteamname, grade, raceName) ) model = SeriesModel.model model.setRaces( raceList ) diff --git a/SeriesMgr/ReadRaceResultsSheet.py b/SeriesMgr/ReadRaceResultsSheet.py index 4cdcb73ab..a6378522c 100644 --- a/SeriesMgr/ReadRaceResultsSheet.py +++ b/SeriesMgr/ReadRaceResultsSheet.py @@ -12,7 +12,7 @@ from Excel import GetExcelReader #----------------------------------------------------------------------------------------------------- -Fields = ['Bib#', 'Pos', 'Time', 'FirstName', 'LastName', 'Category', 'License', 'Team'] +Fields = ['Bib#', 'Pos', 'Time', 'FirstName', 'LastName', 'Category', 'License', 'Machine', 'Team'] class FileNamePage(wx.adv.WizardPageSimple): def __init__(self, parent): diff --git a/SeriesMgr/Results.py b/SeriesMgr/Results.py index 20decd31b..3475ce995 100644 --- a/SeriesMgr/Results.py +++ b/SeriesMgr/Results.py @@ -26,7 +26,7 @@ reNoDigits = re.compile( '[^0-9]' ) -HeaderNamesTemplate = ['Pos', 'Name', 'License', 'Team'] +HeaderNamesTemplate = ['Pos', 'Name', 'License', 'Machine', 'Team'] def getHeaderNames(): return HeaderNamesTemplate + ['Total Time' if SeriesModel.model.scoreByTime else 'Points', 'Gap'] @@ -73,7 +73,7 @@ def getHtmlFileName(): defaultPath = os.path.dirname( modelFileName ) return os.path.join( defaultPath, fileName ) -def getHtml( htmlfileName=None, seriesFileName=None ): +def getHtml( htmlfileName=None, hideCols=[], seriesFileName=None): model = SeriesModel.model scoreByTime = model.scoreByTime scoreByPercent = model.scoreByPercent @@ -89,6 +89,7 @@ def getHtml( htmlfileName=None, seriesFileName=None ): return 'SeriesMgr: No Categories.' HeaderNames = getHeaderNames() + pointsForRank = { r.getFileName(): r.pointStructure for r in model.races } if not seriesFileName: @@ -289,10 +290,15 @@ def write( s ): hr { clear: both; } +.hidden { + display: none; +} + @media print { .noprint { display: none; } .title { page-break-after: avoid; } } + ''') with tag(html, 'script', dict( type="text/javascript")): @@ -347,10 +353,10 @@ def write( s ): if( col == 0 || col == 4 || col == 5 ) { // Pos, Points or Gap cmpFunc = cmpPos; } - else if( col >= 6 ) { // Race Points/Time and Rank + else if( col >= 7 ) { // Race Points/Time and Rank cmpFunc = function( a, b ) { - var x = parseRank( a.cells[6+(col-6)*2+1].textContent.trim() ); - var y = parseRank( b.cells[6+(col-6)*2+1].textContent.trim() ); + var x = parseRank( a.cells[7+(col-7)*2+1].textContent.trim() ); + var y = parseRank( b.cells[7+(col-7)*2+1].textContent.trim() ); return MakeCmpStable( a, b, x - y ); }; } @@ -445,9 +451,9 @@ def write( s ): pointsForRank, useMostEventsCompleted=model.useMostEventsCompleted, numPlacesTieBreaker=model.numPlacesTieBreaker ) - results = [rr for rr in results if toFloat(rr[3]) > 0.0] - - headerNames = HeaderNames + ['{}'.format(r[1]) for r in races] + results = [rr for rr in results if toFloat(rr[4]) > 0.0] + hideRaces = [] + headerNames = HeaderNames + ['{}'.format(r[3].raceName) for r in races] with tag(html, 'div', {'id':'catContent{}'.format(iTable)} ): write( '

') @@ -462,14 +468,20 @@ def write( s ): colAttr = { 'onclick': 'sortTableId({}, {})'.format(iTable, iHeader) } if col in ('License', 'Gap'): colAttr['class'] = 'noprint' + if col in hideCols: + colAttr['class'] = colAttr.get('class', '') + ' hidden' with tag(html, 'th', colAttr): with tag(html, 'span', dict(id='idUpDn{}_{}'.format(iTable,iHeader)) ): pass write( '{}'.format(escape(col).replace('\n', '
\n')) ) for iRace, r in enumerate(races): # r[0] = RaceData, r[1] = RaceName, r[2] = RaceURL, r[3] = Race + hideClass = '' + if r[1] in hideCols: + hideRaces.append(iRace) #list of race columns to hide when rendering points rows + hideClass = ' hidden' with tag(html, 'th', { - 'class':'leftBorder centerAlign noprint', + 'class':'leftBorder centerAlign noprint' + hideClass, 'colspan': 2, 'onclick': 'sortTableId({}, {})'.format(iTable, len(HeaderNames) + iRace), } ): @@ -477,9 +489,9 @@ def write( s ): pass if r[2]: with tag(html,'a',dict(href='{}?raceCat={}'.format(r[2], quote(categoryName.encode('utf8')))) ): - write( '{}'.format(escape(r[1]).replace('\n', '
\n')) ) + write( '{}'.format(escape(r[3].raceName).replace('\n', '
\n')) ) else: - write( '{}'.format(escape(r[1]).replace('\n', '
\n')) ) + write( '{}'.format(escape(r[3].raceName).replace('\n', '
\n')) ) if r[0]: write( '
' ) with tag(html, 'span', {'class': 'smallFont'}): @@ -489,49 +501,51 @@ def write( s ): with tag(html, 'span', {'class': 'smallFont'}): write( 'Top {}'.format(len(r[3].pointStructure)) ) with tag(html, 'tbody'): - for pos, (name, license, team, points, gap, racePoints) in enumerate(results): + for pos, (name, license, machines, team, points, gap, racePoints) in enumerate(results): with tag(html, 'tr', {'class':'odd'} if pos % 2 == 1 else {} ): - with tag(html, 'td', {'class':'rightAlign'}): + with tag(html, 'td', {'class':'rightAlign' + (' hidden' if 'Pos' in hideCols else '')}): write( '{}'.format(pos+1) ) - with tag(html, 'td'): + with tag(html, 'td', {'class':'' + (' hidden' if 'Name' in hideCols else '')}): write( '{}'.format(name or '') ) - with tag(html, 'td', {'class':'noprint'}): + with tag(html, 'td', {'class':'noprint' + (' hidden' if 'License' in hideCols else '')}): if licenseLinkTemplate and license: with tag(html, 'a', {'href':'{}{}'.format(licenseLinkTemplate, license), 'target':'_blank'}): write( '{}'.format(license or '') ) else: write( '{}'.format(license or '') ) - with tag(html, 'td'): + with tag(html, 'td', {'class':'' + (' hidden' if 'Machine' in hideCols else '')}): + write( '{}'.format(',
'.join(list(filter(None, machines))) or '') ) + with tag(html, 'td', {'class':'' + (' hidden' if 'Team' in hideCols else '')}): write( '{}'.format(team or '') ) - with tag(html, 'td', {'class':'rightAlign'}): + with tag(html, 'td', {'class':'rightAlign' + (' hidden' if 'Points' in hideCols else '')}): write( '{}'.format(points or '') ) - with tag(html, 'td', {'class':'rightAlign noprint'}): + with tag(html, 'td', {'class':'rightAlign noprint' + (' hidden' if 'Gap' in hideCols else '')}): write( '{}'.format(gap or '') ) + iRace = 0 #simple iterator, is there a more pythonesque way to do this? for rPoints, rRank, rPrimePoints, rTimeBonus in racePoints: if rPoints: - with tag(html, 'td', {'class':'leftBorder rightAlign noprint' + (' ignored' if '**' in '{}'.format(rPoints) else '')}): + with tag(html, 'td', {'class':'leftBorder rightAlign noprint' + (' ignored' if '**' in '{}'.format(rPoints) else '') + (' hidden' if iRace in hideRaces else '')}): write( '{}'.format(rPoints).replace('[','').replace(']','').replace(' ', ' ') ) else: - with tag(html, 'td', {'class':'leftBorder noprint'}): + with tag(html, 'td', {'class':'leftBorder noprint' + (' hidden' if iRace in hideRaces else '')}): pass - if rRank: if rPrimePoints: - with tag(html, 'td', {'class':'rank noprint'}): + with tag(html, 'td', {'class':'rank noprint' + (' hidden' if iRace in hideRaces else '')}): write( '({}) +{}'.format(Utils.ordinal(rRank).replace(' ', ' '), rPrimePoints) ) elif rTimeBonus: - with tag(html, 'td', {'class':'rank noprint'}): + with tag(html, 'td', {'class':'rank noprint' + (' hidden' if iRace in hideRaces else '')}): write( '({}) -{}'.format( Utils.ordinal(rRank).replace(' ', ' '), Utils.formatTime(rTimeBonus, twoDigitMinutes=False)), ) else: - with tag(html, 'td', {'class':'rank noprint'}): + with tag(html, 'td', {'class':'rank noprint' + (' hidden' if iRace in hideRaces else '')}): write( '({})'.format(Utils.ordinal(rRank).replace(' ', ' ')) ) else: - with tag(html, 'td', {'class':'noprint'}): + with tag(html, 'td', {'class':'noprint' + (' hidden' if iRace in hideRaces else '')}): pass - + iRace += 1 #----------------------------------------------------------------------------- if considerPrimePointsOrTimeBonus: with tag(html, 'p', {'class':'noprint'}): @@ -708,7 +722,7 @@ def __init__(self, parent): self.refreshButton.Bind( wx.EVT_BUTTON, self.onRefresh ) self.publishToHtml = wx.Button( self, label='Publish to Html' ) self.publishToHtml.Bind( wx.EVT_BUTTON, self.onPublishToHtml ) - self.publishToFtp = wx.Button( self, label='Publish to Html with FTP' ) + self.publishToFtp = wx.Button( self, label='Publish to Html with (S)FTP' ) self.publishToFtp.Bind( wx.EVT_BUTTON, self.onPublishToFtp ) self.publishToExcel = wx.Button( self, label='Publish to Excel' ) self.publishToExcel.Bind( wx.EVT_BUTTON, self.onPublishToExcel ) @@ -741,6 +755,7 @@ def __init__(self, parent): self.grid.EnableReorderRows( False ) self.grid.Bind( wx.grid.EVT_GRID_LABEL_LEFT_CLICK, self.doLabelClick ) self.grid.Bind( wx.grid.EVT_GRID_CELL_RIGHT_CLICK, self.doCellClick ) + self.grid.Bind( wx.grid.EVT_GRID_LABEL_RIGHT_CLICK, self.onResultsColumnRightClick ) self.sortCol = None self.setColNames(getHeaderNames()) @@ -794,6 +809,7 @@ def doCellClick( self, event ): self.popupInfo = [ ('{}...'.format(_('Copy Name to Clipboard')), wx.NewId(), self.onCopyName), ('{}...'.format(_('Copy License to Clipboard')), wx.NewId(), self.onCopyLicense), + ('{}...'.format(_('Copy Machine to Clipboard')), wx.NewId(), self.onCopyMachine), ('{}...'.format(_('Copy Team to Clipboard')), wx.NewId(), self.onCopyTeam), ] for p in self.popupInfo: @@ -815,7 +831,7 @@ def copyCellToClipboard( self, r, c ): if wx.TheClipboard.Open(): # Create a wx.TextDataObject do = wx.TextDataObject() - do.SetText( self.grid.GetCellValue(r, c) ) + do.SetText( self.grid.GetCellValue(r, c).replace(',\n', '; ') ) #reformat delimiter for compatibility with aliases # Add the data to the clipboard wx.TheClipboard.SetData(do) @@ -830,14 +846,17 @@ def onCopyName( self, event ): def onCopyLicense( self, event ): self.copyCellToClipboard( self.rowCur, 2 ) - def onCopyTeam( self, event ): + def onCopyMachine( self, event ): self.copyCellToClipboard( self.rowCur, 3 ) + def onCopyTeam( self, event ): + self.copyCellToClipboard( self.rowCur, 4 ) + def setColNames( self, headerNames ): for col, headerName in enumerate(headerNames): self.grid.SetColLabelValue( col, headerName ) attr = gridlib.GridCellAttr() - if headerName in ('Name', 'Team', 'License'): + if headerName in ('Name', 'Team', 'License', 'Machine'): attr.SetAlignment( wx.ALIGN_LEFT, wx.ALIGN_TOP ) elif headerName in ('Pos', 'Points', 'Gap'): attr.SetAlignment( wx.ALIGN_RIGHT, wx.ALIGN_TOP ) @@ -896,33 +915,44 @@ def refresh( self ): numPlacesTieBreaker=model.numPlacesTieBreaker, ) - results = [rr for rr in results if toFloat(rr[3]) > 0.0] + results = [rr for rr in results if toFloat(rr[4]) > 0.0] - headerNames = HeaderNames + ['{}\n{}'.format(r[1],r[0].strftime('%Y-%m-%d') if r[0] else '') for r in races] + headerNames = HeaderNames + ['{}\n{}'.format(r[3].raceName,r[0].strftime('%Y-%m-%d') if r[0] else '') for r in races] Utils.AdjustGridSize( self.grid, len(results), len(headerNames) ) self.setColNames( headerNames ) + #These columns start off hidden + hideLicense = True + hideMachine = True + hideTeam = True - for row, (name, license, team, points, gap, racePoints) in enumerate(results): + for row, (name, license, machines, team, points, gap, racePoints) in enumerate(results): self.grid.SetCellValue( row, 0, '{}'.format(row+1) ) self.grid.SetCellValue( row, 1, '{}'.format(name or '') ) self.grid.SetCellBackgroundColour( row, 1, wx.Colour(255,255,0) if name in potentialDuplicates else wx.Colour(255,255,255) ) self.grid.SetCellValue( row, 2, '{}'.format(license or '') ) - self.grid.SetCellValue( row, 3, '{}'.format(team or '') ) - self.grid.SetCellValue( row, 4, '{}'.format(points) ) - self.grid.SetCellValue( row, 5, '{}'.format(gap) ) + self.grid.SetCellValue( row, 3, '{}'.format(',\n'.join(list(filter(None, machines))) or '') ) + self.grid.SetCellValue( row, 4, '{}'.format(team or '') ) + self.grid.SetCellValue( row, 5, '{}'.format(points) ) + self.grid.SetCellValue( row, 6, '{}'.format(gap) ) for q, (rPoints, rRank, rPrimePoints, rTimeBonus) in enumerate(racePoints): - self.grid.SetCellValue( row, 6 + q, + self.grid.SetCellValue( row, 7 + q, '{} ({}) +{}'.format(rPoints, Utils.ordinal(rRank), rPrimePoints) if rPoints and rPrimePoints else '{} ({}) -{}'.format(rPoints, Utils.ordinal(rRank), Utils.formatTime(rTimeBonus, twoDigitMinutes=False)) if rPoints and rRank and rTimeBonus else '{} ({})'.format(rPoints, Utils.ordinal(rRank)) if rPoints else '({})'.format(Utils.ordinal(rRank)) if rRank else '' ) - for c in range( len(headerNames) ): self.grid.SetCellBackgroundColour( row, c, wx.WHITE ) self.grid.SetCellTextColour( row, c, wx.BLACK ) + #Unhide label columns as soon as we see some data + if license is not (None or ''): + hideLicense = False + if ''.join(machines) is not (None or ''): + hideMachine = False + if team is not (None or ''): + hideTeam = False if self.sortCol is not None: def getBracketedNumber( v ): @@ -969,11 +999,45 @@ def getBracketedNumber( v ): self.statsLabel.SetLabel( '{} / {}'.format(self.grid.GetNumberRows(), GetModelInfo.GetTotalUniqueParticipants(self.raceResults)) ) + #Reset column visibility, hide the empty label columns + for c in range(0, self.grid.GetNumberCols()): + self.grid.ShowCol(c) + if hideLicense: + self.grid.HideCol( 2 ) + if hideMachine: + self.grid.HideCol( 3 ) + if hideTeam: + self.grid.HideCol( 4 ) + self.grid.AutoSizeColumns( False ) self.grid.AutoSizeRows( False ) self.GetSizer().Layout() + def onResultsColumnRightClick( self, event ): + # Create and display a popup menu of columns on right-click event + menu = wx.Menu() + menu.SetTitle( 'Show/Hide columns' ) + for c in range(self.grid.GetNumberCols()): + menuItem = menu.AppendCheckItem( wx.ID_ANY, self.grid.GetColLabelValue(c).strip() ) + self.Bind(wx.EVT_MENU, self.onToggleResultsColumn) + if self.grid.IsColShown(c): + menu.Check( menuItem.GetId(), True ) + self.PopupMenu(menu) + menu.Destroy() + + def onToggleResultsColumn( self, event ): + #find the column number + colLabels = [] + for c in range(self.grid.GetNumberCols()): + colLabels.append(self.grid.GetColLabelValue(c).strip()) + label = event.GetEventObject().FindItemById(event.GetId()).GetItemLabel() + c = colLabels.index(label) + if self.grid.IsColShown(c): + self.grid.HideCol(c) + else: + self.grid.ShowCol(c) + def onPublishToExcel( self, event ): model = SeriesModel.model @@ -981,6 +1045,7 @@ def onPublishToExcel( self, event ): scoreByPercent = model.scoreByPercent scoreByTrueSkill = model.scoreByTrueSkill HeaderNames = getHeaderNames() + hideCols = [self.grid.GetColLabelValue(c).strip() for c in range(self.grid.GetNumberCols()) if not self.grid.IsColShown(c)] if Utils.mainWin: if not Utils.mainWin.fileName: @@ -1005,9 +1070,14 @@ def onPublishToExcel( self, event ): useMostEventsCompleted=model.useMostEventsCompleted, numPlacesTieBreaker=model.numPlacesTieBreaker, ) - results = [rr for rr in results if toFloat(rr[3]) > 0.0] + results = [rr for rr in results if toFloat(rr[4]) > 0.0] - headerNames = HeaderNames + [r[1] for r in races] + headerNames = HeaderNames + [r[3].raceName for r in races] + + hideRaces = [] + for iRace, r in enumerate(races): + if r[1] in hideCols: + hideRaces.append(iRace) ws = wb.add_sheet( re.sub('[:\\/?*\[\]]', ' ', categoryName) ) wsFit = FitSheetWrapper( ws ) @@ -1033,26 +1103,47 @@ def onPublishToExcel( self, event ): ) ); rowCur += 2 - for c, headerName in enumerate(headerNames): - wsFit.write( rowCur, c, headerName, labelStyle, bold = True ) + c = 0 + for headerName in headerNames: + if headerName not in hideCols: + wsFit.write( rowCur, c, headerName, labelStyle, bold = True ) + c += 1 rowCur += 1 - for pos, (name, license, team, points, gap, racePoints) in enumerate(results): - wsFit.write( rowCur, 0, pos+1, numberStyle ) - wsFit.write( rowCur, 1, name, textStyle ) - wsFit.write( rowCur, 2, license, textStyle ) - wsFit.write( rowCur, 3, team, textStyle ) - wsFit.write( rowCur, 4, points, numberStyle ) - wsFit.write( rowCur, 5, gap, numberStyle ) + for pos, (name, license, machines, team, points, gap, racePoints) in enumerate(results): + c = 0 + if 'Pos' not in hideCols: + wsFit.write( rowCur, c, pos+1, numberStyle ) + c += 1 + if 'Name' not in hideCols: + wsFit.write( rowCur, c, name, textStyle ) + c += 1 + if 'License' not in hideCols: + wsFit.write( rowCur, c, license, textStyle ) + c += 1 + if 'Machine' not in hideCols: + wsFit.write( rowCur, c, ', '.join(machines), textStyle ) + c += 1 + if 'Team' not in hideCols: + wsFit.write( rowCur, c, team, textStyle ) + c += 1 + if 'Points' not in hideCols: + wsFit.write( rowCur, c, points, numberStyle ) + c += 1 + if 'Gap' not in hideCols: + wsFit.write( rowCur, c, gap, numberStyle ) + c += 1 for q, (rPoints, rRank, rPrimePoints, rTimeBonus) in enumerate(racePoints): - wsFit.write( rowCur, 6 + q, - '{} ({}) +{}'.format(rPoints, Utils.ordinal(rRank), rPrimePoints) if rPoints and rPrimePoints - else '{} ({}) -{}'.format(rPoints, Utils.ordinal(rRank), Utils.formatTime(rTimeBonus, twoDigitMinutes=False)) if rPoints and rRank and rTimeBonus - else '{} ({})'.format(rPoints, Utils.ordinal(rRank)) if rPoints - else '({})'.format(Utils.ordinal(rRank)) if rRank - else '', - centerStyle - ) + if q not in hideRaces: + wsFit.write( rowCur, c, + '{} ({}) +{}'.format(rPoints, Utils.ordinal(rRank), rPrimePoints) if rPoints and rPrimePoints + else '{} ({}) -{}'.format(rPoints, Utils.ordinal(rRank), Utils.formatTime(rTimeBonus, twoDigitMinutes=False)) if rPoints and rRank and rTimeBonus + else '{} ({})'.format(rPoints, Utils.ordinal(rRank)) if rPoints + else '({})'.format(Utils.ordinal(rRank)) if rRank + else '', + centerStyle + ) + c += 1 rowCur += 1 # Add branding at the bottom of the sheet. @@ -1084,9 +1175,10 @@ def onPublishToHtml( self, event ): htmlfileName = getHtmlFileName() model = SeriesModel.model model.postPublishCmd = self.postPublishCmd.GetValue().strip() - + try: - getHtml( htmlfileName ) + #surpress currently hidden columns in the HTML output + getHtml( htmlfileName, [self.grid.GetColLabelValue(c).strip() for c in range(self.grid.GetNumberCols()) if not self.grid.IsColShown(c)]) webbrowser.open( htmlfileName, new = 2, autoraise = True ) Utils.MessageOK(self, 'Html file written to:\n\n {}'.format(htmlfileName), 'html Write') except IOError: @@ -1104,7 +1196,8 @@ def onPublishToFtp( self, event ): htmlfileName = getHtmlFileName() try: - getHtml( htmlfileName ) + #surpress currently hidden license/machine/team columns in the HTML output + getHtml( htmlfileName, [self.grid.GetColLabelValue(c).strip() for c in range(self.grid.GetNumberCols()) if not self.grid.IsColShown(c)]) except IOError: return diff --git a/SeriesMgr/SeriesModel.py b/SeriesMgr/SeriesModel.py index 146d10697..35f2533e6 100644 --- a/SeriesMgr/SeriesModel.py +++ b/SeriesMgr/SeriesModel.py @@ -156,14 +156,19 @@ class Race: pureTeam = False # True if the results are by pure teams, that is, no individual results. teamPointStructure = None # If specified, team points will be recomputed from the top individual results. - def __init__( self, fileName, pointStructure, teamPointStructure=None, grade=None ): + def __init__( self, fileName, pointStructure, teamPointStructure=None, grade=None, raceName=None ): self.fileName = fileName self.pointStructure = pointStructure self.teamPointStructure = teamPointStructure self.grade = grade or 'A' + if raceName is None: + self.raceName = RaceNameFromPath( self.fileName ) + else: + self.raceName = raceName def getRaceName( self ): - return RaceNameFromPath( self.fileName ) + #return RaceNameFromPath( self.fileName ) + return self.raceName def postReadFix( self ): if getattr( self, 'fname', None ): @@ -174,7 +179,7 @@ def getFileName( self ): return self.fileName def __repr__( self ): - return ', '.join( '{}={}'.format(a, repr(getattr(self, a))) for a in ['fileName', 'pointStructure'] ) + return ', '.join( '{}={}'.format(a, repr(getattr(self, a))) for a in ['fileName', 'pointStructure', 'raceName'] ) class Category: name = '' @@ -242,15 +247,19 @@ class SeriesModel: references = [] referenceLicenses = [] + referenceMachines = [] referenceTeams = [] aliasLookup = {} aliasLicenseLookup = {} + aliasMachineLookup = {} aliasTeamLookup = {} ftpHost = '' + ftpPort = 21 ftpPath = '' ftpUser = '' ftpPassword = '' + ftpProtocol = '' urlPath = '' @property @@ -304,7 +313,7 @@ def setPoints( self, pointsList ): self.setChanged() def setRaces( self, raceList ): - if [(r.fileName, r.pointStructure.name, r.teamPointStructure.name if r.teamPointStructure else None, r.grade) for r in self.races] == raceList: + if [(r.fileName, r.pointStructure.name, r.teamPointStructure.name if r.teamPointStructure else None, r.grade, r.raceName) for r in self.races] == raceList: return self.setChanged() @@ -312,7 +321,7 @@ def setRaces( self, raceList ): racesSeen = set() newRaces = [] ps = { p.name:p for p in self.pointStructures } - for fileName, pname, pteamname, grade in raceList: + for fileName, pname, pteamname, grade, racename in raceList: fileName = fileName.strip() if not fileName or fileName in racesSeen: continue @@ -324,7 +333,7 @@ def setRaces( self, raceList ): except KeyError: continue pt = ps.get( pteamname, None ) - newRaces.append( Race(fileName, p, pt, grade) ) + newRaces.append( Race(fileName, p, pt, grade, racename) ) self.races = newRaces for i, r in enumerate(self.races): @@ -398,6 +407,39 @@ def setReferenceLicenses( self, referenceLicenses ): #if updated: # memoize.clear() + def setReferenceMachines( self, referenceMachines ): + dNew = dict( referenceMachines ) + dExisting = dict( self.referenceMachines ) + + changed = (len(dNew) != len(dExisting)) + updated = False + + for name, aliases in dNew.items(): + if name not in dExisting: + changed = True + if aliases: + updated = True + elif aliases != dExisting[name]: + changed = True + updated = True + + for name, aliases in dExisting.items(): + if name not in dNew: + changed = True + if aliases: + updated = True + + if changed: + self.changed = changed + self.referenceMachines = referenceMachines + self.aliasMachineLookup = {} + for machine, aliases in self.referenceMachines: + for alias in aliases: + key = nameToAliasKey( alias ) + self.aliasMachineLookup[key] = machine + + #if updated: + # memoize.clear() def setReferenceTeams( self, referenceTeams ): dNew = dict( referenceTeams ) @@ -442,9 +484,12 @@ def getReferenceLicense( self, license ): key = Utils.removeDiacritic(license).upper() return self.aliasLicenseLookup.get( key, key ) + def getReferenceMachine( self, machine ): + return self.aliasMachineLookup.get( nameToAliasKey(machine), machine ) + def getReferenceTeam( self, team ): return self.aliasTeamLookup.get( nameToAliasKey(team), team ) - + def fixCategories( self ): categorySequence = getattr( self, 'categorySequence', None ) if self.categorySequence or not isinstance(self.categories, dict): diff --git a/SeriesMgr/TeamResults.py b/SeriesMgr/TeamResults.py index d4adf426a..80d98b467 100644 --- a/SeriesMgr/TeamResults.py +++ b/SeriesMgr/TeamResults.py @@ -95,7 +95,7 @@ def getHtmlFileName(): defaultPath = os.path.dirname( modelFileName ) return os.path.join( defaultPath, fileName ) -def getHtml( htmlfileName=None, seriesFileName=None ): +def getHtml( htmlfileName=None, hideCols=[], seriesFileName=None ): model = SeriesModel.model scoreByPoints = model.scoreByPoints scoreByTime = model.scoreByTime @@ -304,6 +304,10 @@ def write( s ): hr { clear: both; } +.hidden { + display: none; +} + @media print { .noprint { display: none; } .title { page-break-after: avoid; } @@ -359,13 +363,13 @@ def write( s ): }; var cmpFunc; - if( col == 0 || col == 4 || col == 5 ) { // Pos, Points or Gap + if( col == 0 || col == 2 || col == 3 ) { // Pos, Points or Gap cmpFunc = cmpPos; } - else if( col >= 6 ) { // Race Points/Time and Rank + else if( col >= 4 ) { // Race Points/Time and Rank cmpFunc = function( a, b ) { - var x = parseRank( a.cells[6+(col-6)*2+1].textContent.trim() ); - var y = parseRank( b.cells[6+(col-6)*2+1].textContent.trim() ); + var x = parseRank( a.cells[4+(col-4)].textContent.trim() ); + var y = parseRank( b.cells[4+(col-4)].textContent.trim() ); return MakeCmpStable( a, b, x - y ); }; } @@ -464,8 +468,8 @@ def write( s ): numPlacesTieBreaker=model.numPlacesTieBreaker ) results = filterResults( results, scoreByPoints, scoreByTime ) - - headerNames = HeaderNames + ['{}'.format(r[1]) for r in races] + hideRaces = [] + headerNames = HeaderNames + ['{}'.format(r[3].raceName) for r in races] with tag(html, 'div', {'id':'catContent{}'.format(iTable)} ): write( '

') @@ -480,23 +484,29 @@ def write( s ): colAttr = { 'onclick': 'sortTableId({}, {})'.format(iTable, iHeader) } if col in ('Gap',): colAttr['class'] = 'noprint' + if col in hideCols: + colAttr['class'] = colAttr.get('class', '') + ' hidden' with tag(html, 'th', colAttr): with tag(html, 'span', dict(id='idUpDn{}_{}'.format(iTable,iHeader)) ): pass write( '{}'.format(escape(col).replace('\n', '
\n')) ) for iRace, r in enumerate(races): # r[0] = RaceData, r[1] = RaceName, r[2] = RaceURL, r[3] = Race + hideClass = '' + if r[1] in hideCols: + hideRaces.append(iRace) #list of race columns to hide when rendering points rows + hideClass = ' hidden' with tag(html, 'th', { - 'class':'centerAlign noprint', - #'onclick': 'sortTableId({}, {})'.format(iTable, len(HeaderNames) + iRace), + 'class':'centerAlign noprint' + hideClass, + 'onclick': 'sortTableId({}, {})'.format(iTable, len(HeaderNames) + iRace), #I've re-enabled this and fixed the constants in sortTable() so it actually works- KW } ): with tag(html, 'span', dict(id='idUpDn{}_{}'.format(iTable,len(HeaderNames) + iRace)) ): pass if r[2]: with tag(html,'a',dict(href='{}?raceCat={}'.format(r[2], quote(categoryName.encode('utf8')))) ): - write( '{}'.format(escape(r[1]).replace('\n', '
\n')) ) + write( '{}'.format(escape(r[3].raceName).replace('\n', '
\n')) ) else: - write( '{}'.format(escape(r[1]).replace('\n', '
\n')) ) + write( '{}'.format(escape(r[3].raceName).replace('\n', '
\n')) ) if r[0]: write( '
' ) with tag(html, 'span', {'class': 'smallFont'}): @@ -504,18 +514,19 @@ def write( s ): with tag(html, 'tbody'): for pos, (team, result, gap, rrs) in enumerate(results): with tag(html, 'tr', {'class':'odd'} if pos % 2 == 1 else {} ): - with tag(html, 'td', {'class':'rightAlign'}): + with tag(html, 'td', {'class':'rightAlign' + (' hidden' if 'Pos' in hideCols else '')}): write( '{}'.format(pos+1) ) - with tag(html, 'td'): + with tag(html, 'td', {'class':'' + (' hidden' if 'Team' in hideCols else '')}): write( '{}'.format(team or '') ) - with tag(html, 'td', {'class':'rightAlign'}): + with tag(html, 'td', {'class':'rightAlign' + (' hidden' if 'Points' in hideCols else '')}): write( '{}'.format(result or '') ) - with tag(html, 'td', {'class':'rightAlign noprint'}): + with tag(html, 'td', {'class':'rightAlign noprint' + (' hidden' if 'Gap' in hideCols else '')}): write( '{}'.format(gap or '') ) + iRace = 0 #simple iterator, is there a more pythonesque way to do this? for rt in rrs: - with tag(html, 'td', {'class': 'centerAlign noprint'}): + with tag(html, 'td', {'class': 'centerAlign noprint' + (' hidden' if iRace in hideRaces else '')}): write( formatTeamResults(scoreByPoints, rt) ) - + iRace += 1 #----------------------------------------------------------------------------- if considerPrimePointsOrTimeBonus: with tag(html, 'p', {'class':'noprint'}): @@ -628,7 +639,7 @@ def __init__(self, parent): self.refreshButton.Bind( wx.EVT_BUTTON, self.onRefresh ) self.publishToHtml = wx.Button( self, label='Publish to Html' ) self.publishToHtml.Bind( wx.EVT_BUTTON, self.onPublishToHtml ) - self.publishToFtp = wx.Button( self, label='Publish to Html with FTP' ) + self.publishToFtp = wx.Button( self, label='Publish to Html with (S)FTP' ) self.publishToFtp.Bind( wx.EVT_BUTTON, self.onPublishToFtp ) self.publishToExcel = wx.Button( self, label='Publish to Excel' ) self.publishToExcel.Bind( wx.EVT_BUTTON, self.onPublishToExcel ) @@ -661,6 +672,7 @@ def __init__(self, parent): self.grid.EnableReorderRows( False ) self.grid.Bind( wx.grid.EVT_GRID_LABEL_LEFT_CLICK, self.doLabelClick ) self.grid.Bind( wx.grid.EVT_GRID_CELL_RIGHT_CLICK, self.doCellClick ) + self.grid.Bind( wx.grid.EVT_GRID_LABEL_RIGHT_CLICK, self.onResultsColumnRightClick ) self.sortCol = None self.setColNames(getHeaderNames()) @@ -761,7 +773,7 @@ def setColNames( self, headerNames ): def getGrid( self ): return self.grid - + def getTitle( self ): return self.showResults.GetStringSelection() + ' Series Results' @@ -816,7 +828,11 @@ def refresh( self ): results = filterResults( results, scoreByPoints, scoreByTime ) - headerNames = HeaderNames + ['{}\n{}'.format(r[1],r[0].strftime('%Y-%m-%d') if r[0] else '') for r in races] + headerNames = HeaderNames + ['{}\n{}'.format(r[3].raceName,r[0].strftime('%Y-%m-%d') if r[0] else '') for r in races] + + #Show all columns + for c in range(self.grid.GetNumberCols()): + self.grid.ShowCol(c) Utils.AdjustGridSize( self.grid, len(results), len(headerNames) ) self.setColNames( headerNames ) @@ -883,6 +899,30 @@ def getBracketedNumber( v ): self.GetSizer().Layout() + def onResultsColumnRightClick( self, event ): + # Create and display a popup menu of columns on right-click event + menu = wx.Menu() + menu.SetTitle( 'Show/Hide columns' ) + for c in range(self.grid.GetNumberCols()): + menuItem = menu.AppendCheckItem( wx.ID_ANY, self.grid.GetColLabelValue(c).strip() ) + self.Bind(wx.EVT_MENU, self.onToggleResultsColumn) + if self.grid.IsColShown(c): + menu.Check( menuItem.GetId(), True ) + self.PopupMenu(menu) + menu.Destroy() + + def onToggleResultsColumn( self, event ): + #find the column number + colLabels = [] + for c in range(self.grid.GetNumberCols()): + colLabels.append(self.grid.GetColLabelValue(c).strip()) + label = event.GetEventObject().FindItemById(event.GetId()).GetItemLabel() + c = colLabels.index(label) + if self.grid.IsColShown(c): + self.grid.HideCol(c) + else: + self.grid.ShowCol(c) + def onPublishToExcel( self, event ): model = SeriesModel.model @@ -891,6 +931,7 @@ def onPublishToExcel( self, event ): scoreByPercent = model.scoreByPercent scoreByTrueSkill = model.scoreByTrueSkill HeaderNames = getHeaderNames() + hideCols = [self.grid.GetColLabelValue(c).strip() for c in range(self.grid.GetNumberCols()) if not self.grid.IsColShown(c)] if Utils.mainWin: if not Utils.mainWin.fileName: @@ -920,7 +961,12 @@ def onPublishToExcel( self, event ): results = filterResults( results, scoreByPoints, scoreByTime ) - headerNames = HeaderNames + [r[1] for r in races] + headerNames = HeaderNames + [r[3].raceName for r in races] + + hideRaces = [] + for iRace, r in enumerate(races): + if r[1] in hideCols: + hideRaces.append(iRace) ws = wb.add_sheet( re.sub('[:\\/?*\[\]]', ' ', categoryName) ) wsFit = FitSheetWrapper( ws ) @@ -946,17 +992,31 @@ def onPublishToExcel( self, event ): ) ); rowCur += 2 - for c, headerName in enumerate(headerNames): - wsFit.write( rowCur, c, headerName, labelStyle, bold = True ) + c = 0 + for headerName in headerNames: + if headerName not in hideCols: + wsFit.write( rowCur, c, headerName, labelStyle, bold = True ) + c += 1 rowCur += 1 for pos, (team, points, gap, rrs) in enumerate(results): - wsFit.write( rowCur, 0, pos+1, numberStyle ) - wsFit.write( rowCur, 1, team, textStyle ) - wsFit.write( rowCur, 2, points, numberStyle ) - wsFit.write( rowCur, 3, gap, numberStyle ) + c = 0 + if 'Pos' not in hideCols: + wsFit.write( rowCur, c, pos+1, numberStyle ) + c += 1 + if 'Team' not in hideCols: + wsFit.write( rowCur, c, team, textStyle ) + c += 1 + if 'Points' not in hideCols: + wsFit.write( rowCur, c, points, numberStyle ) + c += 1 + if 'Gap' not in hideCols: + wsFit.write( rowCur, c, gap, numberStyle ) + c += 1 for q, rt in enumerate(rrs): - wsFit.write( rowCur, 4 + q, formatTeamResults(scoreByPoints, rt), centerStyle ) + if q not in hideRaces: + wsFit.write( rowCur, c, formatTeamResults(scoreByPoints, rt), centerStyle ) + c += 1 rowCur += 1 # Add branding at the bottom of the sheet. @@ -990,7 +1050,7 @@ def onPublishToHtml( self, event ): model.postPublishCmd = self.postPublishCmd.GetValue().strip() try: - getHtml( htmlfileName ) + getHtml( htmlfileName, [self.grid.GetColLabelValue(c).strip() for c in range(self.grid.GetNumberCols()) if not self.grid.IsColShown(c)]) webbrowser.open( htmlfileName, new = 2, autoraise = True ) Utils.MessageOK(self, 'Html file written to:\n\n {}'.format(htmlfileName), 'html Write') except IOError: @@ -1008,12 +1068,12 @@ def onPublishToFtp( self, event ): htmlfileName = getHtmlFileName() try: - getHtml( htmlfileName ) + getHtml( htmlfileName, [self.grid.GetColLabelValue(c).strip() for c in range(self.grid.GetNumberCols()) if not self.grid.IsColShown(c)]) except IOError: return html = io.open( htmlfileName, 'r', encoding='utf-8', newline='' ).read() - with FtpWriteFile.FtpPublishDialog( self, html=html ) as dlg: + with FtpWriteFile.FtpPublishDialog( self, html=html, team=True ) as dlg: dlg.ShowModal() self.callPostPublishCmd( htmlfileName ) diff --git a/SeriesMgr/helptxt/QuickStart.txt b/SeriesMgr/helptxt/QuickStart.txt index 8ef429118..857aa08c3 100644 --- a/SeriesMgr/helptxt/QuickStart.txt +++ b/SeriesMgr/helptxt/QuickStart.txt @@ -55,6 +55,7 @@ In each row, add the race, either a CrossMgr race file (.cmn) or an Excel sheet You can change the order of races by dragging the row in the grey column to the left of the race name. +* The __Race__ column can be edited to change how the race name is displayed in the results. * The __Grade__ column is used to break ties (more on that later too). * The __Points__ column is the points structure to be used to score that race. This only applies if you are scoring by Points (more on that later). * The __Team Points__ column is the points structure used to score teams for that race. @@ -185,7 +186,7 @@ Use this screen to specify license aliases to fix misspellings in your given Rac Shows the individual SeriesResult for each category. The Refresh button will recompute the results and is necessary if you have changed one of the Race files. -The publish buttons are fairly self-explanitory. __Publish to HTML with FTP__ will use the FTP site and password in the last CrossMgr race file. +The publish buttons are fairly self-explanitory. __Publish to HTML with (S)FTP__ will open a dialog where you can enter FTP/SFTP server details. The __Post Publish Cmd__ is a command that is run after the publish. This allows you to post-process the results (for example, copy them somewhere). As per the Windows shell standard, you can use %* to refer to all files created by SeriesMgr in the publish. diff --git a/helptxt/Properties.md b/helptxt/Properties.md index 53eb30b51..6fb3647f1 100644 --- a/helptxt/Properties.md +++ b/helptxt/Properties.md @@ -295,8 +295,9 @@ Options for SFTP and FTP upload: Option|Description :-------|:---------- -Use SFTP|Check this if you wish to use the SFTP protocol. Otherwise, FTP protocol will be used. -Host Name:Name of the FTP/SFTP host to upload to. In SFTP, CrossMgr also loads hosts from the user's local hosts file (as used by OpenSSH). +Protocol|Select one of FTP, FTPS (FTP with SSL encryption) or SFTP (SSH File Transfer Protocol) +Host Name|Name of the FTP/SFTP host to upload to. In SFTP, CrossMgr also loads hosts from the user's local hosts file (as used by OpenSSH). +Port|Port of the FTP/SFTP host to upload to (resets to default after switching between FTP and SFTP). Upload files to Path|The directory path on the host you wish to upload the files into. If blank, files will be uploaded into the root directory. User|FTP/SFTP User name Password|FTP/SFTP Password