backend.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. import cgi
  4. import os
  5. import sys
  6. import sqlite3
  7. import urlparse
  8. import datetime
  9. import json
  10. from email import utils
  11. from os.path import join, dirname, exists
  12. import bottle
  13. from bottle import route, run, static_file, request, template, FormsDict, redirect, response, Bottle
  14. URL_PREFIX = os.environ.get('URL_PREFIX', '')
  15. ORIENTATIONS = (
  16. ('N', 'Nord'),
  17. ('NO', 'Nord-Ouest'),
  18. ('O', 'Ouest'),
  19. ('SO', 'Sud-Ouest'),
  20. ('S', 'Sud'),
  21. ('SE', 'Sud-Est'),
  22. ('E', 'Est'),
  23. ('NE', 'Nord-Est'),
  24. )
  25. # Angular sector for each direction, written as (start, stop) in degrees
  26. ANGLES = {
  27. 'N': (-23, 22),
  28. 'NO': (292, 337),
  29. 'O': (247, 292),
  30. 'SO': (202, 247),
  31. 'S': (157, 202),
  32. 'SE': (112, 157),
  33. 'E': (67, 112),
  34. 'NE': (22, 67)
  35. }
  36. TABLE_NAME = 'contribs'
  37. DB_FILENAME = join(dirname(__file__), 'db.sqlite3')
  38. DB = sqlite3.connect(DB_FILENAME)
  39. DB_COLS = (
  40. ('id', 'INTEGER PRIMARY KEY'),
  41. ('name', 'TEXT'),
  42. ('contrib_type', 'TEXT'),
  43. ('latitude', 'REAL'),
  44. ('longitude', 'REAL'),
  45. ('phone', 'TEXT'),
  46. ('email', 'TEXT'),
  47. ('access_type', 'TEXT'),
  48. ('connect_local', 'INTEGER'),
  49. ('connect_internet', 'INTEGER'),
  50. ('bandwidth', 'REAL'),
  51. ('share_part', 'REAL'),
  52. ('floor', 'INTEGER'),
  53. ('floor_total', 'INTEGER'),
  54. ('orientations', 'TEXT'),
  55. ('roof', 'INTEGER'),
  56. ('comment', 'TEXT'),
  57. ('privacy_name', 'INTEGER'),
  58. ('privacy_email', 'INTEGER'),
  59. ('privacy_coordinates', 'INTEGER'),
  60. ('privacy_place_details', 'INTEGER'),
  61. ('privacy_comment', 'INTEGER'),
  62. ('date', 'TEXT'),
  63. )
  64. GEOJSON_NAME = 'public.json'
  65. GEOJSON_LICENSE_TYPE = 'ODC-BY-1.0'
  66. GEOJSON_LICENSE_URL = 'http://opendatacommons.org/licenses/by/1.0/'
  67. ANTISPAM_FIELD = 'url'
  68. app = Bottle()
  69. @app.route('/')
  70. def home():
  71. redirect(urlparse.urljoin(request.path,join(URL_PREFIX, 'wifi-form')))
  72. @app.route('/wifi-form')
  73. def show_wifi_form():
  74. return template('wifi-form', errors=None, data = FormsDict(),
  75. orientations=ORIENTATIONS, geojson=GEOJSON_NAME)
  76. def create_tabble(db, name, columns):
  77. col_defs = ','.join(['{} {}'.format(*i) for i in columns])
  78. db.execute('CREATE TABLE {} ({})'.format(name, col_defs))
  79. def escape(s):
  80. if not isinstance(s, (bool, float, int)) and (s != None):
  81. return cgi.escape(s)
  82. else:
  83. return s
  84. def json_url(json_filename):
  85. """ Returns (relative) json URL with a querystring mentioning file mtime
  86. That's to prevent too much browser caching (mtime will change on file
  87. generation, changing querystring) while letting browser doing relevant
  88. caching.
  89. """
  90. file_path = join(dirname(__file__), 'json/', json_filename)
  91. mtime = os.path.getmtime(file_path)
  92. return '{}?mtime={}'.format(json_filename, mtime)
  93. def save_to_db(db, dic):
  94. # SQLite is picky about encoding else
  95. tosave = {bytes(k):escape(v.decode('utf-8')) if isinstance(v,str)
  96. else escape(v)
  97. for k,v in dic.items()}
  98. tosave['date'] = utils.formatdate()
  99. return db.execute("""
  100. INSERT INTO {}
  101. (name, contrib_type, latitude, longitude, phone, email, access_type, connect_local, connect_internet, bandwidth, share_part, floor, floor_total, orientations, roof, comment,
  102. privacy_name, privacy_email, privacy_place_details, privacy_coordinates, privacy_comment, date)
  103. VALUES (:name, :contrib_type, :latitude, :longitude, :phone, :email, :access_type, :connect_local, :connect_internet, :bandwidth, :share_part, :floor, :floor_total, :orientations, :roof, :comment,
  104. :privacy_name, :privacy_email, :privacy_place_details, :privacy_coordinates, :privacy_comment, :date)
  105. """.format(TABLE_NAME), tosave)
  106. @app.route('/wifi-form', method='POST')
  107. def submit_wifi_form():
  108. required = ('name', 'contrib-type',
  109. 'latitude', 'longitude')
  110. required_or = (('email', 'phone'),)
  111. required_if = (
  112. ('contrib-type', 'share',('access-type', 'bandwidth',
  113. 'share-part')),
  114. )
  115. field_names = {
  116. 'name' : 'Nom/Pseudo',
  117. 'contrib-type': 'Type de participation',
  118. 'latitude' : 'Localisation',
  119. 'longitude' : 'Localisation',
  120. 'phone' : 'Téléphone',
  121. 'email' : 'Email',
  122. 'access-type' : 'Type de connexion',
  123. 'bandwidth' : 'Bande passante',
  124. 'share-part' : 'Débit partagé',
  125. 'floor' : 'Étage',
  126. 'floor_total' : 'Nombre d\'étages total'
  127. }
  128. errors = []
  129. if request.forms.get(ANTISPAM_FIELD):
  130. errors.append(('', "Une erreur s'est produite"))
  131. for name in required:
  132. if (not request.forms.get(name)):
  133. errors.append((field_names[name], 'ce champ est requis'))
  134. for name_list in required_or:
  135. filleds = [True for name in name_list if request.forms.get(name)]
  136. if len(filleds) <= 0:
  137. errors.append((
  138. ' ou '.join([field_names[i] for i in name_list]),
  139. 'au moins un des de ces champs est requis'))
  140. for key, value, fields in required_if:
  141. if request.forms.get(key) == value:
  142. for name in fields:
  143. if not request.forms.get(name):
  144. errors.append(
  145. (field_names[name], 'ce champ est requis'))
  146. floor = request.forms.get('floor')
  147. floor_total = request.forms.get('floor_total')
  148. if floor and not floor_total:
  149. errors.append((field_names['floor_total'], "ce champ est requis"))
  150. if not floor and floor_total:
  151. errors.append((field_names['floor'], "ce champ est requis"))
  152. if floor and floor_total and (int(floor) > int(floor_total)):
  153. errors.append((field_names['floor'], "Étage supérieur au nombre total"))
  154. if floor and (int(floor) < 0):
  155. errors.append((field_names['floor'], "l'étage doit-être positif"))
  156. if floor_total and (int(floor_total) < 0):
  157. errors.append((field_names['floor_total'], "le nombre d'étages doit-être positif"))
  158. if errors:
  159. return template('wifi-form', errors=errors, data=request.forms,
  160. orientations=ORIENTATIONS, geojson=json_url(GEOJSON_NAME))
  161. else:
  162. d = request.forms
  163. save_to_db(DB, {
  164. 'name' : d.get('name'),
  165. 'contrib_type' : d.get('contrib-type'),
  166. 'latitude' : d.get('latitude'),
  167. 'longitude' : d.get('longitude'),
  168. 'phone' : d.get('phone'),
  169. 'email' : d.get('email'),
  170. 'phone' : d.get('phone'),
  171. 'access_type' : d.get('access-type'),
  172. 'connect_local' : 'local' in d.getall('connect-type'),
  173. 'connect_internet' : 'internet' in d.getall('connect-type'),
  174. 'bandwidth' : d.get('bandwidth'),
  175. 'share_part' : d.get('share-part'),
  176. 'floor' : d.get('floor'),
  177. 'floor_total' : d.get('floor_total'),
  178. 'orientations' : ','.join(d.getall('orientation')),
  179. 'roof' : d.get('roof'),
  180. 'comment' : d.get('comment'),
  181. 'privacy_name' : 'name' in d.getall('privacy'),
  182. 'privacy_email' : 'email' in d.getall('privacy'),
  183. 'privacy_place_details': 'place_details' in d.getall('privacy'),
  184. 'privacy_coordinates' : 'coordinates' in d.getall('privacy'),
  185. 'privacy_comment' : 'comment' in d.getall('privacy'),
  186. })
  187. DB.commit()
  188. # Rebuild GeoJSON
  189. build_geojson()
  190. return redirect(urlparse.urljoin(request.path,join(URL_PREFIX,'thanks')))
  191. @app.route('/thanks')
  192. def wifi_form_thanks():
  193. return template('thanks')
  194. @app.route('/assets/<filename:path>')
  195. def send_asset(filename):
  196. for i in STATIC_DIRS:
  197. path = join(i, filename)
  198. if exists(path):
  199. return static_file(filename, root=i)
  200. raise bottle.HTTPError(404)
  201. @app.route('/legal')
  202. def legal():
  203. return template('legal')
  204. """
  205. Results Map
  206. """
  207. @app.route('/map')
  208. def public_map():
  209. return template('map', geojson=json_url(GEOJSON_NAME))
  210. @app.route('/public.json')
  211. def public_geojson():
  212. return static_file('public.json', root=join(dirname(__file__), 'json/'))
  213. """
  214. GeoJSON Functions
  215. """
  216. # Useful for merging angle intervals (orientations)
  217. def merge_intervals(l, wrap=360):
  218. """Merge a list of intervals, assuming the space is cyclic. The
  219. intervals should already by sorted by start value."""
  220. if l == []:
  221. return []
  222. result = list()
  223. # Transform the 2-tuple into a 2-list to be able to modify it
  224. result.append(list(l[0]))
  225. for (start, stop) in l:
  226. current = result[-1]
  227. if start > current[1]:
  228. result.append([start, stop])
  229. else:
  230. result[-1][1] = max(result[-1][1], stop)
  231. if len(result) == 1:
  232. return result
  233. # Handle the cyclicity by merging the ends if necessary
  234. last = result[-1]
  235. first = result[0]
  236. if first[0] <= last[1] - wrap:
  237. result[-1][1] = max(result[-1][1], first[1] + wrap)
  238. result.pop(0)
  239. return result
  240. def orientations_to_angle(orientations):
  241. """Return a list of (start, stop) angles from a list of orientations."""
  242. # Cleanup
  243. orientations = [o for o in orientations if o in ANGLES.keys()]
  244. # Hack to make leaflet-semicircle happy (drawing a full circle only
  245. # works with (0, 360))
  246. if len(orientations) == 8:
  247. return [[0, 360]]
  248. angles = [ANGLES[orientation] for orientation in orientations]
  249. angles.sort(key=lambda (x, y): x)
  250. return merge_intervals(angles)
  251. # Save feature collection to a json file
  252. def save_featurecollection_json(id, features, license=None):
  253. with open('json/' + id + '.json', 'w') as outfile:
  254. geojson = {
  255. "type" : "FeatureCollection",
  256. "features" : features,
  257. "id" : id,
  258. }
  259. if license:
  260. geojson['license'] = license
  261. json.dump(geojson, outfile)
  262. # Build GeoJSON files from DB
  263. def build_geojson():
  264. # Read from DB
  265. DB.row_factory = sqlite3.Row
  266. cur = DB.execute("""
  267. SELECT * FROM {} ORDER BY id DESC
  268. """.format(TABLE_NAME))
  269. public_features = []
  270. private_features = []
  271. # Loop through results
  272. rows = cur.fetchall()
  273. for row in rows:
  274. orientations = row['orientations'].split(',')
  275. if row['roof'] == "on":
  276. angles = [(0, 360)]
  277. else:
  278. angles = orientations_to_angle(orientations)
  279. # Private JSON file
  280. private_features.append({
  281. "type" : "Feature",
  282. "geometry" : {
  283. "type": "Point",
  284. "coordinates": [row['longitude'], row['latitude']],
  285. },
  286. "id" : row['id'],
  287. "properties": {
  288. "name" : row['name'],
  289. "place" : {
  290. 'floor' : row['floor'],
  291. 'floor_total' : row['floor_total'],
  292. 'orientations' : orientations,
  293. 'angles' : angles,
  294. 'roof' : row['roof'],
  295. 'contrib_type' : row['contrib_type']
  296. },
  297. "comment" : row['comment']
  298. }
  299. })
  300. # Bypass non-public points
  301. if not row['privacy_coordinates']:
  302. continue
  303. # Public JSON file
  304. public_feature = {
  305. "type" : "Feature",
  306. "geometry" : {
  307. "type": "Point",
  308. "coordinates": [row['longitude'], row['latitude']],
  309. },
  310. "id" : row['id'],
  311. "properties": {'contrib_type': row['contrib_type']}
  312. }
  313. # Add optionnal variables
  314. if row['privacy_name']:
  315. public_feature['properties']['name'] = row['name']
  316. if row['privacy_comment']:
  317. public_feature['properties']['comment'] = row['comment']
  318. if row['privacy_place_details']:
  319. public_feature['properties']['place'] = {
  320. 'floor' : row['floor'],
  321. 'floor_total' : row['floor_total'],
  322. 'orientations' : orientations,
  323. 'angles' : angles,
  324. 'roof' : row['roof'],
  325. }
  326. # Add to public features list
  327. public_features.append(public_feature)
  328. # Build GeoJSON Feature Collection
  329. save_featurecollection_json('private', private_features)
  330. public_json_license = {
  331. "type" : GEOJSON_LICENSE_TYPE,
  332. "url" : GEOJSON_LICENSE_URL
  333. }
  334. save_featurecollection_json('public', public_features, public_json_license)
  335. DEBUG = bool(os.environ.get('DEBUG', False))
  336. LISTEN_ADDR= os.environ.get('BIND_ADDR', 'localhost')
  337. LISTEN_PORT= int(os.environ.get('BIND_PORT', 8080))
  338. URL_PREFIX = os.environ.get('URL_PREFIX', '').strip('/')
  339. CUSTOMIZATION_DIR = os.environ.get('CUSTOMIZATION_DIR', None)
  340. STATIC_DIRS = [join(dirname(__file__), 'assets')]
  341. if __name__ == '__main__':
  342. if len(sys.argv) > 1:
  343. if sys.argv[1] == 'createdb':
  344. create_tabble(DB, TABLE_NAME, DB_COLS)
  345. if sys.argv[1] == 'buildgeojson':
  346. build_geojson()
  347. else:
  348. if URL_PREFIX:
  349. print('Using url prefix "{}"'.format(URL_PREFIX))
  350. root_app = Bottle()
  351. root_app.mount('/{}/'.format(URL_PREFIX), app)
  352. run(root_app, host=LISTEN_ADDR, port=LISTEN_PORT, reloader=DEBUG)
  353. if CUSTOMIZATION_DIR:
  354. custom_templates_dir = join(CUSTOMIZATION_DIR, 'views')
  355. if exists(custom_templates_dir):
  356. bottle.TEMPLATE_PATH.insert(0, custom_templates_dir)
  357. custom_assets_dir = join(CUSTOMIZATION_DIR, 'assets')
  358. if exists(custom_assets_dir):
  359. STATIC_DIRS.insert(0, custom_assets_dir)
  360. run(app, host=LISTEN_ADDR, port=LISTEN_PORT, reloader=DEBUG)
  361. DB.close()