source: tmcsimulator/branches/LCSv2/controllers/default.py @ 634

Revision 634, 23.3 KB checked in by jdalbey, 5 years ago (diff)

LCS implement #252 Add logging for new closures and status updates

Line 
1# added comments for testing
2# Constants
3kLogfile = "samplelogfile.txt"
4kSimtimefile = "sim_elapsedtime.json"
5hwys = ['','1','5', '22', '55', '57', '73', '74', '91', '133', '142', '241', '261', '405', '605']
6hwyDirections = ['','NB', 'SB', 'NB/SB','EB','WB','EB/WB']
7# Names to appear in the username dropdown box
8users = []
9# Show the login page
10def index():
11    userfile = open('student_names.txt','r')
12    users = [line.strip() for line in userfile.readlines()]
13    users.insert(0,'')
14    form = FORM(LABEL('User:',_for='username', _class="label username-label"),
15                #INPUT(_name='username', _size='15', _style="font-size: 18px;"), BR(),
16                SELECT(users,_name='username',requires=IS_LENGTH(minsize=1,error_message='Must select a user from the list.')),BR(),
17                INPUT(_type='submit',_value="Log in", _class=" btn btn-primary", _style="margin-top: 3%;"))
18    if form.process().accepted:
19        # Put the username entry into the session variable
20        session.username = form.vars.username
21        redirect(URL('home'))
22    return dict(form=form)
23def home():
24    return dict(name=session.username)
25def help():
26    return dict()
27# List all the current records in the database - remove in final application
28def list():
29    highways = db().select(db.closures.ALL, orderby=db.closures.closureid)
30    return dict(highways = highways)
31# Show details of a single record - remove in final application
32def show():
33    # Retrieve the requested log entry from the database
34    # Assumes the requested entry exists in the db (no error handling yet)
35    hwy = db(db.closures.lognum == request.args(0)).select().first()
36    return dict(hwy=hwy)
37# Display a search form
38def search():
39    form = FORM(LABEL('ClosureID/Log:',_for='closureid', _class="label"), 
40                INPUT(_name='closureid',_size='7', _style="margin-right: 5px;"),
41                INPUT(_name='lognum',_size='3'),
42                XML('   '), 
43                LABEL('Route: ',_for='route', _class="label"), 
44                SELECT(hwys,_name='route'), XML('   '), 
45                LABEL('Direction: ',_for='direction', _class="label"),
46                SELECT(hwyDirections,_name='direction'), BR(),BR(), 
47                LABEL('Dates:',_for='startdate', _class="label"), 
48                INPUT(_name='startdate',_class='date'), 
49                XML('   '), 
50                LABEL('  to:',_for='enddate', _class="label"), 
51                INPUT(_name='enddate',_class='date'),BR(),
52                INPUT(_value="Search", _type='submit', _class="btn btn-primary btn-default", _style="margin:  7% 45% 2% 40%;"))
53    if form.process(onvalidation=special_validation).accepted:
54        # Put the form fields into the session variables
55        session.closureid = form.vars.closureid.strip().upper()
56        session.lognum = form.vars.lognum.strip()
57        session.startdate = form.vars.startdate.strip()
58        session.enddate = form.vars.enddate.strip()
59        session.route = form.vars.route
60        session.direction = form.vars.direction
61        redirect(URL('results'))
62    return dict(form=form)
63# Search Form: Special validation check to reject lognum without closureID
64def special_validation(form):
65    # Error if a lognum was given and no closure id
66    if (len(form.vars.lognum) > 0 and len(form.vars.closureid) == 0):
67       form.errors.lognum = 'Must provide a closureID when specifying a log number'
68# Show the item that was found in the search
69def results():
70    # query object is equivalent to the where clause in query
71    query = True
72    msg = ""
73    if (len(session.closureid) != 0):
74        query = (db.closures.closureid == session.closureid)
75        msg += " Closure ID = " + session.closureid   
76    if (len(session.lognum) != 0):
77        query = query & (db.closures.lognum == session.lognum)
78        msg += " Log number = " + session.lognum   
79    if (len(session.route) != 0):
80        query = query & (db.closures.route == session.route)
81        msg += " Route = " + session.route
82    if (len(session.direction) != 0):
83        query = query & (db.closures.direction == session.direction)
84        msg += " Route = " + session.route
85    if (len(session.startdate) != 0):
86        query = query & (db.closures.startdate >= session.startdate)
87        msg += "Start date = " + session.startdate
88    if (len(session.enddate) != 0):
89        query = query & (db.closures.enddate <= session.enddate)
90        msg += "End date = " + session.enddate
91   
92    # if no restrictions entered then get all entries
93    if query == True :
94        hwy = db().select(db.closures.ALL)
95        msg = "ALL"
96    else:
97        # get entries with the matching requirements
98        hwy = db(query).select()
99
100    count = len(hwy)
101    # Show the results in table format.  Get the radio call number from supervisor name lookup
102    header = THEAD(TR(TH(''), TH('DTM',BR(),'Area'), TH('Closure ID/',BR(),'Log No.'),TH('Route & Dir/',BR(),'Type of Closure'),TH('Start Date/',BR(),'End Date/',BR(),'Est. Delay'),TH('Facility/Lanes'),TH('Limits'),TH('Work'), TH('TMP:',BR(),'Cozeep/',BR(),'Detour'),TH('Requestor/',BR(),'Radio Call No.')))
103    multiform = []
104    # Iterates over all search results
105    for row in hwy:
106        statusfields = row.closureid +','+ row.lognum + ',1097,' + str(row.s1097user) +','+ str(row.startdate) + ',' + formatTime(row.starttime) +','+str(row.s1097date)+','+ formatTime(row.s1097time) + ',1098,' + str(row.s1098user) +','+ str(row.s1098date)+','+ formatTime(row.s1098time)+ ',1022,' + str(row.s1022user) +','+ str(row.s1022date)+','+ formatTime(row.s1022time)
107        # Each row contains a form with two buttons and columns with fields from database
108        multiform.append(TR(TD(
109                    XML("<button class='submit-button' onclick=\"showPopup(\'"),statusfields,XML("\')\">View History</button>"),BR(),
110                    FORM(
111                          INPUT(_type='submit',_name='btn2',_value='Show Status Form',_class="submit-button" ),
112                          INPUT(_type='hidden',_name='row',_value=row.closureid))),
113                          TD(row.closureid[0]),TD(row.closureid,HR(),row.lognum), TD(row.route,' ',row.direction,HR(),row.closuretype), TD(row.startdate,' ',formatTime(row.starttime),HR(),row.enddate,' ',formatTime(row.endtime),HR(),row.estdelay), TD(row.facility,HR(),row.closedlanes),
114TD(row.startlocation,HR(),row.endlocation), TD(row.worktype), TD(row.tmpcozeep,BR(),row.tmpdetour), TD(row.supervisor,HR(),db(db.supervisors.name == row.supervisor).select().first().radiocallnum) )) 
115
116    session.chosenid = request.vars.row #Pass the hidden field containing the closure ID
117    if request.vars.btn2:
118        redirect(URL('statuslist'))
119    return dict(msg=msg, count=count, highways=hwy, table=header, multiform=multiform)
120
121# Show a selected closure with a status update form
122def statuslist():
123    closedItems = []
124    if (session.chosenid):
125        if (type(session.chosenid) is str):
126            retrieved = db(db.closures.closureid == session.chosenid).select().first()
127            closedItems.append(retrieved) 
128        else:
129            # This logic is available to show multiple results, for possible future use.
130            for item in session.chosenid:
131                retrieved = db(db.closures.closureid == item).select().first()
132                closedItems.append(retrieved) 
133        # Build the table rows       
134        tblrows = TR()       
135        for row in closedItems:
136            # Construct the status radio buttons; disable if date already in database
137            statusflags = "disabled" if row.s1097date == "" else "" 
138            if row.s1097date == "":
139                btn = LABEL('1097'), INPUT(_type='radio', _name='statustype', _value='1097'+row.closureid)
140            else:
141                btn = LABEL('1097 ○',_class='labelgray'),
142            btngroup = btn
143            if row.s1098date == "":
144                btn = LABEL('1098'), INPUT(_type='radio', _name='statustype', _value='1098'+row.closureid)
145            else:
146                btn = LABEL('1098 ○',_class='labelgray'),
147            btngroup += btn
148            if row.s1022date == "":
149                btn = LABEL('1022'), INPUT(_type='radio', _name='statustype', _value='1022'+row.closureid)
150            else:
151                btn = LABEL('1022 ○',_class='labelgray'),
152            btngroup += btn
153
154            if row.s1097date != "" and row.s1098date != "" and row.s1022date != "":
155                btngroup += BR(),LABEL('Statuser:', _class='labelgray')
156            else:   
157                btngroup += BR(),LABEL('Statuser:'),INPUT(_name='statuser',_size='9'),BR(),BR(),INPUT(_type='submit',_value="submit status update",_class="submit-button")
158#            LABEL('1097 ○',_class='colorgray'), INPUT(_type='radio', _name='statustype', _value='1097'+row.closureid),LABEL('1098'), INPUT(_type='radio', _name='statustype', _value='1098'+row.closureid),LABEL('1022'), INPUT(_type='radio', _name='statustype', _value='1022'+row.closureid),BR(), LABEL('Statuser:'),INPUT(_name='statuser',_size='9')
159            tblrows += TR(TD(row.closureid,HR(),row.lognum),TD(row.route,' ',row.direction,HR(),row.closuretype),TD(row.startdate,HR(),row.enddate,HR(),row.estdelay),TD(row.supervisor,BR(),db(db.supervisors.name == row.supervisor).select().first().radiocallnum),TD(btngroup))
160        form = FORM(BR(), 
161                TABLE(THEAD(TR(TH('Closure ID/',BR(),'Log No.'), TH('Route & Dir',BR(),'Type of Closure'),TH('Start Date/',BR(),'End Date/',BR(),'Est. Delay'),TH('Requestor/',BR(),'Radio Call No.'),TH('Status'))),
162                    tblrows,
163                     
164                    _border='1', _cellpadding='5', _width="70%"))
165       
166    else:
167        msg = "No items were selected.  Use the checkbox in the lefthand column."
168        form = ""
169        return dict(msg=msg,form=form)
170   
171    if form.process().accepted:
172        #session.flash = 'Status submit acknowledgement appears here.'
173        session.statustype = form.vars.statustype
174        session.statuser = form.vars.statuser
175        redirect(URL('statusAck'))
176    return dict(form=form)
177
178# show status update acknowledgement - and update database
179def statusAck():
180    if (session.statustype):
181        if (type(session.statustype) is str):
182            msg = "You submitted a status update for " + session.statustype[4:] + ": " + session.statustype[0:4] 
183#           Perform the update on the database
184            # Construct the name of the field to update
185            fieldname = "s"+session.statustype[0:4]+"user"
186            db(db.closures.closureid == session.statustype[4:]).update(**{fieldname:session.statuser})
187            import datetime 
188            now = datetime.datetime.today()
189            fieldname = "s"+session.statustype[0:4]+"date"
190            db(db.closures.closureid == session.statustype[4:]).update(**{fieldname:now.strftime("%Y-%m-%d")})
191            fieldname = "s"+session.statustype[0:4]+"time"
192            db(db.closures.closureid == session.statustype[4:]).update(**{fieldname:now.strftime("%H%M")})
193            # Log the update to external file
194            logmessage = getSimTime() + " LCS status update: " + session.username + ", " + session.statustype[4:] + ", " + session.statustype[0:4] + ", " + session.statuser + " " + "\n"
195            text_file = open(kLogfile, "a")
196            text_file.write(logmessage)
197            text_file.close()
198        else:
199            msg = "error because only checking one box is allowed."
200    else:
201        msg = "Error no statustype checkbox was checked"
202       
203    return dict(msg=msg)
204# Utility functions for formatting
205def formatTime(msg):
206    if (msg):
207        return msg[0:2]+':'+msg[2:4]
208    else:
209        return ""
210
211# Fetch simulation time and format it into a timestamp
212def getSimTime():
213    import json,datetime
214    try:
215        jsontime = json.load(open(kSimtimefile,'r'))
216        currentSimTime = jsontime["elapsedtime"]
217        timestamp = str(datetime.timedelta(seconds = int(currentSimTime)))
218        return timestamp
219    except:
220        # Fallback if missing file, use current time
221        now = datetime.datetime.today()
222        return now.strftime("%H:%M:%S")
223
224# Create a new record
225def submit():
226    # Don't name this function 'request' because it creates a name conflict with http.request
227    hournames = ['','00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23']
228    closuretypes = ['', 'Lane', 'Full', 'Moving', 'One-Way Traffic', 'Alternating Lanes', 'Traffic Break']
229    facilities = ['', 'Connector', 'Conventional_Hwy', 'Mainline', 'Off Ramp', 'On Ramp', 'Rest Area', 'Surface Street']
230    worktypes = ['','AC Paving', 'Accident Investigation', 'Attenuator Repair', 'Blasting', 'Bridge Inspection', 'Bridge Work', 'Brush Fire', 'Chip Seal Operation', 'Concrete Pour', 'Core Drilling', 'Crack Seal Operation', 'Curb/Gutter/Sidewalk Work', 'Drainage Cleaning', 'Drainage Inspection', 'Drainage Work', 'Electrical Work', 'Emergency Work', 'Falsework Installation', 'Falsework Removal', 'Fence Work', 'Filming Activity', 'Fog Seal Operation', 'Graffiti Removal', 'Grinding and Paving', 'Grinding Operation', 'Guardrail Repair', 'Guardrail Work', 'Highway Construction', 'K-rail Installation', 'K-rail Removal', 'Landscape Work', 'Litter Removal', 'Maintenance Operation', 'Median Barrier Work', 'Miscellaneous Work', 'Pavement Marker Replacement', 'Pavement Repair', 'Pavement Work', 'Paving Operation', 'Pile Driving', 'Police Investigation', 'Roadway Excavation', 'Roadway Flooding', 'Sewer Work', 'Shoulder Work', 'Sign Work', 'Slab Replacement', 'Slide Removal', 'Slope Clearing', 'Soundwall Work', 'Special Event', 'Spray Operation', 'Striping Operation', 'Survey Work', 'Sweeping Operation', 'Traffic Signal Work', 'Tree Work', 'Utility Work', 'Vegetation Spraying']
231    supervisors = ['']  # List of names for the dropdown box
232    # Obtain all the supervisor names from the database
233    for row in db().select(db.supervisors.ALL):
234        supervisors.append(row.radiocallnum + ' ' + row.name)
235    crew = ['']  # List of names for the dropdown box
236    crewlookup = []
237    # Obtain all the crew names from the database
238    for row in db().select(db.crew.ALL):
239        crew.append(row.radiocallnum + ' ' + row.name)
240        crewlookup.append(row.radiocallnum + ' ' + row.name)
241    # Build the list of street locations and a hidden cross street lookup table
242    streets = ['']
243    streetlookup = []
244    for row in db().select(db.streets.ALL, orderby=db.streets.street):
245        streets.append(row.street)
246        streetlookup.append(row.route + ',' + row.street)
247    # Build the list of existing closures   
248    existingclosures = []
249    existingclosures.append("")
250    for row in db().select(db.closures.ALL, orderby=db.closures.closureid):
251        # Omit duplicate ID's (with different lognumbers)
252        if row.closureid not in existingclosures:
253            existingclosures.append(row.closureid)
254       
255    form = FORM(
256                LABEL('*Route',_for='route'), SELECT(hwys,_name='route', _id='routecombo', _onchange='routechanged()', requires=IS_LENGTH(minsize=1,error_message='route cannot be empty')), XML('&nbsp;&nbsp;&nbsp;'), 
257           LABEL('*Direction',_for='direction'), SELECT(hwyDirections,_name='direction', requires=IS_LENGTH(minsize=1,error_message='direction cannot be empty')), XML('&nbsp;&nbsp;&nbsp;'), 
258           LABEL('*Facility',_for='facility'), SELECT(facilities,_name='facility', _id='facilitycombo', requires=IS_LENGTH(minsize=1,error_message='facility cannot be empty')), BR(),BR(), 
259           TABLE(TR(TD(),TD(LABEL('*County')),TD(LABEL('*Location'))),
260                TR(TD(LABEL('BEGIN=')),TD(SELECT('ORA',_name='startcounty')),
261                TD(SELECT(streets,_name='startlocation',_id='startlocation')),
262           TR(TD(LABEL('END=')),TD(SELECT('ORA',_name='endcounty')),TD(SELECT(streets,_name='endlocation',_id='endlocation'))))),BR(),
263           LABEL('Date Range:'),BR(),
264           LABEL('From',_for='startdate'),INPUT(_name='startdate',_size='8',_class='date'), XML('&nbsp;&nbsp;&nbsp;'), 
265           LABEL('to:',_for='enddate'), INPUT(_name='enddate',_size='8',_class='date'),XML('&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'), 
266           LABEL('Times',_for='starttime'),SELECT(hournames,_name='starttime'), 
267           LABEL(':',_for='starttimemin'),SELECT('','00','15','30','45','59',_name='starttimemin'), XML('&nbsp;&nbsp;'), 
268           LABEL('to:',_for='endtime'),SELECT(hournames,_name='endtime'), 
269           LABEL(':',_for='endtimemin'),SELECT('','00','15','30','45','59',_name='endtimemin'), BR(),BR(), 
270           TABLE(TR(TD(LABEL('*Type of Closure') ),
271                    TD(LABEL('*Type of Work')),
272                    TD(LABEL('Estimated Delay')),
273                    TD(LABEL('TMP Details'))), 
274                 TR(TD(SELECT(closuretypes,_name='closuretype',_id='closuretype', _onchange='closuretypechanged()',requires=IS_LENGTH(minsize=1,error_message='type of closure cannot be empty'))), 
275                    TD(SELECT(worktypes,_name='worktype', requires=IS_LENGTH(minsize=1,error_message='type of work cannot be empty'))), 
276                    TD(INPUT(_name='estdelay',_size='4'),'minutes'), 
277                    TD(INPUT(_type='checkbox',_name='cozeep'),'CoZeep MaZeep/CHP',BR(), 
278                       INPUT(_type='checkbox', _name='detour'),'Detour Available')),
279                 TR(TD(DIV(LABEL("Lanes closed"),DIV(INPUT(_type='checkbox', _name='lanes', _id='lanes', _value=' '), _id='boxes'),_id='lanechooser',_style='display:none')),
280                    TD(INPUT(_type='hidden', _name='lanecount', _id='lanecount', _value='4')),
281                    TD(),
282                    TD()),
283                 _width='100%' ),
284    TABLE(TR(TD(LABEL('*Supervisor')),
285            TD(LABEL('Field Rep'))
286            ), 
287          TR(TD(SELECT(supervisors,_name='supervisor', _id='supervisorcombo', _onchange='supervisorchanged()', requires=IS_LENGTH(minsize=1,error_message='supervisor cannot be empty'))),
288             TD(SELECT(crew,_name='fieldrep',_id='fieldrep')),
289             TD(XML("&nbsp;&nbsp;&nbsp;&nbsp;")),
290             TD('Is this an existing incident?',
291                INPUT(_type='radio',_name='existing',_value='No',_onclick='radioclicked()'),
292                'No',
293                INPUT(_type='radio',_name='existing',_value='Yes',value='Yes',_onclick='radioclicked()'),
294                'Yes'
295               )
296             ),
297          TR(TD(),TD(),TD(), TD('    Select closure ID:',SELECT(existingclosures,_name='existingid'),_id='closureselect'))),
298    TABLE(TR(TD( LABEL('Meeting Place/CHP Contact')),
299            TD(LABEL('Reason for Closure')),
300            TD(LABEL('Additional Remarks / Detour '))), 
301                  TR(TD(INPUT(_name='meeting', _size='25')),TD(INPUT(_name='reason',_size='25')),TD(INPUT(_name='remarks',_size='25'))) ), BR(), 
302            INPUT(_type='submit',_value='Submit Closure', _class="btn btn-primary btn-default", _style="margin:  2% 45% 2% 40%;"),
303            XML('\n'),SELECT(streetlookup,_name='stlookup', _id='stlookup', _class='hideme'), 
304SELECT(crewlookup,_name='crewlookup', _id='crewlookup', _class='hideme')) 
305
306    if form.process(onvalidation=validate_existing_id).accepted:
307        newLognum = calcNextLogNum(form.vars.existingid)
308        if newLognum == '1':
309            newID = calcNextClosureID(form.vars.route)
310        else:
311            newID = form.vars.existingid
312        supervisor_name = form.vars.supervisor[3:]
313        fieldrep_name = form.vars.fieldrep[5:]
314        selectedlanes = buildLanesClosedString(form.vars.lanes,form.vars.lanecount)
315        # Insert the record into the database
316        newrec = db.closures.insert(closureid=newID, lognum=newLognum, route=form.vars.route, direction=form.vars.direction, facility=form.vars.facility, startcounty=form.vars.startcounty, endcounty=form.vars.endcounty, startlocation=form.vars.startlocation, endlocation=form.vars.endlocation, startdate=form.vars.startdate, enddate=form.vars.enddate, starttime=form.vars.starttime+form.vars.starttimemin, endtime=form.vars.endtime+form.vars.endtimemin, closuretype=form.vars.closuretype, closedlanes=selectedlanes, worktype=form.vars.worktype, estdelay=form.vars.estdelay, tmpcozeep=getCheckbox(form.vars.cozeep), tmpdetour=getCheckbox(form.vars.detour), supervisor=supervisor_name, fieldrep=fieldrep_name, s1097date='', s1098date='', s1022date='' )
317        session.flash = 'New lane closure added: ' + newID + ' ' + newLognum + ': ' + selectedlanes
318        # Log the new closure to external file. Username, closureID, route, dir, type of closure, type of work
319        logmessage = getSimTime() + " LCS new closure: " + session.username + ", " + newID + '.' + newLognum + ', ' + form.vars.route + form.vars.direction + ', ' + form.vars.closuretype + ', ' + form.vars.worktype + "\n"
320        text_file = open(kLogfile, "a")
321        text_file.write(logmessage)
322        text_file.close()
323        redirect(URL('search.html'))
324    return dict(form=form)
325
326# An unlinked page to allow admin to reset the database to simulation start state
327def resetdb():
328    form = FORM("Press this button to reset the closure database to its original state at the start of a simulation.",BR(),
329                "Warning: this will delete all the current closures.",BR(),
330                "Import filename is 'db_closures_start.csv'.",BR(),
331                INPUT(_value="Reset Database", _type='submit', _class="btn btn-primary btn-default"))
332    if form.process().accepted:
333        db(db.closures.id > 0).delete()   # remove all current records
334        with open('db_closures_start.csv', 'rb') as dumpfile:
335            db.closures.import_from_csv_file(dumpfile)  # import from starting state
336        session.flash = 'Closure database has been reset'
337        redirect(URL('search.html'))
338    return dict(form=form)
339
340# Validation for existing closure button
341# If user selected Yes (it's existing incident) then they must select a closure ID
342def validate_existing_id(form):
343    if form.vars.existing == 'Yes' and form.vars.existingid == '':
344        form.errors.existingid = "Existing incidents require selecting an existing closure ID"
345
346# Calculate the closure id to assign to the new closure
347def calcNextClosureID(routeNum):
348    # Retrieve the last existing closure on this route
349    #item = db(db.closures.closureid.startswith('T'+routeNum)).select().last() # defective
350    item = db(db.closures.route == routeNum).select().last() 
351    if (item != None):
352        currID = item.closureid
353        lastchar = currID[-1:]  # Get last character of ID
354        lastchar = chr(ord(lastchar) + 1) # increment it to next character (need bounds check)
355        newID = currID[:-1] + lastchar  # append char to ID
356        return newID
357    else:
358        return 'T'+routeNum+'AA'  # For a non-existing route
359   
360# If existing incident closure ID is provided, increment the log number by 1
361def calcNextLogNum(existingid):
362    if existingid != '':
363        item = db(db.closures.closureid.startswith(existingid)).select().last()
364        prevLog = int(item.lognum)
365        nextLog = prevLog + 1
366        return str(nextLog)
367    else:
368        return '1'
369
370
371# Convert checkbox value to YES/NO
372def getCheckbox(ckBox):
373    if (ckBox == "on"):
374        return "YES"
375    else:
376        return "NO"
377
378# Convert the lanes closed checkboxes into a human readable string
379# E.g.  #1 #3 of 4
380# Note: ckBoxGroup parameter contains only checked items
381def buildLanesClosedString(ckBoxGroup,lanecount):
382    result = ""
383    if ckBoxGroup is not None:
384        # Append each checked value to a string
385        for item in ckBoxGroup:
386            result = result + "#"+item + " "
387        result = result + "of " + lanecount
388    return result
Note: See TracBrowser for help on using the repository browser.