root/trunk/grails-app/controllers/RestController.groovy @ 1813

Revision 1813, 20.5 KB (checked in by robert@…, 3 years ago)

Added possibility to ask for all assays for the given module (without specifying the studyToken). See #428.

  • Property svn:keywords set to Rev Author Date
RevLine 
[480]1/**
[904]2 * RestController
[480]3 *
[515]4 * This controler provides a REST service.
5 * The names of the RESET resources are the same as the names of this
6 * controller's actions. E.g., the resources called getStudies simply
7 * corresponds to the action getStudies. Some of the resources are parameterized.
8 * The parameters are passed as parameters in the url and are available in the
9 * params respecting Grails' conventions. In this file, we adher to the javadoc 
10 * convention for describing parameters ("@param"), but actually we mean
11 * key-value pairs in the params object of each Grails action we comment on.
[480]12 *
[649]13 * @author      Jahn-Takeshi Saito
[515]14 * @since       20100601
[480]15 *
16 */
17
[515]18import dbnp.studycapturing.Study
19import dbnp.studycapturing.Assay
[976]20import dbnp.authentication.SecUser
[515]21import grails.converters.*
[831]22import nl.metabolomicscentre.dsp.http.BasicAuthentication
[1010]23import dbnp.rest.common.CommunicationManager
[1036]24import org.springframework.security.core.context.SecurityContextHolder;
[480]25
26class RestController {
27
[1813]28        /**************************************************/
29        /** Rest resources for Simple Assay Module (SAM) **/
30        /**************************************************/
[480]31
[1588]32        def authenticationService
[1593]33        def beforeInterceptor = [action:this.&auth,except:["isUser"]]
[829]34        def credentials
[1094]35        def requestUser
[649]36
[831]37        /**
38         * Authorization closure, which is run before executing any of the REST resource actions
[983]39         * It fetches a consumer/token combination from the url and checks whether
40         * that is a correct and known combination
41         *
42         * @param       consumer        consumer name of the calling module
43         * @param       token           token for the authenticated user (e.g. session_id)
[1094]44         * @return  true if the user is remotely logged in, false otherwise
[831]45         */
[835]46        private def auth() {
[1588]47                if( !authenticationService.isRemotelyLoggedIn( params.consumer, params.token ) ) {
[983]48                        response.sendError(403)
49                        return false
50                } else {
[831]51                        return true
52                }
[829]53        }
54
[835]55        /**
[983]56         * REST resource for data modules.
57         * Consumer and token should be supplied via URL parameters.
58         * Determines whether the given user/password combination is a valid GSCF account.
59         *
60         * @param       consumer        consumer name of the calling module
61         * @param       token           token for the authenticated user (e.g. session_id)
62         * @return bool {"authenticated":true} when user/password is a valid GSCF account, {"authenticated":false} otherwise.
63         */
64        def isUser = {
[1588]65                boolean isUser = authenticationService.isRemotelyLoggedIn( params.consumer, params.token )
[843]66                def reply = ['authenticated':isUser]
[1655]67
68                // set output header to json
69                response.contentType = 'application/json'
70
[843]71                render reply as JSON
[835]72        }
[829]73
[996]74        /**
75         * REST resource for data modules.
76         * Consumer and token should be supplied via URL parameters.
77         * Provides the details of the user that has logged in
78         *
79         * @param       consumer        consumer name of the calling module
80         * @param       token           token for the authenticated user (e.g. session_id)
81         * @return bool {"username": "...", "id": ... } when user/password is logged in.
82         */
83        def getUser = {
[1588]84                SecUser user = authenticationService.getRemotelyLoggedInUser( params.consumer, params.token )
[1781]85                def reply = [username: user.username, id: user.id, isAdministrator: user.hasAdminRights() ]
[1655]86
87                // set output header to json
88                response.contentType = 'application/json'
89
[996]90                render reply as JSON
91        }
[945]92
[515]93        /**
[1813]94         * REST resource for data modules.
95         * Consumer and token should be supplied via URL parameters.
96         * Provide a list of all studies owned by the supplied user.
[983]97         *
[1011]98         * @param       studyToken  optional parameter. If no studyToken is given, all studies available to user are returned.
99         *                      Otherwise, the studies for which the studyTokens are given are be returned.
[983]100         * @param       consumer        consumer name of the calling module
101         * @param       token           token for the authenticated user (e.g. session_id)
[1011]102         * @return  JSON object list containing 'studyToken', and 'name' (title) for each study
103         *
[1328]104         * If one study is requested, a 404 error might occur if the study doesn't exist, and a 401 error if the user is not
105         * authorized to access this study. If multiple studies are requrested, non-existing studies or studies for which the
106         * user is not authorized are not returned in the list (so the list might be empty).
[1011]107         *
108         * Example 1. REST call without studyToken.
109         *
110         * Call: http://localhost:8080/gscf/rest/getStudies/query
111         *
112         * Result: [{"title":"NuGO PPS3 mouse study leptin module","studyToken":"PPS3_leptin_module",
113         *                      "startDate":"2008-01-01T23:00:00Z","published":false,"Description":"C57Bl/6 mice were fed a high fat (45 en%)
114         *                      or low fat (10 en%) diet after a four week run-in on low fat diet.","Objectives":null,"Consortium":null,
115         *                      "Cohort name":null,"Lab id":null,"Institute":null,"Study protocol":null},
116         *                      {"title":"NuGO PPS human study","studyToken":"PPSH","startDate":"2008-01-13T23:00:00Z","published":false,
117         *                      "Description":"Human study performed at RRI; centres involved: RRI, IFR, TUM, Maastricht U.","Objectives":null,
118         *                      "Consortium":null,"Cohort name":null,"Lab id":null,"Institute":null,"Study protocol":null}]
119         *
120         *
121         * Example 2. REST call with one studyToken.
122         *
123         * Call: http://localhost:8080/gscf/rest/getStudies/query?studyToken=PPSH
124         *
125         * Result: [{"title":"NuGO PPS human study","studyToken":"PPSH","startDate":"2008-01-13T23:00:00Z",
126         *              "published":false,"Description":"Human study performed at RRI; centres involved: RRI, IFR, TUM, Maastricht U.",
127         *              "Objectives":null,"Consortium":null,"Cohort name":null,"Lab id":null,"Institute":null,"Study protocol":null}]
128         *
129         *
130         *
131         * Example 2. REST call with two studyTokens.
132         *
133         * http://localhost:8080/gscf/rest/getStudies/query?studyToken=PPSH&studyToken=PPS3_leptin_module
134         *
135         * Result: same as result of Example 1.
[983]136         */
[515]137        def getStudies = {
[1011]138
[1813]139                List returnStudies = []
140                List studies = []
[1011]141
142                if( !params.studyToken ) {
143                        studies = Study.findAll()
[600]144                }
[1011]145                else if( params.studyToken instanceof String ) {
[1813]146                        def study = Study.findByStudyUUID( params.studyToken )
[1328]147                        if( study ) {
[1588]148                                if( !study.canRead(authenticationService.getRemotelyLoggedInUser( params.consumer, params.token )) ) {
[1328]149                                        response.sendError(401)
150                                        return false
151                                }
[1813]152
153                                studies.push study
[1328]154                        } else {
155                                response.sendError(404)
156                                return false
157                        }
[1813]158
[1011]159                }
[1813]160                else {
[1011]161                        params.studyToken.each{ studyToken ->
[1440]162                                def study = Study.findByStudyUUID( studyToken );
[1328]163                                if( study )
[1813]164                                        studies.push study
[1011]165                        }
166                }
167
[1813]168
[1011]169                studies.each { study ->
170                        if(study) {
[1588]171                                def user = authenticationService.getRemotelyLoggedInUser( params.consumer, params.token )
[1011]172                                // Check whether the person is allowed to read the data of this study
[1588]173                                if( study.canRead(authenticationService.getRemotelyLoggedInUser( params.consumer, params.token ))) {
[1100]174
[1813]175                                        def items = [studyToken:study.giveUUID()]
176                                        study.giveFields().each { field ->
177                                                def name = field.name
178                                                def value = study.getFieldValue( name )
179                                                items[name] = value
180                                        }
181
182                                        // Add study version number
[1357]183                                        items['version'] = study.version;
[1813]184
185                                        returnStudies.push items
186                                }
[1011]187                        }
188                }
189
[1655]190                // set output header to json
191                response.contentType = 'application/json'
192
193                render returnStudies as JSON
[515]194        }
[480]195
[1357]196        /**
197         * REST resource for data modules.
198         * Consumer and token should be supplied via URL parameters.
199         * Provides the version number of the specified study
200         *
201         * @param       studyToken  optional parameter. If no studyToken is given, a 400 error is given
202         * @param       consumer        consumer name of the calling module
203         * @param       token           token for the authenticated user (e.g. session_id)
204         * @return  JSON object list containing 'studyToken', and 'version'
205         *
206         * A 404 error might occur if the study doesn't exist, and a 401 error if the user is not
207         * authorized to access this study.
208         *
209         * Example. REST call with one studyToken.
210         *
211         * Call: http://localhost:8080/gscf/rest/getStudyVersion?studyToken=PPSH
212         *
213         * Result: {"studyToken":"PPSH","version":31}
214         */
215        def getStudyVersion = {
[515]216
[1357]217                def versionInfo = [:];
218                def study
[1813]219
[1357]220                if( !params.studyToken || !(params.studyToken instanceof String)) {
221                        response.sendError(400)
222                        return false
223                } else {
[1440]224                        study = Study.findByStudyUUID( params.studyToken )
[1357]225                        if( study ) {
[1588]226                                if( !study.canRead(authenticationService.getRemotelyLoggedInUser( params.consumer, params.token )) ) {
[1357]227                                        response.sendError(401)
228                                        return false
229                                }
230                        } else {
231                                response.sendError(404)
232                                return false
233                        }
234                }
235
236                versionInfo[ 'studyToken' ] = params.studyToken;
237                versionInfo[ 'version' ] = study.version;
238
[1655]239                // set output header to json
240                response.contentType = 'application/json'
241
[1357]242                render versionInfo as JSON
243        }
244
[515]245        /**
[983]246         * REST resource for data modules.
247         * Consumer and token should be supplied via URL parameters.
248         * Provide a list of all subjects belonging to a study.
249         *
[1328]250         * If the user is not allowed to read the study contents, a 401 error is given. If the study doesn't exist, a 404 error is given
[983]251         *
252         * @param       studyToken      String The external study id (code) of the target GSCF Study object
253         * @param       consumer        consumer name of the calling module
254         * @param       token           token for the authenticated user (e.g. session_id)
255         * @return JSON object list of subject names
256         */
[523]257        def getSubjects = {
[1813]258                List subjects = []
[904]259                if( params.studyToken ) {
[1813]260                        def study = Study.findByStudyUUID( params.studyToken)
[983]261
262                        if(study) {
263                                // Check whether the person is allowed to read the data of this study
[1588]264                                if( !study.canRead(authenticationService.getRemotelyLoggedInUser( params.consumer, params.token ))) {
[983]265                                        response.sendError(401)
266                                        return false
267                                }
268
269                                study.subjects.each { subjects.push it.name }
[1328]270                        } else {
271                                response.sendError(404)
272                                return false
[983]273                        }
[600]274                }
[1655]275
276                // set output header to json
277                response.contentType = 'application/json'
278
279                render subjects as JSON
[523]280        }
281
282
283        /**
[983]284         * REST resource for data modules.
285         * Consumer and token should be supplied via URL parameters.
286         * Provide a list of all assays for a given study.
287         *
[1328]288         * If the user is not allowed to read the study contents, a 401 error is given. If the study doesn't exist, a 404 error is given
[983]289         *
290         * @param       studyToken      String The external study id (code) of the target GSCF Study object
291         * @param       consumer        consumer name of the calling module
292         * @return list of assays in the study as JSON object list, filtered to only contain assays
293         *         for the specified module, with 'assayToken' and 'name' for each assay
[1813]294         *
295         *
296         * Example 1. REST call without assayToken
297         *            http://localhost:8080/gscf/rest/getAssays/aas?studyToken=PPSH
[1014]298         *                              &consumer=http://localhost:8182/sam
[1813]299         *
300         * Result: [{"name":"Glucose assay after",
[1010]301         *                      "module":{"class":"dbnp.studycapturing.AssayModule","id":1,"name":"SAM module for clinical data",
302         *                              "platform":"clinical measurements","url":"http://localhost:8182/sam"},
303         *                      "externalAssayID":"PPSH-Glu-A", "Description":null,"parentStudyToken":"PPSH"},
304         *                      {"name":"Glucose assay before",
305         *                              "module":{"class":"dbnp.studycapturing.AssayModule","id":1,"name":"SAM module for clinical data",
306         *                              "platform":"clinical measurements","url":"http://localhost:8182/sam"},
307         *                              "externalAssayID":"PPSH-Glu-B","Description":null,"parentStudyToken":"PPSH"}]
[1813]308         *
309         *
310         * Example 2. REST call with one assayToken
311         *                        http://localhost:8080/gscf/rest/getAssays/queryOneTokenz?studyToken=PPSH
[1014]312         *                              &consumer=http://localhost:8182/sam&assayToken=PPSH-Glu-A
[1813]313         *
[1010]314         * Result: [{"name":"Glucose assay after","module":{"class":"dbnp.studycapturing.AssayModule","id":1,
315         *                      "name":"SAM module for clinical data","platform":"clinical measurements","url":"http://localhost:8182/sam"},
316         *                      "externalAssayID":"PPSH-Glu-A","Description":null,"parentStudyToken":"PPSH"}]
317         *
318         *
[1813]319         * Example 3. REST call with two assayTokens.
320         *
321         * Result: Same as result in Example 1.
[983]322         */
[515]323        def getAssays = {
[1655]324                // set output header to json
325                response.contentType = 'application/json'
[1010]326
[1813]327                List returnList = []    // return list of hashes each containing fields and values belonging to an assay
[1010]328
[1813]329                // Check if required parameters are present
330                def validCall = CommunicationManager.hasValidParams( params, "consumer" )
331                if( !validCall ) {
[1655]332                        render "Error. Wrong or insufficient parameters." as JSON
[1010]333                        return
334                }
[1813]335               
336                def assays = []
337               
[904]338                if( params.studyToken ) {
[1010]339
[1813]340                        def study = Study.findByStudyUUID(params.studyToken)
[983]341
342                        if(study) {
343                                // Check whether the person is allowed to read the data of this study
[1588]344                                if( !study.canRead(authenticationService.getRemotelyLoggedInUser( params.consumer, params.token ))) {
[983]345                                        response.sendError(401)
346                                        return false
[935]347                                }
[983]348
[1010]349                                if(params.assayToken==null) {
350                                        assays = study.assays
351                                }
[1813]352                                else if( params.assayToken instanceof String ) {
[1440]353                                        def assay = study.assays.find{ it.giveUUID() == params.assayToken }
[1010]354                                        if( assay ) {
[1813]355                                                assays.push assay
[1010]356                                        }
357                                }
358                                else {                                                                                                  // there are multiple assayTokens instances
359                                        params.assayToken.each { assayToken ->
[1440]360                                                def assay = study.assays.find{ it.giveUUID() == assayToken }
[1010]361                                                if(assay) {
362                                                        assays.push assay
363                                                }
364                                        }
365                                }
366
[1813]367                        } else {
[1328]368                                response.sendError(404)
369                                return false
370                        }
[1010]371
[1813]372                } else {
373                        // Return all assays for the given module
374                        assays = Assay.list().findAll{ it.parent.canRead(authenticationService.getRemotelyLoggedInUser( params.consumer, params.token ) ) }
375                }
376
377                // Create data for all assays
378                assays.each{ assay ->
379                        if (assay.module?.url && assay.module.url.equals(params.moduleURL)) {
380                                if(assay) {
381                                        def map = [assayToken : assay.giveUUID()]
382                                        assay.giveFields().each { field ->
383                                                def name = field.name
384                                                def value = assay.getFieldValue( name )
385                                                map[name] = value
386                                        }
387                                        map["parentStudyToken"] = assay.parent.giveUUID()
388                                        returnList.push( map )
389                                }
390                        }
391                }
392
[1655]393                render returnList as JSON
[515]394        }
395
396        /**
[983]397         * REST resource for data modules.
398         * Provide all samples of a given Assay. The result is an enriched list with additional information for each sample.
399         *
[1328]400         * If the user is not allowed to read the study contents, a 401 error is given. If the assay doesn't exist, a 404 error is given
401         *
[983]402         * @param       assayToken      String (assayToken of some Assay in GSCF)
[1001]403         * @param       sampleToken Optional parameter. One or more sampleTokens to specify what sample to give exectly.
404         *                      If not given, return all samples for specified assay.
[983]405         * @param       consumer        consumer name of the calling module
406         * @param       token           token for the authenticated user (e.g. session_id)
407         * @return As a JSON object list, for each sample in that assay:
408         * @return 'name' (Sample name, which is unique)
409         * @return 'material' (Sample material)
410         * @return 'subject' (The name of the subject from which the sample was taken)
411         * @return 'event' (the name of the template of the SamplingEvent describing the sampling)
412         * @return 'startTime' (the time the sample was taken relative to the start of the study, as a string)
[1094]413         * @return additional template fields are returned
[1813]414         *
415         *
416         *
417         * Example 1: no sampleTokens given.
[1001]418         * Query:
[1813]419         * http://localhost:8080/gscf/rest/getSamples/query?assayToken=PPSH-Glu-A
420         *
[1001]421         * Result:
[1813]422         * [{"sampleToken":"5_A","material":"blood plasma","subject":"5","event":"Blood extraction","startTime":"4 days, 6 hours"},
[1001]423         * {"sampleToken":"6_A","material":"blood plasma","subject":"6","event":"Blood extraction","startTime":"4 days, 6 hours"},
424         * {"sampleToken":"10_A","material":"blood plasma","subject":"10","event":"Blood extraction","startTime":"4 days, 6 hours"},
425         * {"sampleToken":"2_A","material":"blood plasma","subject":"2","event":"Blood extraction","startTime":"4 days, 6 hours"},
426         * {"sampleToken":"11_A","material":"blood plasma","subject":"11","event":"Blood extraction","startTime":"4 days, 6 hours"},
427         * {"sampleToken":"1_A","material":"blood plasma","subject":"1","event":"Blood extraction","startTime":"4 days, 6 hours"},
428         * {"sampleToken":"9_A","material":"blood plasma","subject":"9","event":"Blood extraction","startTime":"4 days, 6 hours"},
429         * {"sampleToken":"4_A","material":"blood plasma","subject":"4","event":"Blood extraction","startTime":"4 days, 6 hours"},
430         * {"sampleToken":"8_A","material":"blood plasma","subject":"8","event":"Blood extraction","startTime":"4 days, 6 hours"},
431         * {"sampleToken":"7_A","material":"blood plasma","subject":"7","event":"Blood extraction","startTime":"4 days, 6 hours"},
432         * {"sampleToken":"3_A","material":"blood plasma","subject":"3","event":"Blood extraction","startTime":"4 days, 6 hours"}]
[1813]433         *
434         *
435         *
436         * Example 2: one sampleToken given.
[1001]437         * Query:
438         * http://localhost:8080/gscf/rest/getSamples/query?assayToken=PPSH-Glu-A&sampleToken=5_A
[1813]439         *
440         * Result:
[1001]441         * [{"sampleToken":"5_A","material":"blood plasma","subject":"5","event":"Blood extraction","startTime":"4 days, 6 hours"}]
[1813]442         *
443         *
444         *
445         * Example 3: two sampleTokens given.
[1001]446         * Query:
[1512]447         * http://localhost:8080/gscf/rest/getSamples/query?assayToken=PPSH-Glu-A&sampleToken=5_A&sampleToken=6_A
[1813]448         *
449         * Result:
[1001]450         * [{"sampleToken":"5_A","material":"blood plasma","subject":"5","event":"Blood extraction","startTime":"4 days, 6 hours"},
451         *  {"sampleToken":"6_A","material":"blood plasma","subject":"6","event":"Blood extraction","startTime":"4 days, 6 hours"}]
[1512]452         *
453         *
[1813]454         * Example 4: no assaytoken given
[1512]455         * Query:
456         * http://localhost:8080/gscf/rest/getSamples/query?sampleToken=5_A&sampleToken=6_A
[1813]457         *
458         * Result:
[1512]459         * [{"sampleToken":"5_A","material":"blood plasma","subject":"5","event":"Blood extraction","startTime":"4 days, 6 hours"},
460         *  {"sampleToken":"6_A","material":"blood plasma","subject":"6","event":"Blood extraction","startTime":"4 days, 6 hours"}]
461         *
[983]462         */
[515]463        def getSamples = {
[600]464                def items = []
[1512]465                def samples
[904]466                if( params.assayToken ) {
[1813]467                        def assay = Assay.findByAssayUUID( params.assayToken );
[1101]468
[945]469                        if( assay )  {
[1328]470                                // Check whether the person is allowed to read the data of this study
[1588]471                                if( !assay.parent.canRead(authenticationService.getRemotelyLoggedInUser( params.consumer, params.token ))) {
[1328]472                                        response.sendError(401)
473                                        return false
474                                }
[1813]475
[1512]476                                samples = assay.getSamples() // on all samples
477                        } else {
478                                // Assay not found
479                                response.sendError(404)
480                                return false
481                        }
482                } else {
483                        // Find all samples from studies the user can read
[1588]484                        def studies = Study.list().findAll { it.canRead( authenticationService.getRemotelyLoggedInUser( params.consumer, params.token ) ) };
[1512]485                        samples = studies*.getSamples().flatten();
486                }
[1813]487
[1512]488                // Check whether only a subset of samples should be returned
489                if( params.sampleToken ) {
490                        def sampleTokens = params.list( "sampleToken" );
[1813]491                        samples = samples.findAll { sampleTokens.contains( it.giveUUID() ) }
[1512]492                }
[1094]493
[1512]494                samples.each { sample ->
[1094]495
[1813]496                        def item = [
497                                                'sampleToken' : sample.giveUUID(),
498                                                'material'        : sample.material?.name,
499                                                'subject'         : sample.parentSubject?.name,
500                                                'event'           : sample.parentEvent?.template?.name,
501                                                'startTime'       : sample.parentEvent?.getStartTimeString()
502                                        ]
[1094]503
[1512]504                        sample.giveFields().each { field ->
505                                def name = field.name
506                                def value = sample.getFieldValue( name )
507                                if(name!='material')
508                                {
509                                        item[name]=value
510                                }
511                        }
[1094]512
[1512]513                        if(sample.parentEvent) {
514                                def parentEvent = sample.parentEvent
515                                def eventHash = [:]
516                                parentEvent.giveFields().each { field ->
517                                        def name = field.name
518                                        if( name !='sampleTemplate' && name != 'fields') {
519                                                def value = parentEvent.getFieldValue( name )
520                                                eventHash[name]=value
[1001]521                                        }
[1512]522                                }
[1813]523                                item['eventObject'] = eventHash
[1512]524                        }
[1095]525
[1512]526                        if(sample.parentSubject) {
527                                def parentSubject = sample.parentSubject
528                                def subject = [:]
529                                parentSubject.giveFields().each { field ->
530                                        def name = field.name
531                                        if( name!='fields') {
532                                                def value = parentSubject.getFieldValue( name )
533                                                subject[name]=value
[1095]534                                        }
[1512]535                                }
[1813]536                                item['subjectObject'] = subject
[1512]537                        }
[1095]538
[1813]539                        items.push item
[1512]540                }
[1095]541
[1655]542                // set output header to json
543                response.contentType = 'application/json'
544
[600]545                render items as JSON
[515]546        }
547
[901]548        /**
[983]549         * Returns the authorization level the user has for a given study.
550         *
551         * If no studyToken is given, a 400 (Bad Request) error is given.
552         * If the given study doesn't exist, a 404 (Not found) error is given.
553         *
554         * @param       consumer        consumer name of the calling module
555         * @param       token           token for the authenticated user (e.g. session_id)
556         * @return      JSON Object
557         * @return  { isOwner: true/false, 'canRead': true/false, 'canWrite': true/false }
558         */
[908]559        def getAuthorizationLevel = {
[935]560                if( params.studyToken ) {
[1813]561                        def study = Study.findByStudyUUID(params.studyToken)
[983]562
563                        if( !study ) {
564                                response.sendError(404)
565                                return false
566                        }
567
[1588]568                        def user = authenticationService.getRemotelyLoggedInUser( params.consumer, params.token );
[1482]569                        def auth = ['isOwner': study.isOwner(user), 'canRead': study.canRead(user), 'canWrite': study.canWrite(user)];
570                        log.trace "Authorization for study " + study.title + " and user " + user.username + ": " + auth
[1655]571
572                        // set output header to json
573                        response.contentType = 'application/json'
574
[1482]575                        render auth as JSON;
[983]576                } else {
577                        response.sendError(400)
578                        return false
[935]579                }
[1813]580        }
[1655]581}
Note: See TracBrowser for help on using the browser.