]> git.openstreetmap.org Git - rails.git/blob - app/controllers/changeset_controller.rb
adding link to download the full changeset xml
[rails.git] / app / controllers / changeset_controller.rb
1 # The ChangesetController is the RESTful interface to Changeset objects
2
3 class ChangesetController < ApplicationController
4   require 'xml/libxml'
5
6   before_filter :authorize, :only => [:create, :update, :delete, :upload, :include, :close]
7   before_filter :check_write_availability, :only => [:create, :update, :delete, :upload, :include]
8   before_filter :check_read_availability, :except => [:create, :update, :delete, :upload, :download, :query]
9   after_filter :compress_output
10
11   # Help methods for checking boundary sanity and area size
12   include MapBoundary
13
14   # Helper methods for checking consistency
15   include ConsistencyValidations
16
17   # Create a changeset from XML.
18   def create
19     if request.put?
20       cs = Changeset.from_xml(request.raw_post, true)
21
22       if cs
23         cs.user_id = @user.id
24         cs.save_with_tags!
25         render :text => cs.id.to_s, :content_type => "text/plain"
26       else
27         render :nothing => true, :status => :bad_request
28       end
29     else
30       render :nothing => true, :status => :method_not_allowed
31     end
32   end
33
34   ##
35   # Return XML giving the basic info about the changeset. Does not 
36   # return anything about the nodes, ways and relations in the changeset.
37   def read
38     begin
39       changeset = Changeset.find(params[:id])
40       render :text => changeset.to_xml.to_s, :content_type => "text/xml"
41     rescue ActiveRecord::RecordNotFound
42       render :nothing => true, :status => :not_found
43     end
44   end
45   
46   ##
47   # marks a changeset as closed. this may be called multiple times
48   # on the same changeset, so is idempotent.
49   def close 
50     unless request.put?
51       render :nothing => true, :status => :method_not_allowed
52       return
53     end
54     
55     changeset = Changeset.find(params[:id])    
56     check_changeset_consistency(changeset, @user)
57
58     # to close the changeset, we'll just set its closed_at time to
59     # now. this might not be enough if there are concurrency issues, 
60     # but we'll have to wait and see.
61     changeset.set_closed_time_now
62
63     changeset.save!
64     render :nothing => true
65   rescue ActiveRecord::RecordNotFound
66     render :nothing => true, :status => :not_found
67   rescue OSM::APIError => ex
68     render ex.render_opts
69   end
70
71   ##
72   # insert a (set of) points into a changeset bounding box. this can only
73   # increase the size of the bounding box. this is a hint that clients can
74   # set either before uploading a large number of changes, or changes that
75   # the client (but not the server) knows will affect areas further away.
76   def expand_bbox
77     # only allow POST requests, because although this method is
78     # idempotent, there is no "document" to PUT really...
79     if request.post?
80       cs = Changeset.find(params[:id])
81       check_changeset_consistency(cs, @user)
82
83       # keep an array of lons and lats
84       lon = Array.new
85       lat = Array.new
86
87       # the request is in pseudo-osm format... this is kind-of an
88       # abuse, maybe should change to some other format?
89       doc = XML::Parser.string(request.raw_post).parse
90       doc.find("//osm/node").each do |n|
91         lon << n['lon'].to_f * GeoRecord::SCALE
92         lat << n['lat'].to_f * GeoRecord::SCALE
93       end
94
95       # add the existing bounding box to the lon-lat array
96       lon << cs.min_lon unless cs.min_lon.nil?
97       lat << cs.min_lat unless cs.min_lat.nil?
98       lon << cs.max_lon unless cs.max_lon.nil?
99       lat << cs.max_lat unless cs.max_lat.nil?
100
101       # collapse the arrays to minimum and maximum
102       cs.min_lon, cs.min_lat, cs.max_lon, cs.max_lat = 
103         lon.min, lat.min, lon.max, lat.max
104
105       # save the larger bounding box and return the changeset, which
106       # will include the bigger bounding box.
107       cs.save!
108       render :text => cs.to_xml.to_s, :content_type => "text/xml"
109
110     else
111       render :nothing => true, :status => :method_not_allowed
112     end
113
114   rescue ActiveRecord::RecordNotFound
115     render :nothing => true, :status => :not_found
116   rescue OSM::APIError => ex
117     render ex.render_opts
118   end
119
120   ##
121   # Upload a diff in a single transaction.
122   #
123   # This means that each change within the diff must succeed, i.e: that
124   # each version number mentioned is still current. Otherwise the entire
125   # transaction *must* be rolled back.
126   #
127   # Furthermore, each element in the diff can only reference the current
128   # changeset.
129   #
130   # Returns: a diffResult document, as described in 
131   # http://wiki.openstreetmap.org/index.php/OSM_Protocol_Version_0.6
132   def upload
133     # only allow POST requests, as the upload method is most definitely
134     # not idempotent, as several uploads with placeholder IDs will have
135     # different side-effects.
136     # see http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.1.2
137     unless request.post?
138       render :nothing => true, :status => :method_not_allowed
139       return
140     end
141
142     changeset = Changeset.find(params[:id])
143     check_changeset_consistency(changeset, @user)
144     
145     diff_reader = DiffReader.new(request.raw_post, changeset)
146     Changeset.transaction do
147       result = diff_reader.commit
148       render :text => result.to_s, :content_type => "text/xml"
149     end
150     
151   rescue ActiveRecord::RecordNotFound
152     render :nothing => true, :status => :not_found
153   rescue OSM::APIError => ex
154     render ex.render_opts
155   end
156
157   ##
158   # download the changeset as an osmChange document.
159   #
160   # to make it easier to revert diffs it would be better if the osmChange
161   # format were reversible, i.e: contained both old and new versions of 
162   # modified elements. but it doesn't at the moment...
163   #
164   # this method cannot order the database changes fully (i.e: timestamp and
165   # version number may be too coarse) so the resulting diff may not apply
166   # to a different database. however since changesets are not atomic this 
167   # behaviour cannot be guaranteed anyway and is the result of a design
168   # choice.
169   def download
170     changeset = Changeset.find(params[:id])
171     
172     # get all the elements in the changeset and stick them in a big array.
173     elements = [changeset.old_nodes, 
174                 changeset.old_ways, 
175                 changeset.old_relations].flatten
176     
177     # sort the elements by timestamp and version number, as this is the 
178     # almost sensible ordering available. this would be much nicer if 
179     # global (SVN-style) versioning were used - then that would be 
180     # unambiguous.
181     elements.sort! do |a, b| 
182       if (a.timestamp == b.timestamp)
183         a.version <=> b.version
184       else
185         a.timestamp <=> b.timestamp 
186       end
187     end
188     
189     # create an osmChange document for the output
190     result = OSM::API.new.get_xml_doc
191     result.root.name = "osmChange"
192
193     # generate an output element for each operation. note: we avoid looking
194     # at the history because it is simpler - but it would be more correct to 
195     # check these assertions.
196     elements.each do |elt|
197       result.root <<
198         if (elt.version == 1) 
199           # first version, so it must be newly-created.
200           created = XML::Node.new "create"
201           created << elt.to_xml_node
202         else
203           # get the previous version from the element history
204           prev_elt = elt.class.find(:first, :conditions => 
205                                     ['id = ? and version = ?',
206                                      elt.id, elt.version])
207           unless elt.visible
208             # if the element isn't visible then it must have been deleted, so
209             # output the *previous* XML
210             deleted = XML::Node.new "delete"
211             deleted << prev_elt.to_xml_node
212           else
213             # must be a modify, for which we don't need the previous version
214             # yet...
215             modified = XML::Node.new "modify"
216             modified << elt.to_xml_node
217           end
218         end
219     end
220
221     render :text => result.to_s, :content_type => "text/xml"
222             
223   rescue ActiveRecord::RecordNotFound
224     render :nothing => true, :status => :not_found
225   rescue OSM::APIError => ex
226     render ex.render_opts
227   end
228
229   ##
230   # query changesets by bounding box, time, user or open/closed status.
231   def query
232     # create the conditions that the user asked for. some or all of
233     # these may be nil.
234     conditions = conditions_bbox(params['bbox'])
235     conditions = cond_merge conditions, conditions_user(params['user'])
236     conditions = cond_merge conditions, conditions_time(params['time'])
237     conditions = cond_merge conditions, conditions_open(params['open'])
238
239     # create the results document
240     results = OSM::API.new.get_xml_doc
241
242     # add all matching changesets to the XML results document
243     Changeset.find(:all, 
244                    :conditions => conditions, 
245                    :limit => 100,
246                    :order => 'created_at desc').each do |cs|
247       results.root << cs.to_xml_node
248     end
249
250     render :text => results.to_s, :content_type => "text/xml"
251
252   rescue ActiveRecord::RecordNotFound
253     render :nothing => true, :status => :not_found
254   rescue OSM::APIError => ex
255     render ex.render_opts
256   end
257   
258   ##
259   # updates a changeset's tags. none of the changeset's attributes are
260   # user-modifiable, so they will be ignored.
261   #
262   # changesets are not (yet?) versioned, so we don't have to deal with
263   # history tables here. changesets are locked to a single user, however.
264   #
265   # after succesful update, returns the XML of the changeset.
266   def update
267     # request *must* be a PUT.
268     unless request.put?
269       render :nothing => true, :status => :method_not_allowed
270       return
271     end
272     
273     changeset = Changeset.find(params[:id])
274     new_changeset = Changeset.from_xml(request.raw_post)
275
276     unless new_changeset.nil?
277       check_changeset_consistency(changeset, @user)
278       changeset.update_from(new_changeset, @user)
279       render :text => changeset.to_xml, :mime_type => "text/xml"
280     else
281       
282       render :nothing => true, :status => :bad_request
283     end
284       
285   rescue ActiveRecord::RecordNotFound
286     render :nothing => true, :status => :not_found
287   rescue OSM::APIError => ex
288     render ex.render_opts
289   end
290
291 private
292   #------------------------------------------------------------
293   # utility functions below.
294   #------------------------------------------------------------  
295
296   ##
297   # merge two conditions
298   def cond_merge(a, b)
299     if a and b
300       a_str = a.shift
301       b_str = b.shift
302       return [ a_str + " and " + b_str ] + a + b
303     elsif a 
304       return a
305     else b
306       return b
307     end
308   end
309
310   ##
311   # if a bounding box was specified then parse it and do some sanity 
312   # checks. this is mostly the same as the map call, but without the 
313   # area restriction.
314   def conditions_bbox(bbox)
315     unless bbox.nil?
316       raise OSM::APIBadUserInput.new("Bounding box should be min_lon,min_lat,max_lon,max_lat") unless bbox.count(',') == 3
317       bbox = sanitise_boundaries(bbox.split(/,/))
318       raise OSM::APIBadUserInput.new("Minimum longitude should be less than maximum.") unless bbox[0] <= bbox[2]
319       raise OSM::APIBadUserInput.new("Minimum latitude should be less than maximum.") unless bbox[1] <= bbox[3]
320       return ['min_lon < ? and max_lon > ? and min_lat < ? and max_lat > ?',
321               bbox[2] * GeoRecord::SCALE, bbox[0] * GeoRecord::SCALE, bbox[3]* GeoRecord::SCALE, bbox[1] * GeoRecord::SCALE]
322     else
323       return nil
324     end
325   end
326
327   ##
328   # restrict changesets to those by a particular user
329   def conditions_user(user)
330     unless user.nil?
331       # user input checking, we don't have any UIDs < 1
332       raise OSM::APIBadUserInput.new("invalid user ID") if user.to_i < 1
333
334       u = User.find(user.to_i)
335       # should be able to get changesets of public users only, or 
336       # our own changesets regardless of public-ness.
337       unless u.data_public?
338         # get optional user auth stuff so that users can see their own
339         # changesets if they're non-public
340         setup_user_auth
341         
342         raise OSM::APINotFoundError if @user.nil? or @user.id != u.id
343       end
344       return ['user_id = ?', u.id]
345     else
346       return nil
347     end
348   end
349
350   ##
351   # restrict changes to those during a particular time period
352   def conditions_time(time) 
353     unless time.nil?
354       # if there is a range, i.e: comma separated, then the first is 
355       # low, second is high - same as with bounding boxes.
356       if time.count(',') == 1
357         # check that we actually have 2 elements in the array
358         times = time.split(/,/)
359         raise OSM::APIBadUserInput.new("bad time range") if times.size != 2 
360
361         from, to = times.collect { |t| DateTime.parse(t) }
362         return ['closed_at >= ? and created_at <= ?', from, to]
363       else
364         # if there is no comma, assume its a lower limit on time
365         return ['closed_at >= ?', DateTime.parse(time)]
366       end
367     else
368       return nil
369     end
370     # stupid DateTime seems to throw both of these for bad parsing, so
371     # we have to catch both and ensure the correct code path is taken.
372   rescue ArgumentError => ex
373     raise OSM::APIBadUserInput.new(ex.message.to_s)
374   rescue RuntimeError => ex
375     raise OSM::APIBadUserInput.new(ex.message.to_s)
376   end
377
378   ##
379   # restrict changes to those which are open
380   def conditions_open(open)
381     return open.nil? ? nil : ['closed_at >= ?', DateTime.now]
382   end
383
384 end