clevic 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/History.txt +371 -0
- data/INSTALL +10 -0
- data/Manifest.txt +30 -0
- data/README.txt +94 -0
- data/Rakefile +100 -0
- data/TODO +131 -0
- data/accounts_models.rb +122 -0
- data/bin/clevic +64 -0
- data/lib/active_record/dirty.rb +87 -0
- data/lib/clevic.rb +4 -0
- data/lib/clevic/browser.rb +195 -0
- data/lib/clevic/cache_table.rb +281 -0
- data/lib/clevic/db_options.rb +21 -0
- data/lib/clevic/delegates.rb +383 -0
- data/lib/clevic/extensions.rb +133 -0
- data/lib/clevic/field.rb +181 -0
- data/lib/clevic/item_delegate.rb +62 -0
- data/lib/clevic/model_builder.rb +171 -0
- data/lib/clevic/model_column.rb +23 -0
- data/lib/clevic/search_dialog.rb +77 -0
- data/lib/clevic/table_model.rb +431 -0
- data/lib/clevic/table_view.rb +479 -0
- data/lib/clevic/ui/browser.ui +201 -0
- data/lib/clevic/ui/browser_ui.rb +176 -0
- data/lib/clevic/ui/icon.png +0 -0
- data/lib/clevic/ui/search_dialog.ui +216 -0
- data/lib/clevic/ui/search_dialog_ui.rb +106 -0
- data/sql/accounts.sql +302 -0
- data/sql/times.sql +197 -0
- data/times_models.rb +163 -0
- metadata +93 -0
@@ -0,0 +1,21 @@
|
|
1
|
+
# set up defaults
|
2
|
+
# $options[:database] to be defined with the models
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
$options ||= {}
|
6
|
+
$options[:adapter] ||= 'postgresql'
|
7
|
+
$options[:host] ||= 'localhost'
|
8
|
+
$options[:username] ||= 'panic'
|
9
|
+
$options[:password] ||= ''
|
10
|
+
|
11
|
+
ActiveRecord::Base.establish_connection( $options )
|
12
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT) if $options[:verbose]
|
13
|
+
#~ ActiveRecord.colorize_logging = false
|
14
|
+
|
15
|
+
# workaround for the date freeze issue
|
16
|
+
class Date
|
17
|
+
def freeze
|
18
|
+
self
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
@@ -0,0 +1,383 @@
|
|
1
|
+
require 'clevic/item_delegate.rb'
|
2
|
+
|
3
|
+
module Clevic
|
4
|
+
|
5
|
+
=begin rdoc
|
6
|
+
Base class for other delegates using Combo boxes. Emit focus out signals,
|
7
|
+
because ComboBox stupidly doesn't.
|
8
|
+
|
9
|
+
Generally these will be created using a ModelBuilder.
|
10
|
+
=end
|
11
|
+
class ComboDelegate < Clevic::ItemDelegate
|
12
|
+
def initialize( parent )
|
13
|
+
super
|
14
|
+
@@active_record_options ||= [ :conditions, :class_name, :order ]
|
15
|
+
end
|
16
|
+
|
17
|
+
# Convert Qt:: constants from the integer value to a string value.
|
18
|
+
def hint_string( hint )
|
19
|
+
hs = String.new
|
20
|
+
Qt::AbstractItemDelegate.constants.each do |x|
|
21
|
+
hs = x if eval( "Qt::AbstractItemDelegate::#{x}.to_i" ) == hint.to_i
|
22
|
+
end
|
23
|
+
hs
|
24
|
+
end
|
25
|
+
|
26
|
+
def dump_editor_state( editor )
|
27
|
+
if $options[:debug]
|
28
|
+
puts "#{self.class.name}"
|
29
|
+
puts "editor.completer.completion_count: #{editor.completer.completion_count}"
|
30
|
+
puts "editor.completer.current_completion: #{editor.completer.current_completion}"
|
31
|
+
puts "editor.find_text( editor.completer.current_completion ): #{editor.find_text( editor.completer.current_completion )}"
|
32
|
+
puts "editor.current_text: #{editor.current_text}"
|
33
|
+
puts "editor.count: #{editor.count}"
|
34
|
+
puts "editor.completer.current_row: #{editor.completer.current_row}"
|
35
|
+
puts "editor.item_data( editor.current_index ): #{editor.item_data( editor.current_index ).inspect}"
|
36
|
+
puts
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# open the combo box, just like if f4 was pressed
|
41
|
+
def full_edit
|
42
|
+
if is_combo?( @editor )
|
43
|
+
@editor.show_popup
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# returns true if the editor allows values outside of a predefined
|
48
|
+
# range, false otherwise.
|
49
|
+
def restricted?
|
50
|
+
false
|
51
|
+
end
|
52
|
+
|
53
|
+
# TODO fetch this from the model definition
|
54
|
+
def allow_null?
|
55
|
+
true
|
56
|
+
end
|
57
|
+
|
58
|
+
# Subclasses should override this to fill the combo box
|
59
|
+
# list with values.
|
60
|
+
def populate( editor, model_index )
|
61
|
+
raise "subclass responsibility"
|
62
|
+
end
|
63
|
+
|
64
|
+
# return true if this delegate needs a combo, false otherwise
|
65
|
+
def needs_combo?
|
66
|
+
raise "subclass responsibility"
|
67
|
+
end
|
68
|
+
|
69
|
+
def is_combo?( editor )
|
70
|
+
editor.class == Qt::ComboBox
|
71
|
+
end
|
72
|
+
|
73
|
+
# return true if this field has no data (needs_combo? is false)
|
74
|
+
# and is at the same time restricted (ie needs data from somewhere else)
|
75
|
+
def empty_set?
|
76
|
+
!needs_combo? && restricted?
|
77
|
+
end
|
78
|
+
|
79
|
+
# the message to display if the set is empty, and
|
80
|
+
# the delegate is restricted to a predefined set.
|
81
|
+
def empty_set_message
|
82
|
+
raise "subclass responsibility"
|
83
|
+
end
|
84
|
+
|
85
|
+
# if this delegate has an empty set, return the message, otherwise
|
86
|
+
# return nil.
|
87
|
+
def if_empty_message
|
88
|
+
if empty_set?
|
89
|
+
empty_set_message
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def populate_current( editor, model_index )
|
94
|
+
# add the current entry, if it isn't there already
|
95
|
+
# TODO add it in the correct order
|
96
|
+
if ( editor.find_data( model_index.gui_value.to_variant ) == -1 )
|
97
|
+
editor.add_item( model_index.gui_value, model_index.gui_value.to_variant )
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Override the Qt method. Create a ComboBox widget and fill it with the possible values.
|
102
|
+
def createEditor( parent_widget, style_option_view_item, model_index )
|
103
|
+
if needs_combo?
|
104
|
+
@editor = Qt::ComboBox.new( parent_widget )
|
105
|
+
|
106
|
+
# subclasses fill in the rest of the entries
|
107
|
+
populate( @editor, model_index )
|
108
|
+
|
109
|
+
# add the current item, if it isn't there already
|
110
|
+
populate_current( @editor, model_index )
|
111
|
+
|
112
|
+
# create a nil entry
|
113
|
+
if allow_null?
|
114
|
+
if ( @editor.find_data( nil.to_variant ) == -1 )
|
115
|
+
@editor.add_item( '', nil.to_variant )
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# allow prefix matching from the keyboard
|
120
|
+
@editor.editable = true
|
121
|
+
|
122
|
+
# don't insert if restricted
|
123
|
+
@editor.insert_policy = Qt::ComboBox::NoInsert if restricted?
|
124
|
+
else
|
125
|
+
@editor =
|
126
|
+
if restricted?
|
127
|
+
emit parent.status_text( empty_set_message )
|
128
|
+
nil
|
129
|
+
else
|
130
|
+
Qt::LineEdit.new( model_index.gui_value, parent_widget )
|
131
|
+
end
|
132
|
+
end
|
133
|
+
@editor
|
134
|
+
end
|
135
|
+
|
136
|
+
# Override the Qt::ItemDelegate method.
|
137
|
+
def updateEditorGeometry( editor, style_option_view_item, model_index )
|
138
|
+
rect = style_option_view_item.rect
|
139
|
+
|
140
|
+
# ask the editor for how much space it wants, and set the editor
|
141
|
+
# to that size when it displays in the table
|
142
|
+
rect.set_width( [editor.size_hint.width,rect.width].max ) if is_combo?( editor )
|
143
|
+
editor.set_geometry( rect )
|
144
|
+
end
|
145
|
+
|
146
|
+
# Override the Qt method to send data to the editor from the model.
|
147
|
+
def setEditorData( editor, model_index )
|
148
|
+
if is_combo?( editor )
|
149
|
+
editor.current_index = editor.find_data( model_index.attribute_value.to_variant )
|
150
|
+
editor.line_edit.select_all if editor.editable
|
151
|
+
else
|
152
|
+
editor.text = model_index.gui_value
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# This translates the text from the editor into something that is
|
157
|
+
# stored in an underlying model. Intended to be overridden by subclasses.
|
158
|
+
def translate_from_editor_text( editor, text )
|
159
|
+
index = editor.find_text( text )
|
160
|
+
if index == -1
|
161
|
+
text unless restricted?
|
162
|
+
else
|
163
|
+
editor.item_data( index ).value
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
# Send the data from the editor to the model. The data will
|
168
|
+
# be translated by translate_from_editor_text,
|
169
|
+
def setModelData( editor, abstract_item_model, model_index )
|
170
|
+
if is_combo?( editor )
|
171
|
+
dump_editor_state( editor )
|
172
|
+
value =
|
173
|
+
if editor.completer.current_row == -1
|
174
|
+
# item doesn't exist in the list, add it if not restricted
|
175
|
+
editor.current_text unless restricted?
|
176
|
+
elsif editor.completer.completion_count == editor.count
|
177
|
+
# selection from drop down. if it's empty, we want a nil
|
178
|
+
editor.current_text
|
179
|
+
else
|
180
|
+
# there is a matching completion, so use it
|
181
|
+
editor.completer.current_completion
|
182
|
+
end
|
183
|
+
|
184
|
+
if value != nil
|
185
|
+
model_index.attribute_value = translate_from_editor_text( editor, value )
|
186
|
+
end
|
187
|
+
|
188
|
+
else
|
189
|
+
model_index.gui_value = editor.text
|
190
|
+
end
|
191
|
+
emit abstract_item_model.dataChanged( model_index, model_index )
|
192
|
+
end
|
193
|
+
|
194
|
+
protected
|
195
|
+
|
196
|
+
# given a hash of options, return only those
|
197
|
+
# which are applicable to a ActiveRecord::Base.find
|
198
|
+
# method call
|
199
|
+
def collect_finder_options( options )
|
200
|
+
new_options = {}
|
201
|
+
options.each do |key,value|
|
202
|
+
if @@active_record_options.include?( key )
|
203
|
+
new_options[key] = value
|
204
|
+
end
|
205
|
+
end
|
206
|
+
new_options
|
207
|
+
end
|
208
|
+
|
209
|
+
end
|
210
|
+
|
211
|
+
# Provide a list of all values in this field,
|
212
|
+
# and allow new values to be entered.
|
213
|
+
# :frequency can be set as an option. Boolean. If it's true
|
214
|
+
# the options are sorted in order of most frequently used first.
|
215
|
+
class DistinctDelegate < ComboDelegate
|
216
|
+
|
217
|
+
def initialize( parent, attribute, model_class, options )
|
218
|
+
@ar_model = model_class
|
219
|
+
@attribute = attribute
|
220
|
+
@options = options
|
221
|
+
# hackery for amateur query building in populate
|
222
|
+
@options[:conditions] ||= 'true'
|
223
|
+
super( parent )
|
224
|
+
end
|
225
|
+
|
226
|
+
def needs_combo?
|
227
|
+
# works except when there is a '' in the column
|
228
|
+
@ar_model.count( @attribute, collect_finder_options( @options ) ) > 0
|
229
|
+
end
|
230
|
+
|
231
|
+
def populate_current( editor, model_index )
|
232
|
+
# already done in the SQL query in populate, so don't even check
|
233
|
+
end
|
234
|
+
|
235
|
+
def query_order_description( conn, model_index )
|
236
|
+
<<-EOF
|
237
|
+
select distinct #{@attribute.to_s}, lower(#{@attribute.to_s})
|
238
|
+
from #{@ar_model.table_name}
|
239
|
+
where (#{@options[:conditions]})
|
240
|
+
or #{conn.quote_column_name( @attribute.to_s )} = #{conn.quote( model_index.attribute_value )}
|
241
|
+
order by lower(#{@attribute.to_s})
|
242
|
+
EOF
|
243
|
+
end
|
244
|
+
|
245
|
+
def query_order_frequency( conn, model_index )
|
246
|
+
<<-EOF
|
247
|
+
select distinct #{@attribute.to_s}, count(#{@attribute.to_s})
|
248
|
+
from #{@ar_model.table_name}
|
249
|
+
where (#{@options[:conditions]})
|
250
|
+
or #{conn.quote_column_name( @attribute.to_s )} = #{conn.quote( model_index.attribute_value )}
|
251
|
+
group by #{@attribute.to_s}
|
252
|
+
order by count(#{@attribute.to_s}) desc
|
253
|
+
EOF
|
254
|
+
end
|
255
|
+
|
256
|
+
def populate( editor, model_index )
|
257
|
+
# we only use the first column, so use the second
|
258
|
+
# column to sort by, since SQL requires the order by clause
|
259
|
+
# to be in the select list where distinct is involved
|
260
|
+
conn = @ar_model.connection
|
261
|
+
query =
|
262
|
+
if @options[:frequency]
|
263
|
+
query_order_frequency( conn, model_index )
|
264
|
+
else
|
265
|
+
query_order_description( conn, model_index )
|
266
|
+
end
|
267
|
+
rs = conn.execute( query )
|
268
|
+
rs.each do |row|
|
269
|
+
editor.add_item( row[0], row[0].to_variant )
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
def translate_from_editor_text( editor, text )
|
274
|
+
text
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# A Combo box which only allows a restricted set of value to be entered.
|
279
|
+
class RestrictedDelegate < ComboDelegate
|
280
|
+
# options must contain a :set => [ ... ] to specify the set of values.
|
281
|
+
def initialize( parent, attribute, model_class, options )
|
282
|
+
raise "RestrictedDelegate must have a :set in options" unless options.has_key?( :set )
|
283
|
+
@ar_model = model_class
|
284
|
+
@attribute = attribute
|
285
|
+
@options = options
|
286
|
+
@set = options[:set]
|
287
|
+
super( parent )
|
288
|
+
end
|
289
|
+
|
290
|
+
def needs_combo?
|
291
|
+
true
|
292
|
+
end
|
293
|
+
|
294
|
+
def restricted?
|
295
|
+
true
|
296
|
+
end
|
297
|
+
|
298
|
+
def populate( editor, model_index )
|
299
|
+
@set.each do |item|
|
300
|
+
editor.add_item( item, item.to_variant )
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
# Edit a relation from an id and display a list of relevant entries.
|
306
|
+
#
|
307
|
+
# attribute_path is the full dotted path to get from the entity in the
|
308
|
+
# model to the values displayed in the combo box.
|
309
|
+
#
|
310
|
+
# The ids of the ActiveRecord models are stored in the item data
|
311
|
+
# and the item text is fetched from them using attribute_path.
|
312
|
+
class RelationalDelegate < ComboDelegate
|
313
|
+
|
314
|
+
def initialize( parent, attribute_path, options )
|
315
|
+
@model_class = ( options[:class_name] || attribute_path[0].to_s.classify ).constantize
|
316
|
+
@attribute_path = attribute_path[1..-1].join('.')
|
317
|
+
@options = options.clone
|
318
|
+
[ :class_name, :sample, :format ].each {|x| @options.delete x }
|
319
|
+
super( parent )
|
320
|
+
end
|
321
|
+
|
322
|
+
def needs_combo?
|
323
|
+
@model_class.count( :conditions => @options[:conditions] ) > 0
|
324
|
+
end
|
325
|
+
|
326
|
+
def empty_set_message
|
327
|
+
"There must be records in #{@model_class.name.humanize} for this field to be editable."
|
328
|
+
end
|
329
|
+
|
330
|
+
# add the current item, unless it's already in the combo data
|
331
|
+
def populate_current( editor, model_index )
|
332
|
+
# always add the current selection, if it isn't already there
|
333
|
+
# and it makes sense. This is to make sure that if the list
|
334
|
+
# is filtered, we always have the current value if the filter
|
335
|
+
# excludes it
|
336
|
+
unless model_index.nil?
|
337
|
+
item = model_index.attribute_value
|
338
|
+
if item
|
339
|
+
item_index = editor.find_data( item.id.to_variant )
|
340
|
+
if item_index == -1
|
341
|
+
editor.add_item( item[@attribute_path], item.id.to_variant )
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
def populate( editor, model_index )
|
348
|
+
# add set of all possible related entities
|
349
|
+
@model_class.find( :all, collect_finder_options( @options ) ).each do |x|
|
350
|
+
editor.add_item( x[@attribute_path], x.id.to_variant )
|
351
|
+
end
|
352
|
+
end
|
353
|
+
|
354
|
+
# send data to the editor
|
355
|
+
def setEditorData( editor, model_index )
|
356
|
+
if is_combo?( editor )
|
357
|
+
editor.current_index = editor.find_data( model_index.attribute_value.id.to_variant )
|
358
|
+
editor.line_edit.select_all
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
# don't allow new values
|
363
|
+
def restricted?
|
364
|
+
true
|
365
|
+
end
|
366
|
+
|
367
|
+
# return an AR entity object
|
368
|
+
def translate_from_editor_text( editor, text )
|
369
|
+
item_index = editor.find_text( text )
|
370
|
+
|
371
|
+
# fetch record id from editor item_data
|
372
|
+
item_data = editor.item_data( item_index )
|
373
|
+
if item_data.valid?
|
374
|
+
# get the entity it refers to, if there is one
|
375
|
+
# use find_by_id so that if it's not found, nil will
|
376
|
+
# be returned
|
377
|
+
@model_class.find_by_id( item_data.to_int )
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
end
|
382
|
+
|
383
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# extensions specific to clevic
|
2
|
+
|
3
|
+
require 'qtext/flags.rb'
|
4
|
+
|
5
|
+
module ActiveRecord
|
6
|
+
class Base
|
7
|
+
# recursively calls each entry in path_ary
|
8
|
+
def evaluate_path( path_ary )
|
9
|
+
path_ary.inject( self ) do |value, att|
|
10
|
+
if value.nil?
|
11
|
+
nil
|
12
|
+
else
|
13
|
+
value.send( att )
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.has_attribute?( attribute_sym )
|
19
|
+
if column_names.include?( attribute_sym.to_s )
|
20
|
+
true
|
21
|
+
elsif reflections.has_key?( attribute_sym )
|
22
|
+
true
|
23
|
+
else
|
24
|
+
false
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.attribute_names
|
29
|
+
( column_names + reflections.keys.map {|sym| sym.to_s} ).sort
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# convenience methods
|
35
|
+
module Qt
|
36
|
+
|
37
|
+
PasteRole = UserRole + 1
|
38
|
+
|
39
|
+
class ItemDelegate
|
40
|
+
# overridden in EntryDelegate subclasses
|
41
|
+
def full_edit
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# This provides a bunch of methods to get easy access to the entity
|
46
|
+
# and it's values directly from the index without having to keep
|
47
|
+
# asking the model and jumping through other unncessary hoops
|
48
|
+
class ModelIndex
|
49
|
+
# the value to be displayed in the gui for this index
|
50
|
+
def gui_value
|
51
|
+
return nil if entity.nil?
|
52
|
+
entity.evaluate_path( attribute_path )
|
53
|
+
end
|
54
|
+
|
55
|
+
# set the value returned from the gui, as whatever the underlying
|
56
|
+
# entity wants it to be
|
57
|
+
# TODO this will break for more than 2 objects in a path
|
58
|
+
def gui_value=( obj )
|
59
|
+
entity.send( "#{model.dots[column]}=", obj )
|
60
|
+
end
|
61
|
+
|
62
|
+
def dump
|
63
|
+
<<-EOF
|
64
|
+
field_name: #{field_name}
|
65
|
+
field_value: #{field_value}
|
66
|
+
dotted_path: #{dotted_path.inspect}
|
67
|
+
attribute_path: #{attribute_path.inspect}
|
68
|
+
attribute: #{attribute.inspect}
|
69
|
+
attribute_value: #{attribute_value.inspect}
|
70
|
+
metadata: #{metadata.inspect}
|
71
|
+
EOF
|
72
|
+
end
|
73
|
+
|
74
|
+
# return the attribute of the underlying entity corresponding
|
75
|
+
# to the column of this index
|
76
|
+
def attribute
|
77
|
+
model.attributes[column]
|
78
|
+
end
|
79
|
+
|
80
|
+
# fetch the value of the attribute, without following
|
81
|
+
# the full path. This will return a related entity for
|
82
|
+
# belongs_to or has_one relationships, or a plain value
|
83
|
+
# for model attributes
|
84
|
+
def attribute_value
|
85
|
+
entity.send( attribute )
|
86
|
+
end
|
87
|
+
|
88
|
+
# set the value of the attribute, without following the
|
89
|
+
# full path
|
90
|
+
def attribute_value=( obj )
|
91
|
+
entity.send( "#{attribute.to_s}=", obj )
|
92
|
+
end
|
93
|
+
|
94
|
+
# the dotted attribute path, same as a 'column' in the model
|
95
|
+
def dotted_path
|
96
|
+
model.dots[column]
|
97
|
+
end
|
98
|
+
|
99
|
+
# return an array of path elements from dotted_path
|
100
|
+
def attribute_path
|
101
|
+
return nil if model.nil?
|
102
|
+
model.attribute_paths[column]
|
103
|
+
end
|
104
|
+
|
105
|
+
# returns the ActiveRecord column_for_attribute
|
106
|
+
def metadata
|
107
|
+
# use the optimised version
|
108
|
+
model.metadata( column )
|
109
|
+
end
|
110
|
+
|
111
|
+
# return the table's field name. For associations, this would
|
112
|
+
# be suffixed with _id
|
113
|
+
def field_name
|
114
|
+
metadata.name
|
115
|
+
end
|
116
|
+
|
117
|
+
# return the value of the field, it the _id value
|
118
|
+
def field_value
|
119
|
+
entity.send( field_name )
|
120
|
+
end
|
121
|
+
|
122
|
+
# the underlying entity
|
123
|
+
def entity
|
124
|
+
return nil if model.nil?
|
125
|
+
#~ puts "fetching entity from collection for xy=(#{row},#{column})" if @entity.nil?
|
126
|
+
@entity ||= model.collection[row]
|
127
|
+
end
|
128
|
+
|
129
|
+
attr_writer :entity
|
130
|
+
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|