1 | from galaxy.model.orm import * |
---|
2 | from galaxy.web.base.controller import * |
---|
3 | from galaxy.web.framework.helpers import iff |
---|
4 | from galaxy.web import url_for |
---|
5 | from galaxy.util.json import from_json_string, to_json_string |
---|
6 | from galaxy.util.odict import odict |
---|
7 | from galaxy.web.framework.helpers import to_unicode |
---|
8 | from galaxy.model.item_attrs import * |
---|
9 | |
---|
10 | import sys, logging, math |
---|
11 | |
---|
12 | log = logging.getLogger( __name__ ) |
---|
13 | |
---|
14 | class Grid( object ): |
---|
15 | """ |
---|
16 | Specifies the content and format of a grid (data table). |
---|
17 | """ |
---|
18 | webapp = None |
---|
19 | title = "" |
---|
20 | exposed = True |
---|
21 | model_class = None |
---|
22 | template = "grid_base.mako" |
---|
23 | async_template = "grid_base_async.mako" |
---|
24 | use_async = False |
---|
25 | global_actions = [] |
---|
26 | columns = [] |
---|
27 | operations = [] |
---|
28 | standard_filters = [] |
---|
29 | # Any columns that are filterable (either standard or advanced) should have a default value set in the default filter. |
---|
30 | default_filter = {} |
---|
31 | default_sort_key = None |
---|
32 | preserve_state = False |
---|
33 | use_paging = False |
---|
34 | num_rows_per_page = 25 |
---|
35 | # Set preference names. |
---|
36 | cur_filter_pref_name = ".filter" |
---|
37 | cur_sort_key_pref_name = ".sort_key" |
---|
38 | pass_through_operations = {} |
---|
39 | def __init__( self ): |
---|
40 | # Determine if any multiple row operations are defined |
---|
41 | self.has_multiple_item_operations = False |
---|
42 | for operation in self.operations: |
---|
43 | if operation.allow_multiple: |
---|
44 | self.has_multiple_item_operations = True |
---|
45 | break |
---|
46 | |
---|
47 | # If a column does not have a model class, set the column's model class |
---|
48 | # to be the grid's model class. |
---|
49 | for column in self.columns: |
---|
50 | if not column.model_class: |
---|
51 | column.model_class = self.model_class |
---|
52 | |
---|
53 | def __call__( self, trans, **kwargs ): |
---|
54 | # Get basics. |
---|
55 | webapp = kwargs.get( 'webapp', 'galaxy' ) |
---|
56 | status = kwargs.get( 'status', None ) |
---|
57 | message = kwargs.get( 'message', None ) |
---|
58 | # Build a base filter and sort key that is the combination of the saved state and defaults. |
---|
59 | # Saved state takes preference over defaults. |
---|
60 | base_filter = {} |
---|
61 | if self.default_filter: |
---|
62 | # default_filter is a dictionary that provides a default set of filters based on the grid's columns. |
---|
63 | base_filter = self.default_filter.copy() |
---|
64 | base_sort_key = self.default_sort_key |
---|
65 | if self.preserve_state: |
---|
66 | pref_name = unicode( self.__class__.__name__ + self.cur_filter_pref_name ) |
---|
67 | if pref_name in trans.get_user().preferences: |
---|
68 | saved_filter = from_json_string( trans.get_user().preferences[pref_name] ) |
---|
69 | base_filter.update( saved_filter ) |
---|
70 | pref_name = unicode( self.__class__.__name__ + self.cur_sort_key_pref_name ) |
---|
71 | if pref_name in trans.get_user().preferences: |
---|
72 | base_sort_key = from_json_string( trans.get_user().preferences[pref_name] ) |
---|
73 | # Build initial query |
---|
74 | query = self.build_initial_query( trans, **kwargs ) |
---|
75 | query = self.apply_query_filter( trans, query, **kwargs ) |
---|
76 | # Maintain sort state in generated urls |
---|
77 | extra_url_args = {} |
---|
78 | # Determine whether use_default_filter flag is set. |
---|
79 | use_default_filter_str = kwargs.get( 'use_default_filter' ) |
---|
80 | use_default_filter = False |
---|
81 | if use_default_filter_str: |
---|
82 | use_default_filter = ( use_default_filter_str.lower() == 'true' ) |
---|
83 | # Process filtering arguments to (a) build a query that represents the filter and (b) build a |
---|
84 | # dictionary that denotes the current filter. |
---|
85 | cur_filter_dict = {} |
---|
86 | for column in self.columns: |
---|
87 | if column.key: |
---|
88 | # Get the filter criterion for the column. Precedence is (a) if using default filter, only look there; otherwise, |
---|
89 | # (b) look in kwargs; and (c) look in base filter. |
---|
90 | column_filter = None |
---|
91 | if use_default_filter: |
---|
92 | if self.default_filter: |
---|
93 | column_filter = self.default_filter.get( column.key ) |
---|
94 | elif "f-" + column.model_class.__name__ + ".%s" % column.key in kwargs: |
---|
95 | # Queries that include table joins cannot guarantee unique column names. This problem is |
---|
96 | # handled by setting the column_filter value to <TableName>.<ColumnName>. |
---|
97 | column_filter = kwargs.get( "f-" + column.model_class.__name__ + ".%s" % column.key ) |
---|
98 | elif "f-" + column.key in kwargs: |
---|
99 | column_filter = kwargs.get( "f-" + column.key ) |
---|
100 | elif column.key in base_filter: |
---|
101 | column_filter = base_filter.get( column.key ) |
---|
102 | # Method (1) combines a mix of strings and lists of strings into a single string and (2) attempts to de-jsonify all strings. |
---|
103 | def from_json_string_recurse(item): |
---|
104 | decoded_list = [] |
---|
105 | if isinstance( item, basestring): |
---|
106 | try: |
---|
107 | # Not clear what we're decoding, so recurse to ensure that we catch everything. |
---|
108 | decoded_item = from_json_string( item ) |
---|
109 | if isinstance( decoded_item, list): |
---|
110 | decoded_list = from_json_string_recurse( decoded_item ) |
---|
111 | else: |
---|
112 | decoded_list = [ unicode( decoded_item ) ] |
---|
113 | except ValueError: |
---|
114 | decoded_list = [ unicode ( item ) ] |
---|
115 | elif isinstance( item, list): |
---|
116 | return_val = [] |
---|
117 | for element in item: |
---|
118 | a_list = from_json_string_recurse( element ) |
---|
119 | decoded_list = decoded_list + a_list |
---|
120 | return decoded_list |
---|
121 | # If column filter found, apply it. |
---|
122 | if column_filter is not None: |
---|
123 | # TextColumns may have a mix of json and strings. |
---|
124 | if isinstance( column, TextColumn ): |
---|
125 | column_filter = from_json_string_recurse( column_filter ) |
---|
126 | if len( column_filter ) == 1: |
---|
127 | column_filter = column_filter[0] |
---|
128 | # Interpret ',' as a separator for multiple terms. |
---|
129 | if isinstance( column_filter, basestring ) and column_filter.find(',') != -1: |
---|
130 | column_filter = column_filter.split(',') |
---|
131 | # If filter criterion is empty, do nothing. |
---|
132 | if column_filter == '': |
---|
133 | continue |
---|
134 | # Update query. |
---|
135 | query = column.filter( trans, trans.user, query, column_filter ) |
---|
136 | # Upate current filter dict. |
---|
137 | cur_filter_dict[ column.key ] = column_filter |
---|
138 | # Carry filter along to newly generated urls; make sure filter is a string so |
---|
139 | # that we can encode to UTF-8 and thus handle user input to filters. |
---|
140 | if isinstance( column_filter, list ): |
---|
141 | # Filter is a list; process each item. |
---|
142 | for filter in column_filter: |
---|
143 | if not isinstance( filter, basestring ): |
---|
144 | filter = unicode( filter ).encode("utf-8") |
---|
145 | extra_url_args[ "f-" + column.key ] = to_json_string( column_filter ) |
---|
146 | else: |
---|
147 | # Process singleton filter. |
---|
148 | if not isinstance( column_filter, basestring ): |
---|
149 | column_filter = unicode(column_filter) |
---|
150 | extra_url_args[ "f-" + column.key ] = column_filter.encode("utf-8") |
---|
151 | # Process sort arguments. |
---|
152 | sort_key = None |
---|
153 | if 'sort' in kwargs: |
---|
154 | sort_key = kwargs['sort'] |
---|
155 | elif base_sort_key: |
---|
156 | sort_key = base_sort_key |
---|
157 | if sort_key: |
---|
158 | ascending = not( sort_key.startswith( "-" ) ) |
---|
159 | # Queries that include table joins cannot guarantee unique column names. This problem is |
---|
160 | # handled by setting the column_filter value to <TableName>.<ColumnName>. |
---|
161 | table_name = None |
---|
162 | if sort_key.find( '.' ) > -1: |
---|
163 | a_list = sort_key.split( '.' ) |
---|
164 | if ascending: |
---|
165 | table_name = a_list[0] |
---|
166 | else: |
---|
167 | table_name = a_list[0][1:] |
---|
168 | column_name = a_list[1] |
---|
169 | elif ascending: |
---|
170 | column_name = sort_key |
---|
171 | else: |
---|
172 | column_name = sort_key[1:] |
---|
173 | # Sort key is a column key. |
---|
174 | for column in self.columns: |
---|
175 | if column.key and column.key.find( '.' ) > -1: |
---|
176 | column_key = column.key.split( '.' )[1] |
---|
177 | else: |
---|
178 | column_key = column.key |
---|
179 | if ( table_name is None or table_name == column.model_class.__name__ ) and column_key == column_name: |
---|
180 | query = column.sort( trans, query, ascending, column_name=column_name ) |
---|
181 | break |
---|
182 | extra_url_args['sort'] = sort_key |
---|
183 | # There might be a current row |
---|
184 | current_item = self.get_current_item( trans, **kwargs ) |
---|
185 | # Process page number. |
---|
186 | if self.use_paging: |
---|
187 | if 'page' in kwargs: |
---|
188 | if kwargs['page'] == 'all': |
---|
189 | page_num = 0 |
---|
190 | else: |
---|
191 | page_num = int( kwargs['page'] ) |
---|
192 | else: |
---|
193 | page_num = 1 |
---|
194 | |
---|
195 | if page_num == 0: |
---|
196 | # Show all rows in page. |
---|
197 | total_num_rows = query.count() |
---|
198 | page_num = 1 |
---|
199 | num_pages = 1 |
---|
200 | else: |
---|
201 | # Show a limited number of rows. Before modifying query, get the total number of rows that query |
---|
202 | # returns so that the total number of pages can be computed. |
---|
203 | total_num_rows = query.count() |
---|
204 | query = query.limit( self.num_rows_per_page ).offset( ( page_num-1 ) * self.num_rows_per_page ) |
---|
205 | num_pages = int ( math.ceil( float( total_num_rows ) / self.num_rows_per_page ) ) |
---|
206 | else: |
---|
207 | # Defaults. |
---|
208 | page_num = 1 |
---|
209 | num_pages = 1 |
---|
210 | # Preserve grid state: save current filter and sort key. |
---|
211 | if self.preserve_state: |
---|
212 | pref_name = unicode( self.__class__.__name__ + self.cur_filter_pref_name ) |
---|
213 | trans.get_user().preferences[pref_name] = unicode( to_json_string( cur_filter_dict ) ) |
---|
214 | if sort_key: |
---|
215 | pref_name = unicode( self.__class__.__name__ + self.cur_sort_key_pref_name ) |
---|
216 | trans.get_user().preferences[pref_name] = unicode( to_json_string( sort_key ) ) |
---|
217 | trans.sa_session.flush() |
---|
218 | # Log grid view. |
---|
219 | context = unicode( self.__class__.__name__ ) |
---|
220 | params = cur_filter_dict.copy() |
---|
221 | params['sort'] = sort_key |
---|
222 | params['async'] = ( 'async' in kwargs ) |
---|
223 | params['webapp'] = webapp |
---|
224 | trans.log_action( trans.get_user(), unicode( "grid.view" ), context, params ) |
---|
225 | # Render grid. |
---|
226 | def url( *args, **kwargs ): |
---|
227 | # Only include sort/filter arguments if not linking to another |
---|
228 | # page. This is a bit of a hack. |
---|
229 | if 'action' in kwargs: |
---|
230 | new_kwargs = dict() |
---|
231 | else: |
---|
232 | new_kwargs = dict( extra_url_args ) |
---|
233 | # Extend new_kwargs with first argument if found |
---|
234 | if len(args) > 0: |
---|
235 | new_kwargs.update( args[0] ) |
---|
236 | new_kwargs.update( kwargs ) |
---|
237 | # We need to encode item ids |
---|
238 | if 'id' in new_kwargs: |
---|
239 | id = new_kwargs[ 'id' ] |
---|
240 | if isinstance( id, list ): |
---|
241 | new_args[ 'id' ] = [ trans.security.encode_id( i ) for i in id ] |
---|
242 | else: |
---|
243 | new_kwargs[ 'id' ] = trans.security.encode_id( id ) |
---|
244 | return url_for( **new_kwargs ) |
---|
245 | use_panels = ( kwargs.get( 'use_panels', False ) in [ True, 'True', 'true' ] ) |
---|
246 | async_request = ( ( self.use_async ) and ( kwargs.get( 'async', False ) in [ True, 'True', 'true'] ) ) |
---|
247 | # Currently, filling the template returns a str object; this requires decoding the string into a |
---|
248 | # unicode object within mako templates. What probably should be done is to return the template as |
---|
249 | # utf-8 unicode; however, this would require encoding the object as utf-8 before returning the grid |
---|
250 | # results via a controller method, which is require substantial changes. Hence, for now, return grid |
---|
251 | # as str. |
---|
252 | return trans.fill_template( iff( async_request, self.async_template, self.template ), |
---|
253 | grid=self, |
---|
254 | query=query, |
---|
255 | cur_page_num = page_num, |
---|
256 | num_pages = num_pages, |
---|
257 | default_filter_dict=self.default_filter, |
---|
258 | cur_filter_dict=cur_filter_dict, |
---|
259 | sort_key=sort_key, |
---|
260 | current_item=current_item, |
---|
261 | ids = kwargs.get( 'id', [] ), |
---|
262 | url = url, |
---|
263 | status = status, |
---|
264 | message = message, |
---|
265 | use_panels=use_panels, |
---|
266 | webapp=webapp, |
---|
267 | # Pass back kwargs so that grid template can set and use args without |
---|
268 | # grid explicitly having to pass them. |
---|
269 | kwargs=kwargs ) |
---|
270 | def get_ids( self, **kwargs ): |
---|
271 | id = [] |
---|
272 | if 'id' in kwargs: |
---|
273 | id = kwargs['id'] |
---|
274 | # Coerce ids to list |
---|
275 | if not isinstance( id, list ): |
---|
276 | id = id.split( "," ) |
---|
277 | # Ensure ids are integers |
---|
278 | try: |
---|
279 | id = map( int, id ) |
---|
280 | except: |
---|
281 | error( "Invalid id" ) |
---|
282 | return id |
---|
283 | # ---- Override these ---------------------------------------------------- |
---|
284 | def handle_operation( self, trans, operation, ids, **kwargs ): |
---|
285 | pass |
---|
286 | def get_current_item( self, trans, **kwargs ): |
---|
287 | return None |
---|
288 | def build_initial_query( self, trans, **kwargs ): |
---|
289 | return trans.sa_session.query( self.model_class ) |
---|
290 | def apply_query_filter( self, trans, query, **kwargs ): |
---|
291 | # Applies a database filter that holds for all items in the grid. |
---|
292 | # (gvk) Is this method necessary? Why not simply build the entire query, |
---|
293 | # including applying filters in the build_initial_query() method? |
---|
294 | return query |
---|
295 | |
---|
296 | class GridColumn( object ): |
---|
297 | def __init__( self, label, key=None, model_class=None, method=None, format=None, \ |
---|
298 | link=None, attach_popup=False, visible=True, ncells=1, \ |
---|
299 | # Valid values for filterable are ['standard', 'advanced', None] |
---|
300 | filterable=None, sortable=True ): |
---|
301 | """Create a grid column.""" |
---|
302 | self.label = label |
---|
303 | self.key = key |
---|
304 | self.model_class = model_class |
---|
305 | self.method = method |
---|
306 | self.format = format |
---|
307 | self.link = link |
---|
308 | self.attach_popup = attach_popup |
---|
309 | self.visible = visible |
---|
310 | self.ncells = ncells |
---|
311 | self.filterable = filterable |
---|
312 | # Column must have a key to be sortable. |
---|
313 | self.sortable = ( self.key is not None and sortable ) |
---|
314 | def get_value( self, trans, grid, item ): |
---|
315 | if self.method: |
---|
316 | value = getattr( grid, self.method )( trans, item ) |
---|
317 | elif self.key: |
---|
318 | value = getattr( item, self.key ) |
---|
319 | else: |
---|
320 | value = None |
---|
321 | if self.format: |
---|
322 | value = self.format( value ) |
---|
323 | return value |
---|
324 | def get_link( self, trans, grid, item ): |
---|
325 | if self.link and self.link( item ): |
---|
326 | return self.link( item ) |
---|
327 | return None |
---|
328 | def filter( self, trans, user, query, column_filter ): |
---|
329 | """ Modify query to reflect the column filter. """ |
---|
330 | if column_filter == "All": |
---|
331 | pass |
---|
332 | if column_filter == "True": |
---|
333 | query = query.filter_by( **{ self.key: True } ) |
---|
334 | elif column_filter == "False": |
---|
335 | query = query.filter_by( **{ self.key: False } ) |
---|
336 | return query |
---|
337 | def get_accepted_filters( self ): |
---|
338 | """ Returns a list of accepted filters for this column. """ |
---|
339 | accepted_filters_vals = [ "False", "True", "All" ] |
---|
340 | accepted_filters = [] |
---|
341 | for val in accepted_filters_vals: |
---|
342 | args = { self.key: val } |
---|
343 | accepted_filters.append( GridColumnFilter( val, args) ) |
---|
344 | return accepted_filters |
---|
345 | def sort( self, trans, query, ascending, column_name=None ): |
---|
346 | """Sort query using this column.""" |
---|
347 | if column_name is None: |
---|
348 | column_name = self.key |
---|
349 | if ascending: |
---|
350 | query = query.order_by( self.model_class.table.c.get( column_name ).asc() ) |
---|
351 | else: |
---|
352 | query = query.order_by( self.model_class.table.c.get( column_name ).desc() ) |
---|
353 | return query |
---|
354 | |
---|
355 | class ReverseSortColumn( GridColumn ): |
---|
356 | """ Column that reverses sorting; this is useful when the natural sort is descending. """ |
---|
357 | def sort( self, trans, query, ascending, column_name=None ): |
---|
358 | return GridColumn.sort( self, trans, query, (not ascending), column_name=column_name ) |
---|
359 | |
---|
360 | class TextColumn( GridColumn ): |
---|
361 | """ Generic column that employs freetext and, hence, supports freetext, case-independent filtering. """ |
---|
362 | def filter( self, trans, user, query, column_filter ): |
---|
363 | """ Modify query to filter using free text, case independence. """ |
---|
364 | if column_filter == "All": |
---|
365 | pass |
---|
366 | elif column_filter: |
---|
367 | query = query.filter( self.get_filter( trans, user, column_filter ) ) |
---|
368 | return query |
---|
369 | def get_filter( self, trans, user, column_filter ): |
---|
370 | """ Returns a SQLAlchemy criterion derived from column_filter. """ |
---|
371 | if isinstance( column_filter, basestring ): |
---|
372 | return self.get_single_filter( user, column_filter ) |
---|
373 | elif isinstance( column_filter, list ): |
---|
374 | clause_list = [] |
---|
375 | for filter in column_filter: |
---|
376 | clause_list.append( self.get_single_filter( user, filter ) ) |
---|
377 | return and_( *clause_list ) |
---|
378 | def get_single_filter( self, user, a_filter ): |
---|
379 | """ |
---|
380 | Returns a SQLAlchemy criterion derived for a single filter. Single filter |
---|
381 | is the most basic filter--usually a string--and cannot be a list. |
---|
382 | """ |
---|
383 | # Queries that include table joins cannot guarantee that table column names will be |
---|
384 | # unique, so check to see if a_filter is of type <TableName>.<ColumnName>. |
---|
385 | if self.key.find( '.' ) > -1: |
---|
386 | a_key = self.key.split( '.' )[1] |
---|
387 | else: |
---|
388 | a_key = self.key |
---|
389 | model_class_key_field = getattr( self.model_class, a_key ) |
---|
390 | return func.lower( model_class_key_field ).like( "%" + a_filter.lower() + "%" ) |
---|
391 | def sort( self, trans, query, ascending, column_name=None ): |
---|
392 | """Sort column using case-insensitive alphabetical sorting.""" |
---|
393 | if column_name is None: |
---|
394 | column_name = self.key |
---|
395 | if ascending: |
---|
396 | query = query.order_by( func.lower( self.model_class.table.c.get( column_name ) ).asc() ) |
---|
397 | else: |
---|
398 | query = query.order_by( func.lower( self.model_class.table.c.get( column_name ) ).desc() ) |
---|
399 | return query |
---|
400 | |
---|
401 | class DateTimeColumn( TextColumn ): |
---|
402 | def sort( self, trans, query, ascending, column_name=None ): |
---|
403 | """Sort query using this column.""" |
---|
404 | return GridColumn.sort( self, trans, query, ascending, column_name=column_name ) |
---|
405 | |
---|
406 | class BooleanColumn( TextColumn ): |
---|
407 | def sort( self, trans, query, ascending, column_name=None ): |
---|
408 | """Sort query using this column.""" |
---|
409 | return GridColumn.sort( self, trans, query, ascending, column_name=column_name ) |
---|
410 | |
---|
411 | class IntegerColumn( TextColumn ): |
---|
412 | """ |
---|
413 | Integer column that employs freetext, but checks that the text is an integer, |
---|
414 | so support filtering on integer values. |
---|
415 | |
---|
416 | IMPORTANT NOTE: grids that use this column type should not include the column |
---|
417 | in the cols_to_filter list of MulticolFilterColumn ( i.e., searching on this |
---|
418 | column type should not be performed in the grid's standard search - it won't |
---|
419 | throw exceptions, but it also will not find what you're looking for ). Grids |
---|
420 | that search on this column should use 'filterable="advanced"' so that searching |
---|
421 | is only performed in the advanced search component, restricting the search to |
---|
422 | the specific column. |
---|
423 | |
---|
424 | This is useful for searching on object ids or other integer columns. See the |
---|
425 | JobIdColumn column in the SpecifiedDateListGrid class in the jobs controller of |
---|
426 | the reports webapp for an example. |
---|
427 | """ |
---|
428 | def get_single_filter( self, user, a_filter ): |
---|
429 | model_class_key_field = getattr( self.model_class, self.key ) |
---|
430 | assert int( a_filter ), "The search entry must be an integer" |
---|
431 | return model_class_key_field == int( a_filter ) |
---|
432 | def sort( self, trans, query, ascending, column_name=None ): |
---|
433 | """Sort query using this column.""" |
---|
434 | return GridColumn.sort( self, trans, query, ascending, column_name=column_name ) |
---|
435 | |
---|
436 | class CommunityRatingColumn( GridColumn, UsesItemRatings ): |
---|
437 | """ Column that displays community ratings for an item. """ |
---|
438 | def get_value( self, trans, grid, item ): |
---|
439 | ave_item_rating, num_ratings = self.get_ave_item_rating_data( trans.sa_session, item, webapp_model=trans.model ) |
---|
440 | return trans.fill_template( "community_rating.mako", |
---|
441 | ave_item_rating=ave_item_rating, |
---|
442 | num_ratings=num_ratings, |
---|
443 | item_id=trans.security.encode_id( item.id ) ) |
---|
444 | def sort( self, trans, query, ascending, column_name=None ): |
---|
445 | def get_foreign_key( source_class, target_class ): |
---|
446 | """ Returns foreign key in source class that references target class. """ |
---|
447 | target_fk = None |
---|
448 | for fk in source_class.table.foreign_keys: |
---|
449 | if fk.references( target_class.table ): |
---|
450 | target_fk = fk |
---|
451 | break |
---|
452 | if not target_fk: |
---|
453 | raise RuntimeException( "No foreign key found between objects: %s, %s" % source_class.table, target_class.table ) |
---|
454 | return target_fk |
---|
455 | # Get the columns that connect item's table and item's rating association table. |
---|
456 | item_rating_assoc_class = getattr( trans.model, '%sRatingAssociation' % self.model_class.__name__ ) |
---|
457 | foreign_key = get_foreign_key( item_rating_assoc_class, self.model_class ) |
---|
458 | fk_col = foreign_key.parent |
---|
459 | referent_col = foreign_key.get_referent( self.model_class.table ) |
---|
460 | # Do sorting using a subquery. |
---|
461 | # Subquery to get average rating for each item. |
---|
462 | ave_rating_subquery = trans.sa_session.query( fk_col, \ |
---|
463 | func.avg( item_rating_assoc_class.table.c.rating ).label('avg_rating') ) \ |
---|
464 | .group_by( fk_col ) \ |
---|
465 | .subquery() |
---|
466 | # Integrate subquery into main query. |
---|
467 | query = query.outerjoin( (ave_rating_subquery, referent_col==ave_rating_subquery.columns[fk_col.name]) ) |
---|
468 | # Sort using subquery results; use coalesce to avoid null values. |
---|
469 | if not ascending: # TODO: for now, reverse sorting b/c first sort is ascending, and that should be the natural sort. |
---|
470 | query = query.order_by( func.coalesce( ave_rating_subquery.c.avg_rating, 0 ).asc() ) |
---|
471 | else: |
---|
472 | query = query.order_by( func.coalesce( ave_rating_subquery.c.avg_rating, 0 ).desc() ) |
---|
473 | return query |
---|
474 | |
---|
475 | class OwnerAnnotationColumn( TextColumn, UsesAnnotations ): |
---|
476 | """ Column that displays and filters item owner's annotations. """ |
---|
477 | def __init__( self, col_name, key, model_class=None, model_annotation_association_class=None, filterable=None ): |
---|
478 | GridColumn.__init__( self, col_name, key=key, model_class=model_class, filterable=filterable ) |
---|
479 | self.sortable = False |
---|
480 | self.model_annotation_association_class = model_annotation_association_class |
---|
481 | def get_value( self, trans, grid, item ): |
---|
482 | """ Returns first 150 characters of annotation. """ |
---|
483 | annotation = self.get_item_annotation_str( trans.sa_session, item.user, item ) |
---|
484 | if annotation: |
---|
485 | ann_snippet = annotation[:155] |
---|
486 | if len( annotation ) > 155: |
---|
487 | ann_snippet = ann_snippet[ :ann_snippet.rfind(' ') ] |
---|
488 | ann_snippet += "..." |
---|
489 | else: |
---|
490 | ann_snippet = "" |
---|
491 | return ann_snippet |
---|
492 | def get_single_filter( self, user, a_filter ): |
---|
493 | """ Filter by annotation and annotation owner. """ |
---|
494 | return self.model_class.annotations.any( |
---|
495 | and_( func.lower( self.model_annotation_association_class.annotation ).like( "%" + a_filter.lower() + "%" ), |
---|
496 | # TODO: not sure why, to filter by owner's annotations, we have to do this rather than |
---|
497 | # 'self.model_class.user==self.model_annotation_association_class.user' |
---|
498 | self.model_annotation_association_class.table.c.user_id==self.model_class.table.c.user_id ) ) |
---|
499 | |
---|
500 | class CommunityTagsColumn( TextColumn ): |
---|
501 | """ Column that supports community tags. """ |
---|
502 | def __init__( self, col_name, key, model_class=None, model_tag_association_class=None, filterable=None, grid_name=None ): |
---|
503 | GridColumn.__init__( self, col_name, key=key, model_class=model_class, filterable=filterable, sortable=False ) |
---|
504 | self.model_tag_association_class = model_tag_association_class |
---|
505 | # Column-specific attributes. |
---|
506 | self.grid_name = grid_name |
---|
507 | def get_value( self, trans, grid, item ): |
---|
508 | return trans.fill_template( "/tagging_common.mako", tag_type="community", trans=trans, user=trans.get_user(), tagged_item=item, elt_context=self.grid_name, |
---|
509 | in_form=True, input_size="20", tag_click_fn="add_tag_to_grid_filter", use_toggle_link=True ) |
---|
510 | def filter( self, trans, user, query, column_filter ): |
---|
511 | """ Modify query to filter model_class by tag. Multiple filters are ANDed. """ |
---|
512 | if column_filter == "All": |
---|
513 | pass |
---|
514 | elif column_filter: |
---|
515 | query = query.filter( self.get_filter( trans, user, column_filter ) ) |
---|
516 | return query |
---|
517 | def get_filter( self, trans, user, column_filter ): |
---|
518 | # Parse filter to extract multiple tags. |
---|
519 | if isinstance( column_filter, list ): |
---|
520 | # Collapse list of tags into a single string; this is redundant but effective. TODO: fix this by iterating over tags. |
---|
521 | column_filter = ",".join( column_filter ) |
---|
522 | raw_tags = trans.app.tag_handler.parse_tags( column_filter.encode( "utf-8" ) ) |
---|
523 | clause_list = [] |
---|
524 | for name, value in raw_tags.items(): |
---|
525 | if name: |
---|
526 | # Filter by all tags. |
---|
527 | clause_list.append( self.model_class.tags.any( func.lower( self.model_tag_association_class.user_tname ).like( "%" + name.lower() + "%" ) ) ) |
---|
528 | if value: |
---|
529 | # Filter by all values. |
---|
530 | clause_list.append( self.model_class.tags.any( func.lower( self.model_tag_association_class.user_value ).like( "%" + value.lower() + "%" ) ) ) |
---|
531 | return and_( *clause_list ) |
---|
532 | |
---|
533 | class IndividualTagsColumn( CommunityTagsColumn ): |
---|
534 | """ Column that supports individual tags. """ |
---|
535 | def get_value( self, trans, grid, item ): |
---|
536 | return trans.fill_template( "/tagging_common.mako", |
---|
537 | tag_type="individual", |
---|
538 | user=trans.user, |
---|
539 | tagged_item=item, |
---|
540 | elt_context=self.grid_name, |
---|
541 | in_form=True, |
---|
542 | input_size="20", |
---|
543 | tag_click_fn="add_tag_to_grid_filter", |
---|
544 | use_toggle_link=True ) |
---|
545 | def get_filter( self, trans, user, column_filter ): |
---|
546 | # Parse filter to extract multiple tags. |
---|
547 | if isinstance( column_filter, list ): |
---|
548 | # Collapse list of tags into a single string; this is redundant but effective. TODO: fix this by iterating over tags. |
---|
549 | column_filter = ",".join( column_filter ) |
---|
550 | raw_tags = trans.app.tag_handler.parse_tags( column_filter.encode( "utf-8" ) ) |
---|
551 | clause_list = [] |
---|
552 | for name, value in raw_tags.items(): |
---|
553 | if name: |
---|
554 | # Filter by individual's tag names. |
---|
555 | clause_list.append( self.model_class.tags.any( and_( func.lower( self.model_tag_association_class.user_tname ).like( "%" + name.lower() + "%" ), self.model_tag_association_class.user == user ) ) ) |
---|
556 | if value: |
---|
557 | # Filter by individual's tag values. |
---|
558 | clause_list.append( self.model_class.tags.any( and_( func.lower( self.model_tag_association_class.user_value ).like( "%" + value.lower() + "%" ), self.model_tag_association_class.user == user ) ) ) |
---|
559 | return and_( *clause_list ) |
---|
560 | |
---|
561 | class MulticolFilterColumn( TextColumn ): |
---|
562 | """ Column that performs multicolumn filtering. """ |
---|
563 | def __init__( self, col_name, cols_to_filter, key, visible, filterable="default" ): |
---|
564 | GridColumn.__init__( self, col_name, key=key, visible=visible, filterable=filterable) |
---|
565 | self.cols_to_filter = cols_to_filter |
---|
566 | def filter( self, trans, user, query, column_filter ): |
---|
567 | """ Modify query to filter model_class by tag. Multiple filters are ANDed. """ |
---|
568 | if column_filter == "All": |
---|
569 | return query |
---|
570 | if isinstance( column_filter, list): |
---|
571 | clause_list = [] |
---|
572 | for filter in column_filter: |
---|
573 | part_clause_list = [] |
---|
574 | for column in self.cols_to_filter: |
---|
575 | part_clause_list.append( column.get_filter( trans, user, filter ) ) |
---|
576 | clause_list.append( or_( *part_clause_list ) ) |
---|
577 | complete_filter = and_( *clause_list ) |
---|
578 | else: |
---|
579 | clause_list = [] |
---|
580 | for column in self.cols_to_filter: |
---|
581 | clause_list.append( column.get_filter( trans, user, column_filter ) ) |
---|
582 | complete_filter = or_( *clause_list ) |
---|
583 | return query.filter( complete_filter ) |
---|
584 | |
---|
585 | class OwnerColumn( TextColumn ): |
---|
586 | """ Column that lists item's owner. """ |
---|
587 | def get_value( self, trans, grid, item ): |
---|
588 | return item.user.username |
---|
589 | def sort( self, trans, query, ascending, column_name=None ): |
---|
590 | """ Sort column using case-insensitive alphabetical sorting on item's username. """ |
---|
591 | if ascending: |
---|
592 | query = query.order_by( func.lower ( self.model_class.username ).asc() ) |
---|
593 | else: |
---|
594 | query = query.order_by( func.lower( self.model_class.username ).desc() ) |
---|
595 | return query |
---|
596 | |
---|
597 | class PublicURLColumn( TextColumn ): |
---|
598 | """ Column displays item's public URL based on username and slug. """ |
---|
599 | def get_link( self, trans, grid, item ): |
---|
600 | if item.user.username and item.slug: |
---|
601 | return dict( action='display_by_username_and_slug', username=item.user.username, slug=item.slug ) |
---|
602 | elif not item.user.username: |
---|
603 | # TODO: provide link to set username. |
---|
604 | return None |
---|
605 | elif not item.user.slug: |
---|
606 | # TODO: provide link to set slug. |
---|
607 | return None |
---|
608 | |
---|
609 | class DeletedColumn( GridColumn ): |
---|
610 | """ Column that tracks and filters for items with deleted attribute. """ |
---|
611 | def get_accepted_filters( self ): |
---|
612 | """ Returns a list of accepted filters for this column. """ |
---|
613 | accepted_filter_labels_and_vals = { "active" : "False", "deleted" : "True", "all": "All" } |
---|
614 | accepted_filters = [] |
---|
615 | for label, val in accepted_filter_labels_and_vals.items(): |
---|
616 | args = { self.key: val } |
---|
617 | accepted_filters.append( GridColumnFilter( label, args) ) |
---|
618 | return accepted_filters |
---|
619 | |
---|
620 | class StateColumn( GridColumn ): |
---|
621 | """ |
---|
622 | Column that tracks and filters for items with state attribute. |
---|
623 | |
---|
624 | IMPORTANT NOTE: self.model_class must have a states Bunch or dict if |
---|
625 | this column type is used in the grid. |
---|
626 | """ |
---|
627 | def get_value( self, trans, grid, item ): |
---|
628 | return item.state |
---|
629 | def filter( self, trans, user, query, column_filter ): |
---|
630 | """Modify query to filter self.model_class by state.""" |
---|
631 | if column_filter == "All": |
---|
632 | pass |
---|
633 | elif column_filter in [ v for k, v in self.model_class.states.items() ]: |
---|
634 | query = query.filter( self.model_class.state == column_filter ) |
---|
635 | return query |
---|
636 | def get_accepted_filters( self ): |
---|
637 | """Returns a list of accepted filters for this column.""" |
---|
638 | all = GridColumnFilter( 'all', { self.key : 'All' } ) |
---|
639 | accepted_filters = [ all ] |
---|
640 | for k, v in self.model_class.states.items(): |
---|
641 | args = { self.key: v } |
---|
642 | accepted_filters.append( GridColumnFilter( v, args) ) |
---|
643 | return accepted_filters |
---|
644 | |
---|
645 | class SharingStatusColumn( GridColumn ): |
---|
646 | """ Grid column to indicate sharing status. """ |
---|
647 | def get_value( self, trans, grid, item ): |
---|
648 | # Delete items cannot be shared. |
---|
649 | if item.deleted: |
---|
650 | return "" |
---|
651 | # Build a list of sharing for this item. |
---|
652 | sharing_statuses = [] |
---|
653 | if item.users_shared_with: |
---|
654 | sharing_statuses.append( "Shared" ) |
---|
655 | if item.importable: |
---|
656 | sharing_statuses.append( "Accessible" ) |
---|
657 | if item.published: |
---|
658 | sharing_statuses.append( "Published" ) |
---|
659 | return ", ".join( sharing_statuses ) |
---|
660 | def get_link( self, trans, grid, item ): |
---|
661 | if not item.deleted and ( item.users_shared_with or item.importable or item.published ): |
---|
662 | return dict( operation="share or publish", id=item.id ) |
---|
663 | return None |
---|
664 | def filter( self, trans, user, query, column_filter ): |
---|
665 | """ Modify query to filter histories by sharing status. """ |
---|
666 | if column_filter == "All": |
---|
667 | pass |
---|
668 | elif column_filter: |
---|
669 | if column_filter == "private": |
---|
670 | query = query.filter( self.model_class.users_shared_with == None ) |
---|
671 | query = query.filter( self.model_class.importable == False ) |
---|
672 | elif column_filter == "shared": |
---|
673 | query = query.filter( self.model_class.users_shared_with != None ) |
---|
674 | elif column_filter == "accessible": |
---|
675 | query = query.filter( self.model_class.importable == True ) |
---|
676 | elif column_filter == "published": |
---|
677 | query = query.filter( self.model_class.published == True ) |
---|
678 | return query |
---|
679 | def get_accepted_filters( self ): |
---|
680 | """ Returns a list of accepted filters for this column. """ |
---|
681 | accepted_filter_labels_and_vals = odict() |
---|
682 | accepted_filter_labels_and_vals["private"] = "private" |
---|
683 | accepted_filter_labels_and_vals["shared"] = "shared" |
---|
684 | accepted_filter_labels_and_vals["accessible"] = "accessible" |
---|
685 | accepted_filter_labels_and_vals["published"] = "published" |
---|
686 | accepted_filter_labels_and_vals["all"] = "All" |
---|
687 | accepted_filters = [] |
---|
688 | for label, val in accepted_filter_labels_and_vals.items(): |
---|
689 | args = { self.key: val } |
---|
690 | accepted_filters.append( GridColumnFilter( label, args) ) |
---|
691 | return accepted_filters |
---|
692 | |
---|
693 | class GridOperation( object ): |
---|
694 | def __init__( self, label, key=None, condition=None, allow_multiple=True, allow_popup=True, |
---|
695 | target=None, url_args=None, async_compatible=False, confirm=None ): |
---|
696 | self.label = label |
---|
697 | self.key = key |
---|
698 | self.allow_multiple = allow_multiple |
---|
699 | self.allow_popup = allow_popup |
---|
700 | self.condition = condition |
---|
701 | self.target = target |
---|
702 | self.url_args = url_args |
---|
703 | self.async_compatible = async_compatible |
---|
704 | # if 'confirm' is set, then ask before completing the operation |
---|
705 | self.confirm = confirm |
---|
706 | def get_url_args( self, item ): |
---|
707 | if self.url_args: |
---|
708 | temp = dict( self.url_args ) |
---|
709 | temp['id'] = item.id |
---|
710 | return temp |
---|
711 | else: |
---|
712 | return dict( operation=self.label, id=item.id ) |
---|
713 | def allowed( self, item ): |
---|
714 | if self.condition: |
---|
715 | return self.condition( item ) |
---|
716 | else: |
---|
717 | return True |
---|
718 | |
---|
719 | class DisplayByUsernameAndSlugGridOperation( GridOperation ): |
---|
720 | """ Operation to display an item by username and slug. """ |
---|
721 | def get_url_args( self, item ): |
---|
722 | return { 'action' : 'display_by_username_and_slug', 'username' : item.user.username, 'slug' : item.slug } |
---|
723 | |
---|
724 | class GridAction( object ): |
---|
725 | def __init__( self, label=None, url_args=None ): |
---|
726 | self.label = label |
---|
727 | self.url_args = url_args |
---|
728 | |
---|
729 | class GridColumnFilter( object ): |
---|
730 | def __init__( self, label, args=None ): |
---|
731 | self.label = label |
---|
732 | self.args = args |
---|
733 | def get_url_args( self ): |
---|
734 | rval = {} |
---|
735 | for k, v in self.args.items(): |
---|
736 | rval[ "f-" + k ] = v |
---|
737 | return rval |
---|