Browse Source

[enh] Replace submodules by subtrees

ljf 8 years ago
parent
commit
8854c9b5ca
97 changed files with 13669 additions and 4 deletions
  1. 0 3
      .gitmodules
  2. 0 1
      sources
  3. 9 0
      sources/.gitignore
  4. 14 0
      sources/LICENSE
  5. 103 0
      sources/README.md
  6. 12 0
      sources/TODO.md
  7. 420 0
      sources/backend.py
  8. 62 0
      sources/contrib/ansible/templates/init.j2
  9. 5 0
      sources/contrib/ansible/templates/lighttpd-80-wifiwithme.conf.j2
  10. 67 0
      sources/contrib/ansible/wifiwithme.yml
  11. 0 0
      sources/json/.placeholder
  12. 11 0
      sources/manage.py
  13. 4 0
      sources/requirements/base.txt
  14. 2 0
      sources/requirements/dev.txt
  15. 0 0
      sources/var/media/.placeholder
  16. 0 0
      sources/var/static/.placeholder
  17. 0 0
      sources/wifiwithme/__init__.py
  18. 0 0
      sources/wifiwithme/apps/__init__.py
  19. 0 0
      sources/wifiwithme/apps/contribmap/__init__.py
  20. 44 0
      sources/wifiwithme/apps/contribmap/admin.py
  21. 8 0
      sources/wifiwithme/apps/contribmap/apps.py
  22. 21 0
      sources/wifiwithme/apps/contribmap/decorators.py
  23. 39 0
      sources/wifiwithme/apps/contribmap/fields.py
  24. 14 0
      sources/wifiwithme/apps/contribmap/fixtures/bottle_data.yaml
  25. 96 0
      sources/wifiwithme/apps/contribmap/forms.py
  26. 48 0
      sources/wifiwithme/apps/contribmap/migrations/0001_initial.py
  27. 20 0
      sources/wifiwithme/apps/contribmap/migrations/0003_auto_20160303_1057.py
  28. 39 0
      sources/wifiwithme/apps/contribmap/migrations/0004_auto_20160303_1152.py
  29. 71 0
      sources/wifiwithme/apps/contribmap/migrations/0005_auto_20160303_1222.py
  30. 25 0
      sources/wifiwithme/apps/contribmap/migrations/0006_auto_20160303_1321.py
  31. 30 0
      sources/wifiwithme/apps/contribmap/migrations/0007_auto_20160303_1326.py
  32. 19 0
      sources/wifiwithme/apps/contribmap/migrations/0008_remove_contrib_old_date.py
  33. 25 0
      sources/wifiwithme/apps/contribmap/migrations/0009_auto_20160303_1357.py
  34. 109 0
      sources/wifiwithme/apps/contribmap/migrations/0010_auto_20160315_1644.py
  35. 20 0
      sources/wifiwithme/apps/contribmap/migrations/0011_auto_20160316_1623.py
  36. 36 0
      sources/wifiwithme/apps/contribmap/migrations/0012_auto_20160510_2335.py
  37. 23 0
      sources/wifiwithme/apps/contribmap/migrations/0013_auto_20160515_1036.py
  38. 28 0
      sources/wifiwithme/apps/contribmap/migrations/0014_auto_20160515_1050.py
  39. 0 0
      sources/wifiwithme/apps/contribmap/migrations/__init__.py
  40. 148 0
      sources/wifiwithme/apps/contribmap/models.py
  41. 114 0
      sources/wifiwithme/apps/contribmap/templates/base.html
  42. 35 0
      sources/wifiwithme/apps/contribmap/templates/contribmap/legal.html
  43. 1 0
      sources/wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_notice.subject
  44. 7 0
      sources/wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_notice.txt
  45. 34 0
      sources/wifiwithme/apps/contribmap/templates/contribmap/map.html
  46. 24 0
      sources/wifiwithme/apps/contribmap/templates/contribmap/thanks.html
  47. 200 0
      sources/wifiwithme/apps/contribmap/templates/contribmap/wifi-form.html
  48. 0 0
      sources/wifiwithme/apps/contribmap/templatetags/__init__.py
  49. 6 0
      sources/wifiwithme/apps/contribmap/templatetags/obfuscate_email.py
  50. 240 0
      sources/wifiwithme/apps/contribmap/tests.py
  51. 12 0
      sources/wifiwithme/apps/contribmap/urls.py
  52. 37 0
      sources/wifiwithme/apps/contribmap/utils.py
  53. 159 0
      sources/wifiwithme/apps/contribmap/views.py
  54. 0 0
      sources/wifiwithme/core/__init__.py
  55. 128 0
      sources/wifiwithme/core/py2_compat.py
  56. 0 0
      sources/wifiwithme/core/templates/.placeholder
  57. 6 0
      sources/wifiwithme/core/templates/registration/logged_out.html
  58. 21 0
      sources/wifiwithme/core/templates/registration/login.html
  59. 0 0
      sources/wifiwithme/core/templatetags/__init__.py
  60. 13 0
      sources/wifiwithme/core/templatetags/bootstrap.py
  61. 17 0
      sources/wifiwithme/core/urls.py
  62. 0 0
      sources/wifiwithme/settings/__init__.py
  63. 138 0
      sources/wifiwithme/settings/base.py
  64. 14 0
      sources/wifiwithme/settings/dev.py
  65. 37 0
      sources/wifiwithme/settings/prod.py
  66. 429 0
      sources/wifiwithme/static/bootstrap/config.json
  67. 473 0
      sources/wifiwithme/static/bootstrap/css/bootstrap-theme.css
  68. 1 0
      sources/wifiwithme/static/bootstrap/css/bootstrap-theme.css.map
  69. 10 0
      sources/wifiwithme/static/bootstrap/css/bootstrap-theme.min.css
  70. 6330 0
      sources/wifiwithme/static/bootstrap/css/bootstrap.css
  71. 1 0
      sources/wifiwithme/static/bootstrap/css/bootstrap.css.map
  72. 10 0
      sources/wifiwithme/static/bootstrap/css/bootstrap.min.css
  73. BIN
      sources/wifiwithme/static/bootstrap/fonts/glyphicons-halflings-regular.eot
  74. 229 0
      sources/wifiwithme/static/bootstrap/fonts/glyphicons-halflings-regular.svg
  75. BIN
      sources/wifiwithme/static/bootstrap/fonts/glyphicons-halflings-regular.ttf
  76. BIN
      sources/wifiwithme/static/bootstrap/fonts/glyphicons-halflings-regular.woff
  77. 2276 0
      sources/wifiwithme/static/bootstrap/js/bootstrap.js
  78. 7 0
      sources/wifiwithme/static/bootstrap/js/bootstrap.min.js
  79. 13 0
      sources/wifiwithme/static/bootstrap/js/npm.js
  80. 125 0
      sources/wifiwithme/static/form.js
  81. BIN
      sources/wifiwithme/static/img/background_main.jpg
  82. BIN
      sources/wifiwithme/static/img/background_main_767.jpg
  83. BIN
      sources/wifiwithme/static/img/background_main_991.jpg
  84. 4 0
      sources/wifiwithme/static/jquery/jquery-1.11.0.min.js
  85. 20 0
      sources/wifiwithme/static/leaflet-semicircle/LICENSE
  86. 113 0
      sources/wifiwithme/static/leaflet-semicircle/semicircle.js
  87. BIN
      sources/wifiwithme/static/leaflet/images/marker-icon-2x.png
  88. BIN
      sources/wifiwithme/static/leaflet/images/marker-icon-green.png
  89. BIN
      sources/wifiwithme/static/leaflet/images/marker-icon-red.png
  90. BIN
      sources/wifiwithme/static/leaflet/images/marker-icon-yellow.png
  91. BIN
      sources/wifiwithme/static/leaflet/images/marker-icon.png
  92. BIN
      sources/wifiwithme/static/leaflet/images/marker-shadow.png
  93. 478 0
      sources/wifiwithme/static/leaflet/leaflet.css
  94. 9 0
      sources/wifiwithme/static/leaflet/leaflet.js
  95. 171 0
      sources/wifiwithme/static/main.css
  96. 139 0
      sources/wifiwithme/static/map.js
  97. 16 0
      sources/wifiwithme/wsgi.py

+ 0 - 3
.gitmodules

@@ -1,3 +0,0 @@
-[submodule "sources"]
-	path = sources
-	url = https://code.ffdn.org/ljf/wifi-with-me.git

+ 0 - 1
sources

@@ -1 +0,0 @@
-Subproject commit 5b9fada5da5dcef8ae5e4d0f840875d214263406

+ 9 - 0
sources/.gitignore

@@ -0,0 +1,9 @@
+*.pyc
+__pycache__/
+*~
+*.sqlite3
+wifiwithme/settings/local.py
+var/media/*
+var/static/*
+venv
+.idea

+ 14 - 0
sources/LICENSE

@@ -0,0 +1,14 @@
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+                    Version 2, December 2004
+
+ Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
+
+ Everyone is permitted to copy and distribute verbatim or modified
+ copies of this license document, and changing it is allowed as long
+ as the name is changed.
+
+            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. You just DO WHAT THE FUCK YOU WANT TO.
+

+ 103 - 0
sources/README.md

@@ -0,0 +1,103 @@
+Dependencies
+============
+
+In order to facilitate dependency management, you can use a pip and
+a virtual environment (like virtualenv).
+
+Install packages:
+
+     # apt-get install python3-pip python3-virtualenv
+
+Create and activate the virtualenv with:
+
+     $ virtualenv -p $(which python3) venv
+     $ source venv/bin/activate
+
+We use django framework.  Install it from requirements with pip:
+
+     $ pip install -r requirements/base.txt
+
+For development, install `dev.txt` instead:
+
+     $ pip install -r requirements/dev.txt
+
+Set up configuration
+====================
+
+You may want to create and edit configuration file
+`wifiwithme/settings/local.py` (no setting is mandatory at the
+moment):
+
+URL Prefix
+----------
+
+Optionaly, you can define an url prefix (ex: `/foo/`) so that wifi-with-me is
+accessible under *http://example.com/foo/* :
+
+    URL_PREFIX='foo/'
+
+Notifications
+-------------
+
+If you to receive notifications on each new contrib, customize those :
+
+List of notification recipients:
+
+    NOTIFICATION_EMAILS=['admin@example.tld']
+
+Notification sender address:
+
+    DEFAULT_FROM_EMAIL='notifier@example.tld'
+
+The wifi-with-me website URL (for links included in emails :)
+
+    SITE_URL="http://example.tld"
+
+    ISP={
+        'NAME':'FAIMAISON',
+        'SITE':'//www.faimaison.net',
+        'EMAIL':'bureau (at) faimaison.net',
+        'ZONE':'Nantes et environs',
+        'URL_CONTACT':'//www.faimaison.net/pages/contact.html',
+        'LATITUDE':47.218371,
+        'LONGITUDE':-1.553621,
+        'ZOOM':13,
+        'CNIL':{
+            'LINK':'//www.faimaison.net/files/cnil-faimaison-1819684-withwithme.pdf',
+            'NUMBER':1819684
+        }
+    }
+
+Migrate from bottle version (optional)
+======================================
+
+If you used the (old) bottle version of wifi-with-me and want to migrate your
+data follow this extra step :
+
+    $ ./manage.py migrate auth
+    $ ./manage.py migrate contribmap 0001 --fake
+
+Run development server
+======================
+
+It is required to initialize database first:
+
+    $ ./manage.py migrate
+
+Then launch service with:
+
+    $ ./manage.py runserver
+
+You can visit your browser at <http://127.0.0.1:8000/map/contribute>
+
+Run production server
+=====================
+
+To be done
+
+Drop the database
+=================
+
+    $ rm db.sqlite3
+
+What else ?

+ 12 - 0
sources/TODO.md

@@ -0,0 +1,12 @@
+Bugs
+====
+
+Features
+========
+
+- pass the DB name as enviornment
+- Captcha or Honeypot
+
+Cosmetics/Optional
+==================
+- Use a form handling lib ?

+ 420 - 0
sources/backend.py

@@ -0,0 +1,420 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import cgi
+import os
+import sys
+import sqlite3
+import urlparse
+import datetime
+import json
+from email import utils
+from os.path import join, dirname, exists
+
+import bottle
+from bottle import route, run, static_file, request, template, FormsDict, redirect, response, Bottle
+
+URL_PREFIX = os.environ.get('URL_PREFIX', '')
+
+ORIENTATIONS = (
+    ('N', 'Nord'),
+    ('NO', 'Nord-Ouest'),
+    ('O', 'Ouest'),
+    ('SO', 'Sud-Ouest'),
+    ('S', 'Sud'),
+    ('SE', 'Sud-Est'),
+    ('E', 'Est'),
+    ('NE', 'Nord-Est'),
+)
+
+# Angular sector for each direction, written as (start, stop) in degrees
+ANGLES = {
+     'N':  (-23, 22),
+     'NO': (292, 337),
+     'O':  (247, 292),
+     'SO': (202, 247),
+     'S':  (157, 202),
+     'SE': (112, 157),
+     'E':  (67, 112),
+     'NE': (22, 67)
+}
+
+TABLE_NAME = 'contribs'
+DB_FILENAME = join(dirname(__file__), 'db.sqlite3')
+DB = sqlite3.connect(DB_FILENAME)
+
+DB_COLS = (
+('id', 'INTEGER PRIMARY KEY'),
+('name', 'TEXT'),
+('contrib_type', 'TEXT'),
+('latitude', 'REAL'),
+('longitude', 'REAL'),
+('phone', 'TEXT'),
+('email', 'TEXT'),
+('access_type', 'TEXT'),
+('connect_local', 'INTEGER'),
+('connect_internet', 'INTEGER'),
+('bandwidth', 'REAL'),
+('share_part', 'REAL'),
+('floor', 'INTEGER'),
+('floor_total', 'INTEGER'),
+('orientations', 'TEXT'),
+('roof', 'INTEGER'),
+('comment', 'TEXT'),
+('privacy_name', 'INTEGER'),
+('privacy_email', 'INTEGER'),
+('privacy_coordinates', 'INTEGER'),
+('privacy_place_details', 'INTEGER'),
+('privacy_comment', 'INTEGER'),
+('date', 'TEXT'),
+)
+
+GEOJSON_NAME = 'public.json'
+GEOJSON_LICENSE_TYPE = 'ODC-BY-1.0'
+GEOJSON_LICENSE_URL = 'http://opendatacommons.org/licenses/by/1.0/'
+
+ANTISPAM_FIELD = 'url'
+
+app = Bottle()
+
+@app.route('/')
+def home():
+     redirect(urlparse.urljoin(request.path,join(URL_PREFIX, 'wifi-form')))
+
+@app.route('/wifi-form')
+def show_wifi_form():
+    return template('wifi-form', errors=None, data = FormsDict(),
+                    orientations=ORIENTATIONS, geojson=GEOJSON_NAME)
+
+def create_tabble(db, name, columns):
+    col_defs = ','.join(['{} {}'.format(*i) for i in columns])
+    db.execute('CREATE TABLE {} ({})'.format(name, col_defs))
+
+def escape(s):
+     if not isinstance(s, (bool, float, int)) and (s != None):
+          return cgi.escape(s)
+     else:
+          return s
+
+def json_url(json_filename):
+    """ Returns (relative) json URL with a querystring mentioning file mtime
+
+    That's to prevent too much browser caching (mtime will change on file
+    generation, changing querystring) while letting browser doing relevant
+    caching.
+    """
+    file_path = join(dirname(__file__), 'json/', json_filename)
+    mtime = os.path.getmtime(file_path)
+    return '{}?mtime={}'.format(json_filename, mtime)
+
+def save_to_db(db, dic):
+    # SQLite is picky about encoding else
+    tosave = {bytes(k):escape(v.decode('utf-8')) if isinstance(v,str)
+              else escape(v)
+              for k,v in dic.items()}
+    tosave['date'] = utils.formatdate()
+    return db.execute("""
+INSERT INTO {}
+(name, contrib_type, latitude, longitude, phone, email, access_type, connect_local, connect_internet, bandwidth, share_part, floor, floor_total, orientations, roof, comment,
+privacy_name, privacy_email, privacy_place_details, privacy_coordinates, privacy_comment, date)
+VALUES (:name, :contrib_type, :latitude, :longitude, :phone, :email, :access_type, :connect_local, :connect_internet, :bandwidth, :share_part, :floor, :floor_total, :orientations, :roof, :comment,
+        :privacy_name, :privacy_email, :privacy_place_details, :privacy_coordinates, :privacy_comment, :date)
+""".format(TABLE_NAME), tosave)
+
+@app.route('/wifi-form', method='POST')
+def submit_wifi_form():
+    required = ('name', 'contrib-type',
+                'latitude', 'longitude')
+    required_or = (('email', 'phone'),)
+    required_if = (
+        ('contrib-type', 'share',('access-type', 'bandwidth',
+                                    'share-part')),
+    )
+
+    field_names = {
+        'name'        : 'Nom/Pseudo',
+        'contrib-type': 'Type de participation',
+        'latitude'    : 'Localisation',
+        'longitude'   : 'Localisation',
+        'phone'       : 'Téléphone',
+        'email'       : 'Email',
+        'access-type' : 'Type de connexion',
+        'bandwidth'   : 'Bande passante',
+        'share-part'  : 'Débit partagé',
+        'floor' : 'Étage',
+        'floor_total' : 'Nombre d\'étages total'
+    }
+
+    errors = []
+
+    if request.forms.get(ANTISPAM_FIELD):
+         errors.append(('', "Une erreur s'est produite"))
+
+    for name in required:
+        if (not request.forms.get(name)):
+            errors.append((field_names[name], 'ce champ est requis'))
+
+    for name_list in required_or:
+        filleds = [True for name in name_list if request.forms.get(name)]
+        if len(filleds) <= 0:
+            errors.append((
+                    ' ou '.join([field_names[i] for i in name_list]),
+                    'au moins un des de ces champs est requis'))
+
+    for key, value, fields  in required_if:
+        if request.forms.get(key) == value:
+            for name in fields:
+                if not request.forms.get(name):
+                    errors.append(
+                        (field_names[name], 'ce champ est requis'))
+
+    floor = request.forms.get('floor')
+    floor_total = request.forms.get('floor_total')
+
+    if floor and not floor_total:
+        errors.append((field_names['floor_total'], "ce champ est requis"))
+    if not floor and floor_total:
+        errors.append((field_names['floor'], "ce champ est requis"))
+    if floor and floor_total and (int(floor) > int(floor_total)):
+        errors.append((field_names['floor'], "Étage supérieur au nombre total"))
+    if floor and (int(floor) < 0):
+        errors.append((field_names['floor'], "l'étage doit-être positif"))
+    if floor_total and (int(floor_total) < 0):
+        errors.append((field_names['floor_total'], "le nombre d'étages doit-être positif"))
+
+    if errors:
+        return template('wifi-form', errors=errors, data=request.forms,
+                        orientations=ORIENTATIONS, geojson=json_url(GEOJSON_NAME))
+    else:
+        d = request.forms
+        save_to_db(DB, {
+                'name'         : d.get('name'),
+                'contrib_type' : d.get('contrib-type'),
+                'latitude'     : d.get('latitude'),
+                'longitude'    : d.get('longitude'),
+                'phone'        : d.get('phone'),
+                'email'        : d.get('email'),
+                'phone'        : d.get('phone'),
+                'access_type'          : d.get('access-type'),
+                'connect_local'        : 'local' in d.getall('connect-type'),
+                'connect_internet'     : 'internet' in d.getall('connect-type'),
+                'bandwidth'            : d.get('bandwidth'),
+                'share_part'           : d.get('share-part'),
+                'floor'                : d.get('floor'),
+                'floor_total'                : d.get('floor_total'),
+                'orientations'         : ','.join(d.getall('orientation')),
+                'roof'         : d.get('roof'),
+                'comment'              : d.get('comment'),
+                'privacy_name'         : 'name' in d.getall('privacy'),
+                'privacy_email'        : 'email' in d.getall('privacy'),
+                'privacy_place_details': 'place_details' in d.getall('privacy'),
+                'privacy_coordinates'  : 'coordinates' in d.getall('privacy'),
+                'privacy_comment'      : 'comment' in d.getall('privacy'),
+        })
+        DB.commit()
+
+        # Rebuild GeoJSON
+        build_geojson()
+
+        return redirect(urlparse.urljoin(request.path,join(URL_PREFIX,'thanks')))
+
+@app.route('/thanks')
+def wifi_form_thanks():
+    return template('thanks')
+
+@app.route('/assets/<filename:path>')
+def send_asset(filename):
+     for i in STATIC_DIRS:
+          path = join(i, filename)
+          if exists(path):
+               return static_file(filename, root=i)
+     raise bottle.HTTPError(404)
+
+@app.route('/legal')
+def legal():
+    return template('legal')
+
+
+"""
+Results Map
+"""
+
+@app.route('/map')
+def public_map():
+    return template('map', geojson=json_url(GEOJSON_NAME))
+
+@app.route('/public.json')
+def public_geojson():
+    return static_file('public.json', root=join(dirname(__file__), 'json/'))
+
+
+
+"""
+GeoJSON Functions
+"""
+
+# Useful for merging angle intervals (orientations)
+def merge_intervals(l, wrap=360):
+    """Merge a list of intervals, assuming the space is cyclic.  The
+    intervals should already by sorted by start value."""
+    if l == []:
+        return []
+    result = list()
+    # Transform the 2-tuple into a 2-list to be able to modify it
+    result.append(list(l[0]))
+    for (start, stop) in l:
+        current = result[-1]
+        if start > current[1]:
+            result.append([start, stop])
+        else:
+            result[-1][1] = max(result[-1][1], stop)
+    if len(result) == 1:
+        return result
+    # Handle the cyclicity by merging the ends if necessary
+    last = result[-1]
+    first = result[0]
+    if first[0] <= last[1] - wrap:
+        result[-1][1] = max(result[-1][1], first[1] + wrap)
+        result.pop(0)
+    return result
+
+def orientations_to_angle(orientations):
+     """Return a list of (start, stop) angles from a list of orientations."""
+     # Cleanup
+     orientations = [o for o in orientations if o in ANGLES.keys()]
+     # Hack to make leaflet-semicircle happy (drawing a full circle only
+     # works with (0, 360))
+     if len(orientations) == 8:
+          return [[0, 360]]
+     angles = [ANGLES[orientation] for orientation in orientations]
+     angles.sort(key=lambda (x, y): x)
+     return merge_intervals(angles)
+
+# Save feature collection to a json file
+def save_featurecollection_json(id, features, license=None):
+    with open('json/' + id + '.json', 'w') as outfile:
+        geojson = {
+            "type" : "FeatureCollection",
+            "features" : features,
+            "id" : id,
+        }
+        if license:
+            geojson['license'] = license
+        json.dump(geojson, outfile)
+
+
+# Build GeoJSON files from DB
+def build_geojson():
+    # Read from DB
+    DB.row_factory = sqlite3.Row
+    cur = DB.execute("""
+        SELECT * FROM {} ORDER BY id DESC
+        """.format(TABLE_NAME))
+
+    public_features = []
+    private_features = []
+
+    # Loop through results
+    rows = cur.fetchall()
+    for row in rows:
+        orientations = row['orientations'].split(',')
+        if row['roof'] == "on":
+             angles = [(0, 360)]
+        else:
+             angles = orientations_to_angle(orientations)
+        # Private JSON file
+        private_features.append({
+            "type" : "Feature",
+            "geometry" : {
+                "type": "Point",
+                 "coordinates": [row['longitude'], row['latitude']],
+            },
+             "id" : row['id'],
+             "properties": {
+                "name" : row['name'],
+                "place" : {
+                    'floor' : row['floor'],
+                    'floor_total' : row['floor_total'],
+                    'orientations' : orientations,
+                    'angles' : angles,
+                    'roof' : row['roof'],
+                    'contrib_type' : row['contrib_type']
+                },
+                "comment" : row['comment']
+             }
+        })
+
+        # Bypass non-public points
+        if not row['privacy_coordinates']:
+            continue
+
+        # Public JSON file
+        public_feature = {
+            "type" : "Feature",
+            "geometry" : {
+                "type": "Point",
+                 "coordinates": [row['longitude'], row['latitude']],
+            },
+             "id" : row['id'],
+             "properties": {'contrib_type': row['contrib_type']}
+        }
+
+        # Add optionnal variables
+        if row['privacy_name']:
+            public_feature['properties']['name'] = row['name']
+
+        if row['privacy_comment']:
+            public_feature['properties']['comment'] = row['comment']
+
+        if row['privacy_place_details']:
+            public_feature['properties']['place'] = {
+                'floor' : row['floor'],
+                'floor_total' : row['floor_total'],
+                'orientations' : orientations,
+                'angles' : angles,
+                'roof' : row['roof'],
+            }
+
+        # Add to public features list
+        public_features.append(public_feature)
+
+    # Build GeoJSON Feature Collection
+    save_featurecollection_json('private', private_features)
+    public_json_license = {
+        "type" : GEOJSON_LICENSE_TYPE,
+        "url" : GEOJSON_LICENSE_URL
+    }
+    save_featurecollection_json('public', public_features, public_json_license)
+
+
+
+DEBUG = bool(os.environ.get('DEBUG', False))
+LISTEN_ADDR= os.environ.get('BIND_ADDR', 'localhost')
+LISTEN_PORT= int(os.environ.get('BIND_PORT', 8080))
+URL_PREFIX = os.environ.get('URL_PREFIX', '').strip('/')
+CUSTOMIZATION_DIR = os.environ.get('CUSTOMIZATION_DIR', None)
+STATIC_DIRS = [join(dirname(__file__), 'assets')]
+
+
+if __name__ == '__main__':
+    if len(sys.argv) > 1:
+        if sys.argv[1] == 'createdb':
+            create_tabble(DB, TABLE_NAME, DB_COLS)
+        if sys.argv[1] == 'buildgeojson':
+            build_geojson()
+    else:
+        if URL_PREFIX:
+            print('Using url prefix "{}"'.format(URL_PREFIX))
+            root_app = Bottle()
+            root_app.mount('/{}/'.format(URL_PREFIX), app)
+            run(root_app, host=LISTEN_ADDR, port=LISTEN_PORT, reloader=DEBUG)
+
+        if CUSTOMIZATION_DIR:
+             custom_templates_dir = join(CUSTOMIZATION_DIR, 'views')
+             if exists(custom_templates_dir):
+                 bottle.TEMPLATE_PATH.insert(0, custom_templates_dir)
+             custom_assets_dir = join(CUSTOMIZATION_DIR, 'assets')
+             if exists(custom_assets_dir):
+                 STATIC_DIRS.insert(0, custom_assets_dir)
+
+        run(app, host=LISTEN_ADDR, port=LISTEN_PORT, reloader=DEBUG)
+        DB.close()

+ 62 - 0
sources/contrib/ansible/templates/init.j2

@@ -0,0 +1,62 @@
+#! /bin/sh
+#
+### BEGIN INIT INFO
+# Provides:          wifi-with-me
+# Required-Start:    $syslog $local_fs $remote_fs
+# Required-Stop:     $syslog $local_fs $remote_fs
+# Default-Start:     2 3 4 5
+# Default-Stop:      0 1 6
+# Short-Description: Wifi-with-me form
+# Description:       Get good will from people :)
+#
+### END INIT INFO
+
+PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
+INSTALL_DIR={{ wwm_install_dir }}
+DAEMON=${INSTALL_DIR}/backend.py
+PIDFILE=/var/run/wifiwithme.pid
+NAME=wifi-with-me
+DESC="Wifi with me, formulaire wifi"
+
+test -x $DAEMON || exit 0
+
+RUN_AS_USER={{ wwm_user }}
+
+case "$1" in
+  start)
+        echo -n "Starting $DESC: "
+        start-stop-daemon -d $INSTALL_DIR -b --start --quiet  \
+                --chuid $RUN_AS_USER --exec $DAEMON \
+                --pidfile $PIDFILE --make-pidfile \
+                -- $DAEMON_OPTS
+        echo "$NAME."
+        ;;
+  stop)
+        echo -n "Stopping $DESC: "
+        start-stop-daemon -d $INSTALL_DIR --stop --oknodo \
+                --pidfile $PIDFILE && rm $PIDFILE
+        echo "$NAME."
+        ;;
+  force-reload)
+        # check whether $DAEMON is running. If so, restart
+        start-stop-daemon --stop --test --quiet \
+                --pidfile $PIDFILE \
+                -- $DAEMON_OPTS \
+        && $0 restart \
+        || exit 0
+        ;;
+  restart)
+    echo "Restarting $DESC: " \
+        && $0 stop  \
+        && $0 start \
+        || exit 0
+        ;;
+  *)
+        N=/etc/init.d/$NAME
+        echo "Usage: $N {start|stop|restart|force-reload}" >&2
+        exit 1
+        ;;
+esac
+
+exit 0
+

+ 5 - 0
sources/contrib/ansible/templates/lighttpd-80-wifiwithme.conf.j2

@@ -0,0 +1,5 @@
+$HTTP["host"] =~ "{{ wwm_hostname }}" {
+    proxy.server = ("{{ wwm_folder }}" =>
+           (( "host" => "127.0.0.1", "port" => 8080))
+    )
+}

+ 67 - 0
sources/contrib/ansible/wifiwithme.yml

@@ -0,0 +1,67 @@
+- hosts: all
+  vars:
+    wwm_install_dir: /var/lib/wifiwithme
+    wwm_user: wifiwithme
+    wwm_hostname: wifiwithme.localhost
+    wwm_folder: "/"
+
+  tasks:
+    - name: Install bottle
+      apt: pkg=python-bottle state=installed update_cache=yes cache_valid_time=3600
+    - name: Make install dir
+      file:
+        dest: "{{ wwm_install_dir }}"
+        state: directory
+
+    - name: Git clone
+      git:
+        repo: https://github.com/JocelynDelalande/wifi-with-me.git
+        dest: "{{ wwm_install_dir }}"
+      notify: Restart wifiwithme
+
+    - name: Unix user
+      user: name="{{ wwm_user }}"
+
+    - name: Create db
+      command: "{{ wwm_install_dir }}/backend.py createdb"
+      args:
+        chdir: "{{ wwm_install_dir }}"
+        creates: "{{ wwm_install_dir }}/db.sqlite3"
+
+    - name: Files ownership
+      file:
+        dest: "{{ wwm_install_dir }}"
+        owner: "{{ wwm_user }}"
+        recurse: yes
+
+    - name: Install init file
+      template:
+        src: templates/init.j2
+        dest: /etc/init.d/wifiwithme
+        group: root
+        owner: root
+        mode: 0755
+      notify: Restart wifiwithme
+
+    - name: Enable wifiwithme
+      service: name=wifiwithme enabled=yes state=started
+
+    - name: Put lighttpd proxy rule
+      template:
+        src: templates/lighttpd-80-wifiwithme.conf.j2
+        dest: /etc/lighttpd/conf-available/80-wifiwithme.conf
+      notify: Restart lighttpd
+
+    - name: Enable lighttpd proxy rule
+      file:
+        src: /etc/lighttpd/conf-available/80-wifiwithme.conf
+        dest: /etc/lighttpd/conf-enabled/80-wifiwithme.conf
+        state: link
+      notify: Restart lighttpd
+
+  handlers:
+    - name: Restart lighttpd
+      service: name=lighttpd state=restarted
+
+    - name: Restart wifiwithme
+      service: name=wifiwithme state=restarted

+ 0 - 0
sources/json/.placeholder


+ 11 - 0
sources/manage.py

@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+import os
+import sys
+
+if __name__ == "__main__":
+    os.environ.setdefault(
+        "DJANGO_SETTINGS_MODULE", "wifiwithme.settings.dev")
+
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line(sys.argv)

+ 4 - 0
sources/requirements/base.txt

@@ -0,0 +1,4 @@
+Django==1.9.3
+PyYAML==3.11
+pytz
+sqlparse

+ 2 - 0
sources/requirements/dev.txt

@@ -0,0 +1,2 @@
+-r base.txt
+django-debug-toolbar

+ 0 - 0
sources/var/media/.placeholder


+ 0 - 0
sources/var/static/.placeholder


+ 0 - 0
sources/wifiwithme/__init__.py


+ 0 - 0
sources/wifiwithme/apps/__init__.py


+ 0 - 0
sources/wifiwithme/apps/contribmap/__init__.py


+ 44 - 0
sources/wifiwithme/apps/contribmap/admin.py

@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+
+from django.contrib import admin
+
+# Register your models here.
+from .models import Contrib
+
+
+# Kinda hackish to do that here
+admin.site.site_header = "Administration − Wifi with me"
+admin.site.site_title = "Wifi with me"
+
+
+@admin.register(Contrib)
+class ContribAdmin(admin.ModelAdmin):
+    search_fields = ["name", "email", "phone"]
+    list_display = ("name", "date",)
+
+    fieldsets = [
+        [None, {
+            'fields': [('name', 'contrib_type'), 'comment'],
+        }],
+        ['Localisation', {
+            'fields': [
+                ('latitude', 'longitude'),
+                ('floor', 'floor_total'),
+                'orientations', 'roof']
+        }],
+        ['Raccordement au réseau', {
+            'fields': ['connect_local', 'connect_internet'],
+            'classes': ['collapse'],
+        }],
+        ['Partage de connexion', {
+            'fields': ['access_type'],
+            'classes': ['collapse'],
+        }],
+        ['Vie privée', {
+            'fields': [
+                'privacy_name', 'privacy_email', 'privacy_coordinates',
+                'privacy_place_details', 'privacy_comment'
+            ],
+            'classes': ['collapse'],
+        }]
+    ]

+ 8 - 0
sources/wifiwithme/apps/contribmap/apps.py

@@ -0,0 +1,8 @@
+from __future__ import unicode_literals
+
+from django.apps import AppConfig
+
+
+class ContribmapConfig(AppConfig):
+    name = 'contribmap'
+    verbose_name = 'Carte collaborative'

+ 21 - 0
sources/wifiwithme/apps/contribmap/decorators.py

@@ -0,0 +1,21 @@
+from django.http import HttpResponseForbidden
+from .forms import PublicContribForm
+
+
+def prevent_robots(field_name='human_field'):
+    """
+    this decorator returns a HTTP 403 Forbidden error on POST requests
+    if a given field has been set
+
+    Keyword arguments :
+    field_name -- the name of the field to search for (default 'human_field')
+    """
+    def _dec(func):
+        def _wrapped_func(request, *args, **kwargs):
+            if request.method == 'POST':
+                form = PublicContribForm(request.POST)
+                if field_name in form.data and form.data[field_name]:
+                    return HttpResponseForbidden()
+            return func(request, *args, **kwargs)
+        return _wrapped_func
+    return _dec

+ 39 - 0
sources/wifiwithme/apps/contribmap/fields.py

@@ -0,0 +1,39 @@
+import collections
+
+from django.db import models
+
+
+class CommaSeparatedList(list):
+    """ str representation is useful for displayint in forms
+    """
+    def __str__(self):
+        return ','.join(self)
+
+
+class CommaSeparatedCharField(models.CharField):
+    "Implements comma-separated storage of lists"
+
+    def from_db_value(self, value, expression, connection, context):
+        if value is None:
+            return value
+        return CommaSeparatedList(value.split(','))
+
+    def to_python(self, value):
+        if isinstance(value, CommaSeparatedList):
+            return value
+        elif isinstance(value, collections.Iterable):
+            return CommaSeparatedList(value)
+
+        elif value is None:
+            return value
+
+        return CommaSeparatedList([i.strip() for i in value.split(',')])
+
+    def clean(self, *args, **kwargs):
+        return super().clean(*args, **kwargs)
+
+    def get_prep_value(self, value):
+        if isinstance(value, collections.Iterable):
+            return ','.join(value)
+        else:
+            return value

+ 14 - 0
sources/wifiwithme/apps/contribmap/fixtures/bottle_data.yaml

@@ -0,0 +1,14 @@
+- model: contribmap.contrib
+  pk: 1
+  fields: {name: Bill, contrib_type: share, latitude: 47.218371, longitude: -1.553621,
+    phone: '', email: a@example.com, access_type: fiber,
+     floor: null, floor_total: null, orientations: 'N,NO,O,SO,S,SE,E,NE',
+    roof: false, comment: '', privacy_name: false, privacy_email: false, privacy_coordinates: true,
+    privacy_place_details: true, privacy_comment: false, date: ! '2016-03-03 12:26:39+00:00'}
+- model: contribmap.contrib
+  pk: 2
+  fields: {name: veau, contrib_type: connect, latitude: 47.2195681123155, longitude: -1.5398025512695312,
+    phone: '0101010101', email: '', access_type: vdsl,
+    floor: 1, floor_total: 2, orientations: 'N,NO',
+    roof: false, comment: '', privacy_name: false, privacy_email: false, privacy_coordinates: false,
+    privacy_place_details: false, privacy_comment: false, date: ! '2016-03-03 12:27:24+00:00'}

+ 96 - 0
sources/wifiwithme/apps/contribmap/forms.py

@@ -0,0 +1,96 @@
+from django import forms
+
+from .models import Contrib
+
+
+ORIENTATIONS = (
+    ('N', 'Nord'),
+    ('NO', 'Nord-Ouest'),
+    ('O', 'Ouest'),
+    ('SO', 'Sud-Ouest'),
+    ('S', 'Sud'),
+    ('SE', 'Sud-Est'),
+    ('E', 'Est'),
+    ('NE', 'Nord-Est'),
+)
+
+
+class PublicContribForm(forms.ModelForm):
+    human_field = forms.CharField(required=False, widget=forms.HiddenInput)
+
+    class Meta:
+        model = Contrib
+
+        fields = [
+            'name', 'contrib_type',
+            'latitude', 'longitude',
+            'phone', 'email',
+            'comment',
+            'access_type',
+            'floor', 'floor_total', 'orientations', 'roof',
+            'comment',
+            'privacy_name', 'privacy_email', 'privacy_coordinates',
+            'privacy_place_details', 'privacy_comment',
+        ]
+        widgets = {
+            'contrib_type': forms.RadioSelect,
+            'latitude': forms.HiddenInput,
+            'longitude': forms.HiddenInput,
+            'access_type': forms.RadioSelect,
+            'comment': forms.Textarea({'rows': 3}),
+            'floor': forms.TextInput(
+                attrs={'placeholder': "Étage (0 pour RDC)"}),
+            'floor_total': forms.TextInput(
+                attrs={'placeholder': "Nb. d'étages du bâtiment"}),
+        }
+    # Widget rendering is managed by hand in template for orientions.
+    orientations = forms.MultipleChoiceField(choices=ORIENTATIONS)
+
+    _privacy_fieldnames = (
+        'privacy_name', 'privacy_email', 'privacy_coordinates',
+        'privacy_place_details', 'privacy_comment',
+    )
+
+    def _validate_contact_information(self, data):
+        if (data.get('phone') == '') and (data.get('email') == ''):
+            msg = 'Il faut remplir un des deux champs "téléphone" ou "email".'
+            self.add_error('phone', msg)
+            self.add_error('email', msg)
+
+    def _validate_floors(self, data):
+        if None in (data.get('floor'), data.get('floor_total')):
+            return
+
+        if (data.get('floor') > data.get('floor_total')):
+            self.add_error(
+                'floor',
+                "L'étage doit être inférieur ou égal au nombre d'étages",
+            )
+
+    def _validate_share_fields(self, data):
+        if data.get('contrib_type') == Contrib.CONTRIB_SHARE:
+            if data.get('access_type') == '':
+                self.add_error('access_type', 'Ce champ est requis')
+
+    def clean(self):
+        cleaned_data = super().clean()
+        self._validate_contact_information(cleaned_data)
+        self._validate_floors(cleaned_data)
+        self._validate_share_fields(cleaned_data)
+        return cleaned_data
+
+    def privacy_fields(self):
+        for i in self._privacy_fieldnames:
+            field = self[i]
+
+            # FIXME: What a hack
+            field.label = field.label\
+                               .replace('public', '')\
+                               .replace('publiques', '')
+            yield field
+
+    def __init__(self, *args, **kwargs):
+        super(PublicContribForm, self).__init__(*args, **kwargs)
+
+        for f in ['latitude', 'longitude']:
+            self.fields[f].error_messages['required'] = "Veuillez déplacer le curseur à l'endroit où vous voulez partager/accéder au service"

+ 48 - 0
sources/wifiwithme/apps/contribmap/migrations/0001_initial.py

@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-03 09:39
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Contrib',
+            fields=[
+                ('id', models.AutoField(primary_key=True, serialize=False)),
+                ('name', models.TextField(blank=True, null=True)),
+                ('contrib_type', models.TextField(blank=True, null=True)),
+                ('latitude', models.FloatField(blank=True, null=True)),
+                ('longitude', models.FloatField(blank=True, null=True)),
+                ('phone', models.TextField(blank=True, null=True)),
+                ('email', models.TextField(blank=True, null=True)),
+                ('access_type', models.TextField(blank=True, null=True)),
+                ('connect_local', models.IntegerField(blank=True, null=True)),
+                ('connect_internet', models.IntegerField(blank=True, null=True)),
+                ('bandwidth', models.FloatField(blank=True, null=True)),
+                ('share_part', models.FloatField(blank=True, null=True)),
+                ('floor', models.IntegerField(blank=True, null=True)),
+                ('floor_total', models.IntegerField(blank=True, null=True)),
+                ('orientations', models.TextField(blank=True, null=True)),
+                ('roof', models.IntegerField(blank=True, null=True)),
+                ('comment', models.TextField(blank=True, null=True)),
+                ('privacy_name', models.IntegerField(blank=True, null=True)),
+                ('privacy_email', models.IntegerField(blank=True, null=True)),
+                ('privacy_coordinates', models.IntegerField(blank=True, null=True)),
+                ('privacy_place_details', models.IntegerField(blank=True, null=True)),
+                ('privacy_comment', models.IntegerField(blank=True, null=True)),
+                ('date', models.TextField(blank=True, null=True)),
+            ],
+            options={
+                'db_table': 'contribs',
+                'managed': True,
+            },
+        ),
+    ]

+ 20 - 0
sources/wifiwithme/apps/contribmap/migrations/0003_auto_20160303_1057.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-03 10:57
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    """Empty floatfield are stored as empty string by sqlite. But Django hates
+    that and refuse to handle it properly until they are stored as NULL."""
+
+    dependencies = [
+        ('contribmap', '0001_initial'),
+    ]
+    float_cols = ['bandwidth', 'share_part', 'floor', 'floor_total']
+    operations = [
+        migrations.RunSQL(
+            "UPDATE contribs SET {col}=NULL WHERE {col}='';".format(col=i))
+        for i in float_cols
+    ]

+ 39 - 0
sources/wifiwithme/apps/contribmap/migrations/0004_auto_20160303_1152.py

@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-03 11:52
+from __future__ import unicode_literals
+
+import contribmap.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0003_auto_20160303_1057'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contrib',
+            name='contrib_type',
+            field=models.CharField(choices=[('connect', 'Me raccorder au réseau expérimental'), ('share', 'Partager une partie de ma connexion')], default='connect', max_length=10),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='email',
+            field=models.EmailField(blank=True, default='', max_length=254),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='name',
+            field=models.TextField(default=''),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='orientations',
+            field=contribmap.fields.CommaSeparatedCharField(blank=True, max_length=100, null=True),
+        ),
+    ]

+ 71 - 0
sources/wifiwithme/apps/contribmap/migrations/0005_auto_20160303_1222.py

@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-03 12:22
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0004_auto_20160303_1152'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contrib',
+            name='access_type',
+            field=models.CharField(blank=True, choices=[('vdsl', 'ADSL'), ('vdsl', 'VDSL'), ('fiber', 'Fibre optique'), ('cable', 'Coaxial (FTTLA)')], default='', max_length=10),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='connect_internet',
+            field=models.NullBooleanField(default=False),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='connect_local',
+            field=models.NullBooleanField(default=False),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='floor',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='floor_total',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_comment',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_coordinates',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_email',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_name',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_place_details',
+            field=models.BooleanField(default=True),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='roof',
+            field=models.BooleanField(default=False),
+        ),
+    ]

+ 25 - 0
sources/wifiwithme/apps/contribmap/migrations/0006_auto_20160303_1321.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-03 13:21
+from __future__ import unicode_literals
+import datetime
+import pytz
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0005_auto_20160303_1222'),
+    ]
+
+    operations = [
+        migrations.RenameField('contrib', 'date', 'old_date'),
+        migrations.AddField(
+            model_name='contrib',
+            name='date',
+            field=models.DateTimeField(
+                auto_now_add=True, default=datetime.datetime.now(pytz.utc)),
+            preserve_default=False,
+        ),
+    ]

+ 30 - 0
sources/wifiwithme/apps/contribmap/migrations/0007_auto_20160303_1326.py

@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-03 13:26
+from __future__ import unicode_literals
+
+try:
+    from email.utils import parsedate_to_datetime
+except ImportError:  # py2
+    from wifiwithme.core.py2_compat import parsedate_to_datetime
+
+from django.db import migrations
+from django.utils.timezone import make_aware
+
+
+def migrate_date_field(apps, schema_editor):
+    Contrib = apps.get_model('contribmap', 'contrib')
+    for i in Contrib.objects.all():
+        if i.date:
+            i.date = make_aware(parsedate_to_datetime(i.old_date))
+            i.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0006_auto_20160303_1321'),
+    ]
+
+    operations = [
+        migrations.RunPython(migrate_date_field)
+    ]

+ 19 - 0
sources/wifiwithme/apps/contribmap/migrations/0008_remove_contrib_old_date.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-03 13:36
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0007_auto_20160303_1326'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='contrib',
+            name='old_date',
+        ),
+    ]

+ 25 - 0
sources/wifiwithme/apps/contribmap/migrations/0009_auto_20160303_1357.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-03 13:57
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0008_remove_contrib_old_date'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contrib',
+            name='name',
+            field=models.CharField(max_length=30),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='phone',
+            field=models.CharField(blank=True, default='', max_length=30),
+        ),
+    ]

+ 109 - 0
sources/wifiwithme/apps/contribmap/migrations/0010_auto_20160315_1644.py

@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-15 16:44
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0009_auto_20160303_1357'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='contrib',
+            options={'managed': True, 'verbose_name': 'contribution'},
+        ),
+        migrations.AddField(
+            model_name='contrib',
+            name='status',
+            field=models.CharField(blank=True, choices=[('A_ETUDIER', '\xe0 \xe9tudier'), ('A_CONNECTER', '\xe0 connecter'), ('CONNECTE', 'connect\xe9'), ('PAS_CONNECTABLE', 'pas connectable')], default='A_ETUDIER', max_length=250, null=True),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='access_type',
+            field=models.CharField(blank=True, choices=[('vdsl', 'ADSL'), ('vdsl', 'VDSL'), ('fiber', 'Fibre optique'), ('cable', 'Coaxial (FTTLA)')], max_length=10, verbose_name='Type de connexion'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='bandwidth',
+            field=models.FloatField(blank=True, null=True, verbose_name='d\xe9bit total'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='comment',
+            field=models.TextField(blank=True, null=True, verbose_name='commentaire'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='connect_internet',
+            field=models.NullBooleanField(default=False, verbose_name='Services locaux'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='connect_local',
+            field=models.NullBooleanField(default=False, verbose_name='Acc\xe8s internet'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='contrib_type',
+            field=models.CharField(choices=[('connect', 'Me raccorder au r\xe9seau exp\xe9rimental'), ('share', 'Partager une partie de ma connexion')], max_length=10, verbose_name='Type de contribution'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='floor',
+            field=models.PositiveIntegerField(blank=True, null=True, verbose_name='\xe9tage'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='floor_total',
+            field=models.PositiveIntegerField(blank=True, null=True, verbose_name="mombre d'\xe9tages"),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='name',
+            field=models.CharField(max_length=30, verbose_name='Nom / Pseudo'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='phone',
+            field=models.CharField(blank=True, default='', max_length=30, verbose_name='T\xe9l\xe9phone'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_comment',
+            field=models.BooleanField(default=False, verbose_name='commentaire public'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_coordinates',
+            field=models.BooleanField(default=True, verbose_name='coordonn\xe9es GPS publiques'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_email',
+            field=models.BooleanField(default=False, verbose_name='email public'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_name',
+            field=models.BooleanField(default=False, verbose_name='nom/pseudo public'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='privacy_place_details',
+            field=models.BooleanField(default=True, verbose_name='\xe9tage/orientations publiques'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='roof',
+            field=models.BooleanField(default=False, verbose_name='acc\xe8s au to\xeet'),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='share_part',
+            field=models.FloatField(blank=True, null=True, verbose_name='d\xe9bit partag\xe9'),
+        ),
+    ]

+ 20 - 0
sources/wifiwithme/apps/contribmap/migrations/0011_auto_20160316_1623.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-03-16 16:23
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0010_auto_20160315_1644'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contrib',
+            name='status',
+            field=models.CharField(blank=True, choices=[('TOSTUDY', '\xe0 \xe9tudier'), ('TOCONNECT', '\xe0 connecter'), ('CONNECTED', 'connect\xe9'), ('WONTCONNECT', 'pas connectable')], default='A_ETUDIER', max_length=250, null=True),
+        ),
+    ]

+ 36 - 0
sources/wifiwithme/apps/contribmap/migrations/0012_auto_20160510_2335.py

@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0011_auto_20160316_1623'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contrib',
+            name='contrib_type',
+            field=models.CharField(default=None, verbose_name='Type de contribution', choices=[('connect', 'Me raccorder au réseau expérimental'), ('share', 'Partager une partie de ma connexion')], max_length=10),
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='latitude',
+            field=models.FloatField(default=0),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='longitude',
+            field=models.FloatField(default=0),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='status',
+            field=models.CharField(null=True, blank=True, default='TOSTUDY', choices=[('TOSTUDY', 'à étudier'), ('TOCONNECT', 'à connecter'), ('CONNECTED', 'connecté'), ('WONTCONNECT', 'pas connectable')], max_length=250),
+        ),
+    ]

+ 23 - 0
sources/wifiwithme/apps/contribmap/migrations/0013_auto_20160515_1036.py

@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-05-15 10:36
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0012_auto_20160510_2335'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='contrib',
+            name='bandwidth',
+        ),
+        migrations.RemoveField(
+            model_name='contrib',
+            name='share_part',
+        ),
+    ]

+ 28 - 0
sources/wifiwithme/apps/contribmap/migrations/0014_auto_20160515_1050.py

@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.3 on 2016-05-15 10:50
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contribmap', '0013_auto_20160515_1036'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='contrib',
+            name='connect_internet',
+        ),
+        migrations.RemoveField(
+            model_name='contrib',
+            name='connect_local',
+        ),
+        migrations.AlterField(
+            model_name='contrib',
+            name='contrib_type',
+            field=models.CharField(choices=[('connect', 'Me raccorder à internet'), ('share', 'Partager une partie de ma connexion')], default=None, max_length=10, verbose_name='Type de contribution'),
+        ),
+    ]

+ 0 - 0
sources/wifiwithme/apps/contribmap/migrations/__init__.py


+ 148 - 0
sources/wifiwithme/apps/contribmap/models.py

@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+
+from django.db import models
+
+from .fields import CommaSeparatedCharField
+from .utils import ANGLES, merge_intervals
+
+
+class Contrib(models.Model):
+    CONTRIB_CONNECT = 'connect'
+    CONTRIB_SHARE = 'share'
+
+    id = models.AutoField(primary_key=True, blank=False, null=False)
+    name = models.CharField(
+        'Nom / Pseudo',
+        max_length=30)
+    contrib_type = models.CharField(
+        'Type de contribution',
+        max_length=10, choices=(
+            (CONTRIB_CONNECT, 'Me raccorder à internet'),
+            (CONTRIB_SHARE, 'Partager une partie de ma connexion')
+        ), default=None)
+    latitude = models.FloatField()
+    longitude = models.FloatField()
+    phone = models.CharField(
+        'Téléphone',
+        max_length=30, blank=True, default='')
+    email = models.EmailField(blank=True)
+    access_type = models.CharField(
+        'Type de connexion',
+        max_length=10, blank=True, choices=(
+            ('vdsl', 'ADSL'),
+            ('vdsl', 'VDSL'),
+            ('fiber', 'Fibre optique'),
+            ('cable', 'Coaxial (FTTLA)'),
+        ))
+    floor = models.PositiveIntegerField(
+        'étage',
+        blank=True, null=True)
+    floor_total = models.PositiveIntegerField(
+        "mombre d'étages",
+        blank=True, null=True)
+    orientations = CommaSeparatedCharField(
+        blank=True, null=True, max_length=100)
+    roof = models.BooleanField(
+        'accès au toît',
+        default=False)
+    comment = models.TextField(
+        'commentaire',
+        blank=True, null=True)
+    privacy_name = models.BooleanField(
+        'nom/pseudo public',
+        default=False)
+    privacy_email = models.BooleanField(
+        'email public',
+        default=False)
+    privacy_coordinates = models.BooleanField(
+        'coordonnées GPS publiques',
+        default=True)
+    privacy_place_details = models.BooleanField(
+        'étage/orientations publiques',
+        default=True)
+    privacy_comment = models.BooleanField(
+        'commentaire public',
+        default=False)
+    date = models.DateTimeField(auto_now_add=True)
+
+    STATUS_TOSTUDY = 'TOSTUDY'
+    STATUS_TOCONNECT = 'TOCONNECT'
+    STATUS_CONNECTED = 'CONNECTED'
+    STATUS_WONTCONNECT = 'WONTCONNECT'
+
+    CONNECTABILITY = (
+        (STATUS_TOSTUDY, 'à étudier'),
+        (STATUS_TOCONNECT, 'à connecter'),
+        (STATUS_CONNECTED, 'connecté'),
+        (STATUS_WONTCONNECT, 'pas connectable'),
+    )
+
+    status = models.CharField(
+        blank=True,
+        null=True,
+        max_length=250,
+        choices=CONNECTABILITY,
+        default=STATUS_TOSTUDY)
+
+    class Meta:
+        managed = True
+        db_table = 'contribs'
+        verbose_name = 'contribution'
+
+    PRIVACY_MAP = {
+        'name': 'privacy_name',
+        'comment': 'privacy_comment',
+        'floor': 'privacy_place_details',
+        'floor_total': 'privacy_place_details',
+        'orientations': 'privacy_place_details',
+        'roof': 'privacy_place_details',
+        'angles': 'privacy_place_details',
+    }
+
+    PUBLIC_FIELDS = set(PRIVACY_MAP.keys())
+
+    def __str__(self):
+        return '#{} {}'.format(self.pk, self.name)
+
+    @property
+    def angles(self):
+        """Return a list of (start, stop) angles from cardinal orientations
+        """
+        # Cleanup
+        if self.orientations is None:
+            return []
+        orientations = [o for o in self.orientations if o in ANGLES.keys()]
+        # Hack to make leaflet-semicircle happy (drawing a full circle only
+        # works with (0, 360))
+        if len(orientations) == 8:
+            return [[0, 360]]
+        angles = [ANGLES[orientation] for orientation in self.orientations]
+        angles.sort(key=lambda i: i[0])  # sort by x
+        return merge_intervals(angles)
+
+    def is_public(self):
+        return self.privacy_coordinates
+
+    def _may_be_public(self, field):
+        return field in self.PUBLIC_FIELDS
+
+    def _is_public(self, field):
+        return getattr(self, self.PRIVACY_MAP[field])
+
+    def get_public_field(self, field):
+        """ Gets safely an attribute in its public form (if any)
+
+        :param field: The field name
+        :return: the field value, or None, if the field is private
+        """
+        if self._may_be_public(field) and self._is_public(field):
+            v = getattr(self, field)
+            if hasattr(v, '__call__'):
+                return v()
+            else:
+                return v
+
+        else:
+            return None

+ 114 - 0
sources/wifiwithme/apps/contribmap/templates/base.html

@@ -0,0 +1,114 @@
+{% load staticfiles %}
+
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>{{ isp.NAME }} − contribution à l'expérimentation wifi</title>
+
+    <!-- jQuery -->
+    <script src="{% static 'jquery/jquery-1.11.0.min.js' %}" type="text/javascript"></script>
+
+    <!-- Bootstrap -->
+    <script src="{% static 'bootstrap/js/bootstrap.js' %}"></script>
+    <link href="{% static 'bootstrap/css/bootstrap.min.css' %}" rel="stylesheet">
+
+    <!-- Leaflet -->
+    <link rel="stylesheet" type="text/css" media="all" href="{% static 'leaflet/leaflet.css' %}" />
+    <script src="{% static 'leaflet/leaflet.js' %}" type="text/javascript"></script>
+    <!-- Leaflet-semicircle -->
+    <script src="{% static 'leaflet-semicircle/semicircle.js' %}" type="text/javascript"></script>
+
+    <!-- Custom -->
+    <link rel="stylesheet" type="text/css" media="all" href="{% static 'main.css' %}" />
+
+
+  </head>
+<body class="{% block body_class %}{% endblock %}">
+
+  <header class="main-header jumbotron">
+    <div class="container">
+    <h1>
+    {% block title %}
+      <a href="{% url 'display_map' %}">Réseau wifi expérimental</a>
+    {% endblock %}
+    </h1>
+    </div>
+  </header>
+
+  <nav class="navbar navbar-fixed-top navbar-default">
+    <div class="container-fluid">
+      <!-- Brand and toggle get grouped for better mobile display -->
+      <div class="navbar-header">
+        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" aria-expanded="false">
+          <span class="sr-only">Toggle navigation</span>
+          <span class="icon-bar"></span><span class="icon-bar"></span><span class="icon-bar"></span>
+        </button>
+        <a class="navbar-brand" href="{% url 'display_map' %}">
+        Wifi {{ isp.NAME }}
+        </a>
+      </div>
+
+      <div class="collapse navbar-collapse">
+        <ul class="nav navbar-nav">
+          <li {% if request.resolver_match.url_name == 'add_contrib' %}class="active"{% endif %}>
+            <a href="{% url 'add_contrib' %}">
+              <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+              Faire une demande<span class="sr-only">(current)</span>
+            </a>
+          </li>
+          <li {% if request.resolver_match.url_name == 'display_map' %}class="active"{% endif %}>
+            <a href="{% url 'display_map' %}">
+              <span class="glyphicon glyphicon-map-marker" aria-hidden="true"></span>
+            Voir la carte
+            </a>
+          </li>
+          {% if request.user.is_authenticated %}
+          <li>
+            <a href="{% url 'admin:index' %}">
+              <span class="glyphicon glyphicon-lock" aria-hidden="true"></span>
+                  Administration
+            </a>
+          </li>
+          {% endif %}
+
+        </ul>
+        <ul class="nav navbar-nav navbar-right">
+          {% if request.user.is_authenticated %}
+          <li class="navbar-text">
+            <em>{{ request.user }}</em>
+          </li>
+          <li>
+            <a href="{% url 'logout' %}" title="Déconnexion">
+              <span class="glyphicon glyphicon-log-out" aria-hidden="true"></span>                </a>
+          </li>
+          {% else %}
+          <li>
+            <a href="{% url 'login' %}" title="Connexion">
+              <span class="glyphicon glyphicon-lock" aria-hidden="true"></span>                </a>
+          </li>
+          {% endif %}
+      </div>
+    </div>
+  </nav>
+  <section role="main" class="container">
+    {% block content %}{% endblock %}
+  </section>
+
+  <footer>
+    <p>
+        Les données personnelles que vous ne rendez pas publiques
+	sont stockées exclusivement chez {{ isp.NAME }} durant un an (sauf demande de
+  votre part)	et ne seront pas partagées avec d'autres
+	associations, entreprises ou collectivités. Des membres de
+	{{ isp.NAME }} seront toutefois amenés à les étudier : ne
+	fournissez pas de données si vous n'avez pas confiance en {{ isp.NAME }}.
+        <br>
+        <a href="{{ isp.SITE }}" target="_blank">{{ isp.NAME }}</a> -
+        <a href="{% url 'legal' %}">Vos droits d'accès et de rectification de vos données
+	personnelles</a>
+    </p>
+  </footer>
+
+</body>
+</html>

+ 35 - 0
sources/wifiwithme/apps/contribmap/templates/contribmap/legal.html

@@ -0,0 +1,35 @@
+{% extends "base.html" %}
+
+{% load staticfiles %}
+{% load obfuscate_email %}
+
+{% block content %}
+
+<h1>Mentions légales</h1>
+{% if isp.CNIL.LINK and isp.CNIL.NUMBER %}
+<p>
+Les informations recueillies font l’objet d’un traitement informatique, déclaré
+à la Commission nationale de l'informatique et des libertés (CNIL) sous le
+numéro <a
+href="{{ isp.CNIL.LINK }}">{{ isp.CNIL.NUMBER }}</a>.
+</p>
+{% endif %}
+
+<p>
+Conformément à la loi « informatique et libertés » du 6 janvier 1978 modifiée en
+2004, vous bénéficiez d’un droit d’accès et de rectification aux informations
+qui vous concernent, que vous pouvez exercer en vous adressant à {{ isp.EMAIL|obfuscate_email }}.
+</p>
+
+<p>
+Vous pouvez également, pour des motifs légitimes, vous opposer au traitement des
+données vous concernant.
+</p>
+
+<p>
+Les données rendues publiques sont diffusées sous licence <a
+href="http://opendatacommons.org/licenses/by/1.0/">Open Data Commons Attribution version
+1.0</a> (ODC-BY 1.0) : elles sont librement réutilisables à la seule
+condition d'en attribuer la paternité à {{ isp.NAME }}.
+</p>
+{% endblock content %}

+ 1 - 0
sources/wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_notice.subject

@@ -0,0 +1 @@
+[wifi-with-me] nouvelle demande de {{ contrib.name }}

+ 7 - 0
sources/wifiwithme/apps/contribmap/templates/contribmap/mails/new_contrib_notice.txt

@@ -0,0 +1,7 @@
+Nouvelle demande de la part de {{ contrib.name }}
+
+À retrouver sur {{ site_url }}
+
+Bien à toi,
+
+Wifi-with-me

+ 34 - 0
sources/wifiwithme/apps/contribmap/templates/contribmap/map.html

@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+
+{% load staticfiles %}
+
+<!-- <span class="back-link">&larr; <a href="/">Accueil</a></span> -->
+
+
+{% block content %}
+<h1>Résultats {% if private_mode %}(données privées){% endif %}</h1>
+
+<div id="map" class="results" data-json="{{ json_url }}"
+    start_lon="{{ isp.LONGITUDE|stringformat:"f" }}" start_lat="{{ isp.LATITUDE|stringformat:"f" }}"
+    start_zoom="{{ isp.ZOOM }}"></div>
+
+<script src="{% static 'map.js' %}" type="text/javascript"></script>
+<p>Légende : <br />
+  <img src="{% static 'leaflet/images/marker-icon-red.png' %}" /> Personne souhaitant partager sa connexion Internet<br />
+  <img src="{% static 'leaflet/images/marker-icon.png' %}" /> Personne souhaitant se connecter au réseau radio
+</p>
+<p>
+  Télécharger le fichier <a href="{% url 'public_json' %}">GeoJSON</a>
+
+{% if private_mode %}
+(données privées ; à ne pas diffuser. Pour voir les données publiques, <a
+href="{% url 'logout' %}">déconnecte-toi</a>).
+
+{% else %}
+(base de données mise sous
+  licence <a href="http://opendatacommons.org/licenses/by/summary/">ODC-BY
+  1.0</a>).<br />
+{% endif %}
+</p>
+
+{% endblock content %}

+ 24 - 0
sources/wifiwithme/apps/contribmap/templates/contribmap/thanks.html

@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+
+{% load staticfiles %}
+
+{% block content %}
+
+<h1>Merci !</h1>
+
+<p>
+Votre contribution a bien été enregistrée.
+</p>
+<p>
+Si vous voulez <strong>rester en
+contact</strong> avec {{ isp.NAME }}, nous rencontrer ou vous tenir informé, ça
+se passe sur
+<a href="{{ isp.URL_CONTACT }}">la page contact</a> de
+l'association.
+</p>
+
+<p>
+Vous pouvez consulter la <a href="{% url 'display_map' %}">carte publique avec tous les autres contributions</a>.
+</p>
+
+{% endblock %}

+ 200 - 0
sources/wifiwithme/apps/contribmap/templates/contribmap/wifi-form.html

@@ -0,0 +1,200 @@
+
+{% extends "base.html" %}
+
+{% load bootstrap %}
+{% load staticfiles %}
+
+{% block body_class %}form{% endblock %}
+
+{% block content %}
+  <script src="{% static 'form.js' %}" type="text/javascript"></script>
+
+  <header class="jumbotron">
+    <div class="container">
+    <p>
+<a href="{{ isp.SITE }}">{{ isp.NAME }}</a> expérimente à
+grande échelle ({{ isp.ZONE }}) la création d'un réseau sans-fil à
+longue portée à des fins, entre autres, de <em>partage</em> et
+<em>fourniture</em> d'<strong>accès à internet</strong>.
+    </p>
+
+    <p>
+Pour cela, nous recherchons des volontaires, tant pour <strong>partager une
+partie de leur connexion</strong> que pour participer au réseau (accès à
+internet, partage local…).
+      </p>
+
+      <p>
+Renseigner ce formulaire nous permet de définir quelles <strong>zones
+d'expérimentations</strong> (avec une grande densité de volontaires)
+pourraient être intéressantes.
+      </p>
+    </div>
+  </header>
+
+  <section role="main" class="container">
+  <form role="form" method="post">{% csrf_token %}
+
+{% if form.non_field_errors %}
+    <div id="errors" class="bg-danger">
+      {{ form.non_field_errors }}
+    </div>
+{% endif %}
+
+    <h2>Contact</h2>
+
+    <div class="form-group">
+      <label for="name">Nom / Pseudo</label>
+      {{ form.name|formcontrol }}
+      {{ form.name.errors }}
+    </div>
+
+    <div class="row">
+      <div class="form-group col-md-6">
+        <label for="email">Email</label>
+        {{ form.email|formcontrol }}
+        {{ form.email.errors }}
+        <p class="help-block">
+          <span class="glyphicon glyphicon-warning-sign"></span>
+          Un moyen de contact au moins est nécessaire
+        </p>
+      </div>
+      <div class="form-group col-md-6">
+        <label for="phone">Téléphone</label>
+        {{ form.phone|formcontrol }}
+        {{ form.phone.errors }}
+      </div>
+    </div>
+
+
+    <h2>Je souhaite</h2>
+    <div id="id_contrib_type">
+      {% for i in form.contrib_type %}
+      <p class="radio">{{ i }}</p>
+      {% endfor %}
+    </div>
+
+    <div id="contrib-type-share"
+    <h2>Partager une connexion</h2>
+
+    <h3>Type de connexion</h3>
+    <div id="id_access_type">
+      {% for i in form.access_type %}
+      {% if i.choice_value %}<p class="radio">{{ i }}</p>{% endif %}
+      {% endfor %}
+    </div>
+
+    {{ form.access_type.errors }}
+    </div>
+
+
+  <h2>Ma localisation</h2>
+
+    <div class="row">
+      <div class="col-sm-6">
+        <div id="map" data-json="{{geojson}}"
+            start_lon="{{ isp.LONGITUDE|stringformat:"f" }}" start_lat="{{ isp.LATITUDE|stringformat:"f" }}"
+            start_zoom="{{ isp.ZOOM }}"></div>
+      </div>
+      <div class="form-group col-sm-6">
+        <div class="form-group form-group-lg form-inline">
+          <input type="text" name="search"
+                 id="search" placeholder="rue du calvaire, nantes" class="form-control" />
+          <span id="search-btn" class="btn btn-default btn-lg" data-loading-text="...">Recherche</span>
+
+          <div id="search-results" class=""></div>
+          <p class="help-block">Déplacer le marqueur bleu pour pointer précisément le bâtiment au besoin</p>
+          <p class="help-block">
+            Les ronds verts sont ceux renseignés par d'autres utilisateurs, vous
+            pouvez aussi consulter <a href="{% url 'display_map' %}" target="_blank">la carte
+            publique plus détaillée</a>.
+          </p>
+
+        </div>
+        {{ form.latitude }}
+        {{ form.longitude }}
+        {% firstof form.latitude.errors form.latitude.errors %}
+        </div>
+      </div>
+
+    <p class="help-block">Les antennes peuvent être positionées soit sur le toit soit aux fenêtres/balcons/velux.</p>
+
+    <div class="form-group">
+      <label for="orientation" />Orientation(s) de mes fenêtres, balcons ou velux</label>
+
+    (<label class="checkbox-inline"><input type="checkbox" name="orientation-all" id="orientation-all" value="" />Vue à 360°</label>)
+      <br>
+{% for val, label in form.orientations.field.choices %}
+    <label class="checkbox-inline">
+      <input type="checkbox" class="orientation" name="orientations" value="{{ val }}"
+             {% if val in form.orientations.value %}checked="yes"{% endif %}/>
+      {{label}}
+    </label>
+{% endfor %}
+
+    {{ form.orientations.errors }}
+
+    <div class="form-group">
+      <label for="roof">Je peux accéder à mon toit
+        {{ form.roof }}
+      {{ form.roof.errors }}
+      </label>
+    </div>
+    <p class="form-inline">
+      <label for="floor">Mon étage</label>
+      {{ form.floor }}
+      {{ form.floor.errors }}
+      /
+      {{ form.floor_total }}
+      {{ form.floor_total.errors }}
+    </p>
+
+    <h2>Remarque/commentaire</h2>
+    {{ form.comment|formcontrol }}
+    {{ form.comment.errors }}
+
+
+    <h2>Mes données</h2>
+    {{ form.human_field|formcontrol }}
+
+    <p class="help-block">
+Les données collectées dans ce formulaire sont accessibles
+au bureau de {{ isp.NAME }}.<br />
+
+Vous pouvez cocher ci-dessous celles que vous voulez bien voir <a
+href="{% url 'legal' %}">rendues publiques et librement réutilisées</a>.
+    </p>
+
+    <div class="form-group">
+    <label for="privacy" />
+J'autorise qu'apparaissent sur la carte publique :
+    </label><br />
+    </div>
+    {% for i in form.privacy_fields %}
+    <div class="checkbox">
+      <label>
+      {{ i }}
+      {{ i.label }}
+      </label>
+    </div>
+    {% endfor %}
+
+    <input type="submit" value="Envoyer" class="btn btn-primary btn-lg"/>
+  </form>
+  </section>
+
+
+<div id="modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="Resultats" aria-hidden="true">
+  <div class="modal-dialog modal-lg">
+    <div class="modal-content">
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Fermer</span></button>
+        <h4 class="modal-title" id="myModalLabel">Résultats</h4>
+      </div>
+      <div class="modal-body">
+      </div>
+    </div>
+  </div>
+</div>
+
+{% endblock %}

+ 0 - 0
sources/wifiwithme/apps/contribmap/templatetags/__init__.py


+ 6 - 0
sources/wifiwithme/apps/contribmap/templatetags/obfuscate_email.py

@@ -0,0 +1,6 @@
+from django import template
+register = template.Library()
+
+@register.filter
+def obfuscate_email ( email ):
+    return email.replace('@', ' [at] ')

+ 240 - 0
sources/wifiwithme/apps/contribmap/tests.py

@@ -0,0 +1,240 @@
+import json
+
+from django.core import mail
+from django.contrib.auth.models import User
+from django.test import TestCase, Client, override_settings
+
+from contribmap.models import Contrib
+from contribmap.forms import PublicContribForm
+
+
+class APITestClient(Client):
+    def json_get(self, *args, **kwargs):
+        """ Annotate the response with a .data containing parsed JSON
+        """
+        response = super().get(*args, **kwargs)
+        response.data = json.loads(response.content.decode('utf-8'))
+        return response
+
+
+class APITestCase(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.client = APITestClient()
+
+
+class TestContrib(TestCase):
+    def test_comma_separatedcharfield(self):
+        co = Contrib(name='foo', orientations=['SO', 'NE'],
+                     contrib_type=Contrib.CONTRIB_CONNECT,
+                     latitude=0.5, longitude=0.5,
+)
+        co.save()
+        self.assertEqual(
+            Contrib.objects.get(name='foo').orientations,
+            ['SO', 'NE'])
+        co.orientations = ['S']
+        co.save()
+
+
+class TestContribPrivacy(TestCase):
+    def test_always_private_field(self):
+        c = Contrib.objects.create(
+            name='John',
+            phone='010101010101',
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            latitude=0.5,
+            longitude=0.5,
+        )
+        self.assertEqual(c.get_public_field('phone'), None)
+
+    def test_public_field(self):
+        c = Contrib.objects.create(
+            name='John',
+            phone='010101010101',
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            privacy_name=True,
+            latitude=0.5,
+            longitude=0.5,
+        )
+        self.assertEqual(c.get_public_field('name'), 'John')
+
+    def test_public_callable_field(self):
+        c = Contrib.objects.create(
+            name='John',
+            phone='010101010101',
+            orientations=['N'],
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            privacy_name=True,
+            latitude=0.5,
+            longitude=0.5,
+        )
+        self.assertEqual(c.get_public_field('angles'), [[-23, 22]])
+
+    def test_private_field(self):
+        c = Contrib.objects.create(
+            name='John',
+            phone='010101010101',
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            latitude=0.5,
+            longitude=0.5,
+        )
+        self.assertEqual(c.privacy_name, False)
+        self.assertEqual(c.get_public_field('name'), None)
+
+
+class TestViews(APITestCase):
+    def test_public_json(self):
+        response = self.client.json_get('/map/public.json')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.data['features']), 0)
+
+        Contrib.objects.create(
+            name='John',
+            phone='010101010101',
+            contrib_type=Contrib.CONTRIB_CONNECT,
+            privacy_coordinates=True,
+            latitude=0.5,
+            longitude=0.5,
+        )
+        response = self.client.json_get('/map/public.json')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.data['features']), 1)
+
+    def test_private_json(self):
+        self.client.force_login(
+            User.objects.create(username='foo', is_staff=False))
+
+        response = self.client.get('/map/private.json')
+        self.assertEqual(response.status_code, 403)
+
+    def test_private_json_staff(self):
+        self.client.force_login(
+            User.objects.create(username='foo', is_staff=True))
+        response = self.client.get('/map/private.json')
+        self.assertEqual(response.status_code, 200)
+
+    @override_settings(NOTIFICATION_EMAILS=['foo@example.com'])
+    def test_add_contrib_sends_email(self):
+        response = self.client.post('/map/contribute', {
+            'roof': True,
+            'privacy_place_details': True,
+            'privacy_coordinates': True,
+            'phone': '0202020202',
+            'orientations': 'N',
+            'orientations': 'NO',
+            'orientations': 'O',
+            'orientations': 'SO',
+            'orientations': 'S',
+            'orientations': 'SE',
+            'orientations': 'E',
+            'orientations': 'NE',
+            'orientation': 'all',
+            'name': 'JohnCleese',
+            'longitude': -1.553621,
+            'latitude': 47.218371,
+            'floor_total': '2',
+            'floor': 1,
+            'email': 'coucou@example.com',
+            'contrib_type': 'connect',
+            'connect_local': 'on',
+        })
+        self.assertEqual(response.status_code, 302)
+
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertIn('JohnCleese', mail.outbox[0].subject)
+        self.assertIn('JohnCleese', mail.outbox[0].body)
+
+<<<<<<< HEAD
+class TestForms(TestCase):
+    valid_data = {
+        'roof': True,
+        'privacy_place_details': True,
+        'privacy_coordinates': True,
+        'orientations': ['N'],
+        'orientation': 'all',
+        'name': 'JohnCleese',
+        'longitude': -1.553621,
+        'email': 'foo@example.com',
+        'phone': '0202020202',
+        'latitude': 47.218371,
+        'floor_total': '2',
+        'floor': 1,
+        'contrib_type': 'connect',
+        'connect_local': 'on',
+    }
+
+    def test_contact_validation(self):
+        no_contact, phone_contact, email_contact, both_contact = [
+            self.valid_data.copy() for i in range(4)]
+
+        del phone_contact['email']
+        del email_contact['phone']
+        del no_contact['phone']
+        del no_contact['email']
+
+        both_contact.update(phone_contact)
+        both_contact.update(email_contact)
+
+        self.assertFalse(PublicContribForm(no_contact).is_valid())
+        self.assertTrue(PublicContribForm(phone_contact).is_valid())
+        self.assertTrue(PublicContribForm(email_contact).is_valid())
+        self.assertTrue(PublicContribForm(both_contact).is_valid())
+
+    def test_floors_validation(self):
+        invalid_floors = self.valid_data.copy()
+        invalid_floors['floor'] = 2
+        invalid_floors['floor_total'] = 1
+
+        self.assertFalse(PublicContribForm(invalid_floors).is_valid())
+        self.assertTrue(PublicContribForm(self.valid_data).is_valid())
+
+        invalid_floors['floor'] = None
+        invalid_floors['floor_total'] = None
+        self.assertTrue(PublicContribForm(invalid_floors).is_valid())
+
+    def test_share_fields_validation(self):
+        data = self.valid_data.copy()
+        data['contrib_type'] = 'share'
+
+        self.assertFalse(PublicContribForm(data).is_valid())
+        data['access_type'] = 'cable'
+        self.assertTrue(PublicContribForm(data).is_valid())
+
+    @override_settings(NOTIFICATION_EMAILS=['foo@example.com'])
+    def test_add_contrib_like_a_robot(self):
+        response = self.client.post('/map/contribute', {
+            'roof': True,
+            'human_field': 'should not have no value',
+            'privacy_place_details': True,
+            'privacy_coordinates': True,
+            'phone': '0202020202',
+            'orientations': 'N',
+            'orientations': 'NO',
+            'orientations': 'O',
+            'orientations': 'SO',
+            'orientations': 'S',
+            'orientations': 'SE',
+            'orientations': 'E',
+            'orientations': 'NE',
+            'orientation': 'all',
+            'name': 'JohnCleese',
+            'longitude': -1.553621,
+            'latitude': 47.218371,
+            'floor_total': '2',
+            'floor': 1,
+            'email': 'coucou@example.com',
+            'contrib_type': 'connect',
+            'connect_local': 'on',
+        })
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(len(mail.outbox), 0)
+
+
+class TestDataImport(TestCase):
+    fixtures = ['bottle_data.yaml']
+
+    def test_re_save(self):
+        for contrib in Contrib.objects.all():
+            contrib.full_clean()
+            contrib.save()

+ 12 - 0
sources/wifiwithme/apps/contribmap/urls.py

@@ -0,0 +1,12 @@
+from django.conf.urls import url
+
+from .views import PublicJSON, PrivateJSON, display_map, legal, add_contrib, thanks
+
+urlpatterns = [
+    url(r'^$', display_map, name='display_map'),
+    url(r'^legal$', legal, name='legal'),
+    url(r'^contribute/thanks', thanks, name='thanks'),
+    url(r'^contribute', add_contrib, name='add_contrib'),
+    url(r'^public.json$', PublicJSON.as_view(), name='public_json'),
+    url(r'^private.json$', PrivateJSON.as_view(), name='private_json'),
+]

+ 37 - 0
sources/wifiwithme/apps/contribmap/utils.py

@@ -0,0 +1,37 @@
+ANGLES = {
+    'N': (-23, 22),
+    'NO': (292, 337),
+    'O': (247, 292),
+    'SO': (202, 247),
+    'S': (157, 202),
+    'SE': (112, 157),
+    'E': (67, 112),
+    'NE': (22, 67)
+}
+
+ORIENTATIONS = ANGLES.keys()
+
+
+def merge_intervals(l, wrap=360):
+    """Merge a list of intervals, assuming the space is cyclic.  The
+    intervals should already by sorted by start value."""
+    if l == []:
+        return []
+    result = list()
+    # Transform the 2-tuple into a 2-list to be able to modify it
+    result.append(list(l[0]))
+    for (start, stop) in l:
+        current = result[-1]
+        if start > current[1]:
+            result.append([start, stop])
+        else:
+            result[-1][1] = max(result[-1][1], stop)
+    if len(result) == 1:
+        return result
+    # Handle the cyclicity by merging the ends if necessary
+    last = result[-1]
+    first = result[0]
+    if first[0] <= last[1] - wrap:
+        result[-1][1] = max(result[-1][1], first[1] + wrap)
+        result.pop(0)
+    return result

+ 159 - 0
sources/wifiwithme/apps/contribmap/views.py

@@ -0,0 +1,159 @@
+from django.conf import settings
+
+from django.core.urlresolvers import reverse
+from django.core.mail import send_mail
+from django.http import JsonResponse, HttpResponseForbidden
+from django.shortcuts import render, redirect
+from django.template.loader import get_template
+from django.views.generic import View
+
+from .forms import PublicContribForm
+from .models import Contrib
+from .decorators import prevent_robots
+
+
+@prevent_robots()
+def add_contrib(request):
+    if request.method == 'GET':
+        form = PublicContribForm()
+    elif request.method == 'POST':
+        form = PublicContribForm(request.POST)
+
+        if form.is_valid():
+            contrib = form.save()
+
+            # Send notification email
+            if len(settings.NOTIFICATION_EMAILS) > 0:
+                context = {
+                    'site_url': settings.SITE_URL,
+                    'contrib': contrib,
+                }
+                subject = get_template(
+                    'contribmap/mails/new_contrib_notice.subject')
+                body = get_template(
+                    'contribmap/mails/new_contrib_notice.txt')
+                send_mail(
+                    subject.render(context),
+                    body.render(context),
+                    settings.DEFAULT_FROM_EMAIL,
+                    settings.NOTIFICATION_EMAILS,
+                )
+
+            return redirect(reverse('thanks'))
+    return render(request, 'contribmap/wifi-form.html', {
+        'form': form,
+        'isp':settings.ISP,
+    })
+
+
+def display_map(request):
+    private_mode = request.user.is_authenticated()
+    if private_mode:
+        json_url = reverse('private_json')
+    else:
+        json_url = reverse('public_json')
+
+    return render(request, 'contribmap/map.html', {
+        'private_mode': private_mode,
+        'json_url': json_url,
+        'isp':settings.ISP,
+    })
+
+
+def thanks(request):
+    return render(request, 'contribmap/thanks.html', {
+        'isp':settings.ISP,
+    })
+
+def legal(request):
+    return render(request, 'contribmap/legal.html', {
+        'isp':settings.ISP,
+    })
+
+class JSONContribView(View):
+    def get(self, request):
+        return JsonResponse({
+            "id": self.ID,
+            "license": self.LICENSE,
+            "features": self.get_features(),
+        })
+
+    PLACE_PROPERTIES = [
+        'floor', 'angles', 'orientations', 'roof', 'floor', 'floor_total']
+
+
+class PublicJSON(JSONContribView):
+    ID = 'public'
+    LICENSE = {
+        "type": "ODC-BY-1.0",
+        "url": "http:\/\/opendatacommons.org\/licenses\/by\/1.0\/"
+    }
+
+    def get_features(self):
+        contribs = Contrib.objects.all()
+
+        data = []
+        for i in contribs:
+            if not i.is_public():
+                continue
+            data.append({
+                "id": i.pk,
+                "type": "Feature",
+                "geometry": {
+                    "coordinates": [
+                        i.longitude,
+                        i.latitude
+                    ],
+                    "type": "Point",
+                },
+                "properties": {
+                    "contrib_type": i.contrib_type,
+                    "name": i.get_public_field('name'),
+                    "place": {
+                        k: i.get_public_field(k) for k in self.PLACE_PROPERTIES
+                    },
+                    "comment": i.get_public_field('comment'),
+                }
+            })
+        return data
+
+
+class PrivateJSON(JSONContribView):
+    ID = 'private'
+    LICENSE = {
+        "type": "Copyright",
+    }
+
+    def dispatch(self, request, *args, **kwargs):
+        if hasattr(request, 'user') and request.user.is_staff:
+            return super().dispatch(request, *args, **kwargs)
+        else:
+            return HttpResponseForbidden('Need staff access')
+
+    def get_features(self):
+        contribs = Contrib.objects.all()
+
+        data = []
+        for i in contribs:
+            data.append({
+                "id": i.pk,
+                "type": "Feature",
+                "geometry": {
+                    "coordinates": [
+                        i.longitude,
+                        i.latitude,
+                    ],
+                    "type": "Point",
+                },
+                "properties": {
+                    "contrib_type": i.contrib_type,
+                    "name": i.name,
+                    "place": {
+                        k: getattr(i, k) for k in self.PLACE_PROPERTIES
+                    },
+                    "comment": i.comment,
+                    "phone": i.phone,
+                    "email": i.email
+                }
+            })
+        return data

+ 0 - 0
sources/wifiwithme/core/__init__.py


+ 128 - 0
sources/wifiwithme/core/py2_compat.py

@@ -0,0 +1,128 @@
+import datetime
+from email._parseaddr import _daynames, _monthnames, _timezones
+
+
+def _parsedate_tz(data):
+    """Convert date to extended time tuple.
+
+    The last (additional) element is the time zone offset in seconds, except if
+    the timezone was specified as -0000.  In that case the last element is
+    None.  This indicates a UTC timestamp that explicitly declaims knowledge of
+    the source timezone, as opposed to a +0000 timestamp that indicates the
+    source timezone really was UTC.
+
+    """
+    if not data:
+        return
+    data = data.split()
+    # The FWS after the comma after the day-of-week is optional, so search and
+    # adjust for this.
+    if data[0].endswith(',') or data[0].lower() in _daynames:
+        # There's a dayname here. Skip it
+        del data[0]
+    else:
+        i = data[0].rfind(',')
+        if i >= 0:
+            data[0] = data[0][i+1:]
+    if len(data) == 3: # RFC 850 date, deprecated
+        stuff = data[0].split('-')
+        if len(stuff) == 3:
+            data = stuff + data[1:]
+    if len(data) == 4:
+        s = data[3]
+        i = s.find('+')
+        if i == -1:
+            i = s.find('-')
+        if i > 0:
+            data[3:] = [s[:i], s[i:]]
+        else:
+            data.append('') # Dummy tz
+    if len(data) < 5:
+        return None
+    data = data[:5]
+    [dd, mm, yy, tm, tz] = data
+    mm = mm.lower()
+    if mm not in _monthnames:
+        dd, mm = mm, dd.lower()
+        if mm not in _monthnames:
+            return None
+    mm = _monthnames.index(mm) + 1
+    if mm > 12:
+        mm -= 12
+    if dd[-1] == ',':
+        dd = dd[:-1]
+    i = yy.find(':')
+    if i > 0:
+        yy, tm = tm, yy
+    if yy[-1] == ',':
+        yy = yy[:-1]
+    if not yy[0].isdigit():
+        yy, tz = tz, yy
+    if tm[-1] == ',':
+        tm = tm[:-1]
+    tm = tm.split(':')
+    if len(tm) == 2:
+        [thh, tmm] = tm
+        tss = '0'
+    elif len(tm) == 3:
+        [thh, tmm, tss] = tm
+    elif len(tm) == 1 and '.' in tm[0]:
+        # Some non-compliant MUAs use '.' to separate time elements.
+        tm = tm[0].split('.')
+        if len(tm) == 2:
+            [thh, tmm] = tm
+            tss = 0
+        elif len(tm) == 3:
+            [thh, tmm, tss] = tm
+    else:
+        return None
+    try:
+        yy = int(yy)
+        dd = int(dd)
+        thh = int(thh)
+        tmm = int(tmm)
+        tss = int(tss)
+    except ValueError:
+        return None
+    # Check for a yy specified in two-digit format, then convert it to the
+    # appropriate four-digit format, according to the POSIX standard. RFC 822
+    # calls for a two-digit yy, but RFC 2822 (which obsoletes RFC 822)
+    # mandates a 4-digit yy. For more information, see the documentation for
+    # the time module.
+    if yy < 100:
+        # The year is between 1969 and 1999 (inclusive).
+        if yy > 68:
+            yy += 1900
+        # The year is between 2000 and 2068 (inclusive).
+        else:
+            yy += 2000
+    tzoffset = None
+    tz = tz.upper()
+    if tz in _timezones:
+        tzoffset = _timezones[tz]
+    else:
+        try:
+            tzoffset = int(tz)
+        except ValueError:
+            pass
+        if tzoffset==0 and tz.startswith('-'):
+            tzoffset = None
+    # Convert a timezone offset into seconds ; -0500 -> -18000
+    if tzoffset:
+        if tzoffset < 0:
+            tzsign = -1
+            tzoffset = -tzoffset
+        else:
+            tzsign = 1
+        tzoffset = tzsign * ( (tzoffset//100)*3600 + (tzoffset % 100)*60)
+    # Daylight Saving Time flag is set to -1, since DST is unknown.
+    return [yy, mm, dd, thh, tmm, tss, 0, 1, -1, tzoffset]
+
+
+def parsedate_to_datetime(data):
+    dtuple, tz = _parsedate_tz(data)
+    if tz is None:
+        return datetime.datetime(*dtuple[:6])
+    return datetime.datetime(
+        *dtuple[:6],
+        tzinfo=datetime.timezone(datetime.timedelta(seconds=tz)))

+ 0 - 0
sources/wifiwithme/core/templates/.placeholder


+ 6 - 0
sources/wifiwithme/core/templates/registration/logged_out.html

@@ -0,0 +1,6 @@
+{% extends "base.html" %}
+{% block content %}
+<p class="alert alert-info">
+  Vous avez été deconnecté ; vous pouvez <a href="{% url 'login' %}">vous reconnecter</a>.
+</p>
+{% endblock %}

+ 21 - 0
sources/wifiwithme/core/templates/registration/login.html

@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+{% load bootstrap %}
+
+{% block content %}
+{% if form.errors %}
+<p class="alert alert-danger">Mauvais utilisateur / mot de passe, essaye encore..</p>
+{% endif %}
+<form method="post" action="{% url 'django.contrib.auth.views.login' %}">
+{% csrf_token %}
+  <div class="form-group">
+    {{ form.username.label_tag }}
+    {{ form.username|formcontrol }}
+  </div>
+  <div class="form-group">
+    {{ form.password.label_tag }}
+    {{ form.password|formcontrol }}
+  </div>
+<input class="btn btm-default" type="submit" value="Se connnecter" />
+<input type="hidden" name="next" value="{{ next }}" />
+</form>
+{% endblock %}

+ 0 - 0
sources/wifiwithme/core/templatetags/__init__.py


+ 13 - 0
sources/wifiwithme/core/templatetags/bootstrap.py

@@ -0,0 +1,13 @@
+#
+from django import template
+
+register = template.Library()
+
+
+@register.filter(name='formcontrol')
+def formcontrol(field):
+    """Adds formcontrol class to an inputfield
+
+    For bootstrap form rendering
+    """
+    return field.as_widget(attrs={"class": 'form-control'})

+ 17 - 0
sources/wifiwithme/core/urls.py

@@ -0,0 +1,17 @@
+from django.conf import settings
+from django.conf.urls import url, include
+from django.contrib import admin
+from django.contrib.auth.views import login, logout
+
+def prefix(url_pattern):
+    """
+    :param url: url pattern, without leading "^"
+    """
+    return '^{}{}'.format(settings.URL_PREFIX, url_pattern)
+
+urlpatterns = [
+    url(prefix(r'accounts/login/$'), login, name='login'),
+    url(prefix(r'accounts/logout/$'), logout, name='logout'),
+    url(prefix(r'admin/'), admin.site.urls),
+    url(prefix(r'map/'), include('contribmap.urls')),
+]

+ 0 - 0
sources/wifiwithme/settings/__init__.py


+ 138 - 0
sources/wifiwithme/settings/base.py

@@ -0,0 +1,138 @@
+"""
+Django settings for wifiwithme project.
+
+Generated by 'django-admin startproject' using Django 1.9.2.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.9/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/1.9/ref/settings/
+"""
+
+import os
+import sys
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'e7#ho6q5-vjt58#elu#fyl^qzygvz=dkhv@fau96bfe+6kjipt'
+
+
+ALLOWED_HOSTS = []
+
+# Application definition
+
+# Add apps/ to the Python path
+sys.path = [os.path.join(BASE_DIR, 'apps'), BASE_DIR] + sys.path
+
+INSTALLED_APPS = [
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+    'core',
+    'contribmap',
+    'django.contrib.admin',
+]
+
+MIDDLEWARE_CLASSES = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'wifiwithme.core.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [os.path.join(BASE_DIR, 'templates')],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+WSGI_APPLICATION = 'wifiwithme.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': os.path.join(os.path.dirname(BASE_DIR), 'db.sqlite3'),
+    }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+    },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/1.9/topics/i18n/
+
+LANGUAGE_CODE = 'fr-fr'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+LOGIN_REDIRECT_URL="display_map"
+
+## URL Prefixing
+
+URL_PREFIX=''
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.9/howto/static-files/
+# STATIC_URL = '/{}assets/'.format(URL_PREFIX)
+# not defined here cause prefix overriding would not be taken into account
+
+STATICFILES_DIRS = [
+    os.path.join(BASE_DIR, "static"),
+]
+
+# Where to send notification after a point is added via the form.
+
+NOTIFICATION_EMAILS = []
+
+SITE_URL = 'http://example.com/wifi'

+ 14 - 0
sources/wifiwithme/settings/dev.py

@@ -0,0 +1,14 @@
+from .base import *
+
+DEBUG = True
+INSTALLED_APPS += [
+    'debug_toolbar',
+]
+
+try:
+    from .local import *
+except ImportError:
+    pass
+
+EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
+STATIC_URL = '/{}assets/'.format(URL_PREFIX)

+ 37 - 0
sources/wifiwithme/settings/prod.py

@@ -0,0 +1,37 @@
+# You must set a customized an non versioned SECRET_KEY in production mode
+
+DEBUG = False
+SECRET_KEY = None
+
+from .base import *
+
+try:
+    from .local import *
+except ImportError:
+    pass
+
+STATIC_URL = '/{}assets/'.format(URL_PREFIX)
+
+
+LOGGING = {
+    'version': 1,
+    'disable_existing_loggers': False,
+    'formatters': {
+        'verbose': {
+            'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s',
+            'datefmt': "%d/%b/%Y %H:%M:%S",
+        },
+    },
+    'handlers': {
+        'console': {
+            'level': 'DEBUG',
+            'class': 'logging.StreamHandler',
+        },
+     },
+     'loggers': {
+        'django': {
+            'handlers': ['console'],
+            'level': 'ERROR',
+        },
+    },
+}

+ 429 - 0
sources/wifiwithme/static/bootstrap/config.json

@@ -0,0 +1,429 @@
+{
+  "vars": {
+    "@gray-base": "#000",
+    "@gray-darker": "lighten(@gray-base, 13.5%)",
+    "@gray-dark": "lighten(@gray-base, 20%)",
+    "@gray": "lighten(@gray-base, 33.5%)",
+    "@gray-light": "lighten(@gray-base, 46.7%)",
+    "@gray-lighter": "lighten(@gray-base, 93.5%)",
+    "@brand-primary": "darken(#428bca, 6.5%)",
+    "@brand-success": "#5cb85c",
+    "@brand-info": "#5bc0de",
+    "@brand-warning": "#f0ad4e",
+    "@brand-danger": "#d9534f",
+    "@body-bg": "#fff",
+    "@text-color": "@gray-dark",
+    "@link-color": "@brand-primary",
+    "@link-hover-color": "darken(@link-color, 15%)",
+    "@link-hover-decoration": "underline",
+    "@font-family-sans-serif": "\"Helvetica Neue\", Helvetica, Arial, sans-serif",
+    "@font-family-serif": "Georgia, \"Times New Roman\", Times, serif",
+    "@font-family-monospace": "Menlo, Monaco, Consolas, \"Courier New\", monospace",
+    "@font-family-base": "@font-family-sans-serif",
+    "@font-size-base": "14px",
+    "@font-size-large": "ceil((@font-size-base * 1.25))",
+    "@font-size-small": "ceil((@font-size-base * 0.85))",
+    "@font-size-h1": "floor((@font-size-base * 2.6))",
+    "@font-size-h2": "floor((@font-size-base * 2.15))",
+    "@font-size-h3": "ceil((@font-size-base * 1.7))",
+    "@font-size-h4": "ceil((@font-size-base * 1.25))",
+    "@font-size-h5": "@font-size-base",
+    "@font-size-h6": "ceil((@font-size-base * 0.85))",
+    "@line-height-base": "1.428571429",
+    "@line-height-computed": "floor((@font-size-base * @line-height-base))",
+    "@headings-font-family": "inherit",
+    "@headings-font-weight": "500",
+    "@headings-line-height": "1.1",
+    "@headings-color": "inherit",
+    "@icon-font-path": "\"../fonts/\"",
+    "@icon-font-name": "\"glyphicons-halflings-regular\"",
+    "@icon-font-svg-id": "\"glyphicons_halflingsregular\"",
+    "@padding-base-vertical": "6px",
+    "@padding-base-horizontal": "12px",
+    "@padding-large-vertical": "10px",
+    "@padding-large-horizontal": "16px",
+    "@padding-small-vertical": "5px",
+    "@padding-small-horizontal": "10px",
+    "@padding-xs-vertical": "1px",
+    "@padding-xs-horizontal": "5px",
+    "@line-height-large": "1.33",
+    "@line-height-small": "1.5",
+    "@border-radius-base": "4px",
+    "@border-radius-large": "6px",
+    "@border-radius-small": "3px",
+    "@component-active-color": "#fff",
+    "@component-active-bg": "@brand-primary",
+    "@caret-width-base": "4px",
+    "@caret-width-large": "5px",
+    "@table-cell-padding": "8px",
+    "@table-condensed-cell-padding": "5px",
+    "@table-bg": "transparent",
+    "@table-bg-accent": "#f9f9f9",
+    "@table-bg-hover": "#f5f5f5",
+    "@table-bg-active": "@table-bg-hover",
+    "@table-border-color": "#ddd",
+    "@btn-font-weight": "normal",
+    "@btn-default-color": "#333",
+    "@btn-default-bg": "#fff",
+    "@btn-default-border": "#ccc",
+    "@btn-primary-color": "#fff",
+    "@btn-primary-bg": "@brand-primary",
+    "@btn-primary-border": "darken(@btn-primary-bg, 5%)",
+    "@btn-success-color": "#fff",
+    "@btn-success-bg": "@brand-success",
+    "@btn-success-border": "darken(@btn-success-bg, 5%)",
+    "@btn-info-color": "#fff",
+    "@btn-info-bg": "@brand-info",
+    "@btn-info-border": "darken(@btn-info-bg, 5%)",
+    "@btn-warning-color": "#fff",
+    "@btn-warning-bg": "@brand-warning",
+    "@btn-warning-border": "darken(@btn-warning-bg, 5%)",
+    "@btn-danger-color": "#fff",
+    "@btn-danger-bg": "@brand-danger",
+    "@btn-danger-border": "darken(@btn-danger-bg, 5%)",
+    "@btn-link-disabled-color": "@gray-light",
+    "@input-bg": "#fff",
+    "@input-bg-disabled": "@gray-lighter",
+    "@input-color": "@gray",
+    "@input-border": "#ccc",
+    "@input-border-radius": "@border-radius-base",
+    "@input-border-radius-large": "@border-radius-large",
+    "@input-border-radius-small": "@border-radius-small",
+    "@input-border-focus": "#66afe9",
+    "@input-color-placeholder": "#999",
+    "@input-height-base": "(@line-height-computed + (@padding-base-vertical * 2) + 2)",
+    "@input-height-large": "(ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2)",
+    "@input-height-small": "(floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2)",
+    "@legend-color": "@gray-dark",
+    "@legend-border-color": "#e5e5e5",
+    "@input-group-addon-bg": "@gray-lighter",
+    "@input-group-addon-border-color": "@input-border",
+    "@cursor-disabled": "not-allowed",
+    "@dropdown-bg": "#fff",
+    "@dropdown-border": "rgba(0,0,0,.15)",
+    "@dropdown-fallback-border": "#ccc",
+    "@dropdown-divider-bg": "#e5e5e5",
+    "@dropdown-link-color": "@gray-dark",
+    "@dropdown-link-hover-color": "darken(@gray-dark, 5%)",
+    "@dropdown-link-hover-bg": "#f5f5f5",
+    "@dropdown-link-active-color": "@component-active-color",
+    "@dropdown-link-active-bg": "@component-active-bg",
+    "@dropdown-link-disabled-color": "@gray-light",
+    "@dropdown-header-color": "@gray-light",
+    "@dropdown-caret-color": "#000",
+    "@screen-xs": "480px",
+    "@screen-xs-min": "@screen-xs",
+    "@screen-phone": "@screen-xs-min",
+    "@screen-sm": "768px",
+    "@screen-sm-min": "@screen-sm",
+    "@screen-tablet": "@screen-sm-min",
+    "@screen-md": "992px",
+    "@screen-md-min": "@screen-md",
+    "@screen-desktop": "@screen-md-min",
+    "@screen-lg": "1200px",
+    "@screen-lg-min": "@screen-lg",
+    "@screen-lg-desktop": "@screen-lg-min",
+    "@screen-xs-max": "(@screen-sm-min - 1)",
+    "@screen-sm-max": "(@screen-md-min - 1)",
+    "@screen-md-max": "(@screen-lg-min - 1)",
+    "@grid-columns": "12",
+    "@grid-gutter-width": "30px",
+    "@grid-float-breakpoint": "@screen-sm-min",
+    "@grid-float-breakpoint-max": "(@grid-float-breakpoint - 1)",
+    "@container-tablet": "(720px + @grid-gutter-width)",
+    "@container-sm": "@container-tablet",
+    "@container-desktop": "(940px + @grid-gutter-width)",
+    "@container-md": "@container-desktop",
+    "@container-large-desktop": "(1040px + @grid-gutter-width)",
+    "@container-lg": "@container-large-desktop",
+    "@navbar-height": "50px",
+    "@navbar-margin-bottom": "@line-height-computed",
+    "@navbar-border-radius": "@border-radius-base",
+    "@navbar-padding-horizontal": "floor((@grid-gutter-width / 2))",
+    "@navbar-padding-vertical": "((@navbar-height - @line-height-computed) / 2)",
+    "@navbar-collapse-max-height": "340px",
+    "@navbar-default-color": "#777",
+    "@navbar-default-bg": "#f8f8f8",
+    "@navbar-default-border": "darken(@navbar-default-bg, 6.5%)",
+    "@navbar-default-link-color": "#777",
+    "@navbar-default-link-hover-color": "#333",
+    "@navbar-default-link-hover-bg": "transparent",
+    "@navbar-default-link-active-color": "#555",
+    "@navbar-default-link-active-bg": "darken(@navbar-default-bg, 6.5%)",
+    "@navbar-default-link-disabled-color": "#ccc",
+    "@navbar-default-link-disabled-bg": "transparent",
+    "@navbar-default-brand-color": "@navbar-default-link-color",
+    "@navbar-default-brand-hover-color": "darken(@navbar-default-brand-color, 10%)",
+    "@navbar-default-brand-hover-bg": "transparent",
+    "@navbar-default-toggle-hover-bg": "#ddd",
+    "@navbar-default-toggle-icon-bar-bg": "#888",
+    "@navbar-default-toggle-border-color": "#ddd",
+    "@navbar-inverse-color": "lighten(@gray-light, 15%)",
+    "@navbar-inverse-bg": "#222",
+    "@navbar-inverse-border": "darken(@navbar-inverse-bg, 10%)",
+    "@navbar-inverse-link-color": "lighten(@gray-light, 15%)",
+    "@navbar-inverse-link-hover-color": "#fff",
+    "@navbar-inverse-link-hover-bg": "transparent",
+    "@navbar-inverse-link-active-color": "@navbar-inverse-link-hover-color",
+    "@navbar-inverse-link-active-bg": "darken(@navbar-inverse-bg, 10%)",
+    "@navbar-inverse-link-disabled-color": "#444",
+    "@navbar-inverse-link-disabled-bg": "transparent",
+    "@navbar-inverse-brand-color": "@navbar-inverse-link-color",
+    "@navbar-inverse-brand-hover-color": "#fff",
+    "@navbar-inverse-brand-hover-bg": "transparent",
+    "@navbar-inverse-toggle-hover-bg": "#333",
+    "@navbar-inverse-toggle-icon-bar-bg": "#fff",
+    "@navbar-inverse-toggle-border-color": "#333",
+    "@nav-link-padding": "10px 15px",
+    "@nav-link-hover-bg": "@gray-lighter",
+    "@nav-disabled-link-color": "@gray-light",
+    "@nav-disabled-link-hover-color": "@gray-light",
+    "@nav-tabs-border-color": "#ddd",
+    "@nav-tabs-link-hover-border-color": "@gray-lighter",
+    "@nav-tabs-active-link-hover-bg": "@body-bg",
+    "@nav-tabs-active-link-hover-color": "@gray",
+    "@nav-tabs-active-link-hover-border-color": "#ddd",
+    "@nav-tabs-justified-link-border-color": "#ddd",
+    "@nav-tabs-justified-active-link-border-color": "@body-bg",
+    "@nav-pills-border-radius": "@border-radius-base",
+    "@nav-pills-active-link-hover-bg": "@component-active-bg",
+    "@nav-pills-active-link-hover-color": "@component-active-color",
+    "@pagination-color": "@link-color",
+    "@pagination-bg": "#fff",
+    "@pagination-border": "#ddd",
+    "@pagination-hover-color": "@link-hover-color",
+    "@pagination-hover-bg": "@gray-lighter",
+    "@pagination-hover-border": "#ddd",
+    "@pagination-active-color": "#fff",
+    "@pagination-active-bg": "@brand-primary",
+    "@pagination-active-border": "@brand-primary",
+    "@pagination-disabled-color": "@gray-light",
+    "@pagination-disabled-bg": "#fff",
+    "@pagination-disabled-border": "#ddd",
+    "@pager-bg": "@pagination-bg",
+    "@pager-border": "@pagination-border",
+    "@pager-border-radius": "15px",
+    "@pager-hover-bg": "@pagination-hover-bg",
+    "@pager-active-bg": "@pagination-active-bg",
+    "@pager-active-color": "@pagination-active-color",
+    "@pager-disabled-color": "@pagination-disabled-color",
+    "@jumbotron-padding": "30px",
+    "@jumbotron-color": "inherit",
+    "@jumbotron-bg": "@gray-lighter",
+    "@jumbotron-heading-color": "inherit",
+    "@jumbotron-font-size": "ceil((@font-size-base * 1.5))",
+    "@state-success-text": "#3c763d",
+    "@state-success-bg": "#dff0d8",
+    "@state-success-border": "darken(spin(@state-success-bg, -10), 5%)",
+    "@state-info-text": "#31708f",
+    "@state-info-bg": "#d9edf7",
+    "@state-info-border": "darken(spin(@state-info-bg, -10), 7%)",
+    "@state-warning-text": "#8a6d3b",
+    "@state-warning-bg": "#fcf8e3",
+    "@state-warning-border": "darken(spin(@state-warning-bg, -10), 5%)",
+    "@state-danger-text": "#a94442",
+    "@state-danger-bg": "#f2dede",
+    "@state-danger-border": "darken(spin(@state-danger-bg, -10), 5%)",
+    "@tooltip-max-width": "200px",
+    "@tooltip-color": "#fff",
+    "@tooltip-bg": "#000",
+    "@tooltip-opacity": ".9",
+    "@tooltip-arrow-width": "5px",
+    "@tooltip-arrow-color": "@tooltip-bg",
+    "@popover-bg": "#fff",
+    "@popover-max-width": "276px",
+    "@popover-border-color": "rgba(0,0,0,.2)",
+    "@popover-fallback-border-color": "#ccc",
+    "@popover-title-bg": "darken(@popover-bg, 3%)",
+    "@popover-arrow-width": "10px",
+    "@popover-arrow-color": "@popover-bg",
+    "@popover-arrow-outer-width": "(@popover-arrow-width + 1)",
+    "@popover-arrow-outer-color": "fadein(@popover-border-color, 5%)",
+    "@popover-arrow-outer-fallback-color": "darken(@popover-fallback-border-color, 20%)",
+    "@label-default-bg": "@gray-light",
+    "@label-primary-bg": "@brand-primary",
+    "@label-success-bg": "@brand-success",
+    "@label-info-bg": "@brand-info",
+    "@label-warning-bg": "@brand-warning",
+    "@label-danger-bg": "@brand-danger",
+    "@label-color": "#fff",
+    "@label-link-hover-color": "#fff",
+    "@modal-inner-padding": "15px",
+    "@modal-title-padding": "15px",
+    "@modal-title-line-height": "@line-height-base",
+    "@modal-content-bg": "#fff",
+    "@modal-content-border-color": "rgba(0,0,0,.2)",
+    "@modal-content-fallback-border-color": "#999",
+    "@modal-backdrop-bg": "#000",
+    "@modal-backdrop-opacity": ".5",
+    "@modal-header-border-color": "#e5e5e5",
+    "@modal-footer-border-color": "@modal-header-border-color",
+    "@modal-lg": "900px",
+    "@modal-md": "600px",
+    "@modal-sm": "300px",
+    "@alert-padding": "15px",
+    "@alert-border-radius": "@border-radius-base",
+    "@alert-link-font-weight": "bold",
+    "@alert-success-bg": "@state-success-bg",
+    "@alert-success-text": "@state-success-text",
+    "@alert-success-border": "@state-success-border",
+    "@alert-info-bg": "@state-info-bg",
+    "@alert-info-text": "@state-info-text",
+    "@alert-info-border": "@state-info-border",
+    "@alert-warning-bg": "@state-warning-bg",
+    "@alert-warning-text": "@state-warning-text",
+    "@alert-warning-border": "@state-warning-border",
+    "@alert-danger-bg": "@state-danger-bg",
+    "@alert-danger-text": "@state-danger-text",
+    "@alert-danger-border": "@state-danger-border",
+    "@progress-bg": "#f5f5f5",
+    "@progress-bar-color": "#fff",
+    "@progress-border-radius": "@border-radius-base",
+    "@progress-bar-bg": "@brand-primary",
+    "@progress-bar-success-bg": "@brand-success",
+    "@progress-bar-warning-bg": "@brand-warning",
+    "@progress-bar-danger-bg": "@brand-danger",
+    "@progress-bar-info-bg": "@brand-info",
+    "@list-group-bg": "#fff",
+    "@list-group-border": "#ddd",
+    "@list-group-border-radius": "@border-radius-base",
+    "@list-group-hover-bg": "#f5f5f5",
+    "@list-group-active-color": "@component-active-color",
+    "@list-group-active-bg": "@component-active-bg",
+    "@list-group-active-border": "@list-group-active-bg",
+    "@list-group-active-text-color": "lighten(@list-group-active-bg, 40%)",
+    "@list-group-disabled-color": "@gray-light",
+    "@list-group-disabled-bg": "@gray-lighter",
+    "@list-group-disabled-text-color": "@list-group-disabled-color",
+    "@list-group-link-color": "#555",
+    "@list-group-link-hover-color": "@list-group-link-color",
+    "@list-group-link-heading-color": "#333",
+    "@panel-bg": "#fff",
+    "@panel-body-padding": "15px",
+    "@panel-heading-padding": "10px 15px",
+    "@panel-footer-padding": "@panel-heading-padding",
+    "@panel-border-radius": "@border-radius-base",
+    "@panel-inner-border": "#ddd",
+    "@panel-footer-bg": "#f5f5f5",
+    "@panel-default-text": "@gray-dark",
+    "@panel-default-border": "#ddd",
+    "@panel-default-heading-bg": "#f5f5f5",
+    "@panel-primary-text": "#fff",
+    "@panel-primary-border": "@brand-primary",
+    "@panel-primary-heading-bg": "@brand-primary",
+    "@panel-success-text": "@state-success-text",
+    "@panel-success-border": "@state-success-border",
+    "@panel-success-heading-bg": "@state-success-bg",
+    "@panel-info-text": "@state-info-text",
+    "@panel-info-border": "@state-info-border",
+    "@panel-info-heading-bg": "@state-info-bg",
+    "@panel-warning-text": "@state-warning-text",
+    "@panel-warning-border": "@state-warning-border",
+    "@panel-warning-heading-bg": "@state-warning-bg",
+    "@panel-danger-text": "@state-danger-text",
+    "@panel-danger-border": "@state-danger-border",
+    "@panel-danger-heading-bg": "@state-danger-bg",
+    "@thumbnail-padding": "4px",
+    "@thumbnail-bg": "@body-bg",
+    "@thumbnail-border": "#ddd",
+    "@thumbnail-border-radius": "@border-radius-base",
+    "@thumbnail-caption-color": "@text-color",
+    "@thumbnail-caption-padding": "9px",
+    "@well-bg": "#f5f5f5",
+    "@well-border": "darken(@well-bg, 7%)",
+    "@badge-color": "#fff",
+    "@badge-link-hover-color": "#fff",
+    "@badge-bg": "@gray-light",
+    "@badge-active-color": "@link-color",
+    "@badge-active-bg": "#fff",
+    "@badge-font-weight": "bold",
+    "@badge-line-height": "1",
+    "@badge-border-radius": "10px",
+    "@breadcrumb-padding-vertical": "8px",
+    "@breadcrumb-padding-horizontal": "15px",
+    "@breadcrumb-bg": "#f5f5f5",
+    "@breadcrumb-color": "#ccc",
+    "@breadcrumb-active-color": "@gray-light",
+    "@breadcrumb-separator": "\"/\"",
+    "@carousel-text-shadow": "0 1px 2px rgba(0,0,0,.6)",
+    "@carousel-control-color": "#fff",
+    "@carousel-control-width": "15%",
+    "@carousel-control-opacity": ".5",
+    "@carousel-control-font-size": "20px",
+    "@carousel-indicator-active-bg": "#fff",
+    "@carousel-indicator-border-color": "#fff",
+    "@carousel-caption-color": "#fff",
+    "@close-font-weight": "bold",
+    "@close-color": "#000",
+    "@close-text-shadow": "0 1px 0 #fff",
+    "@code-color": "#c7254e",
+    "@code-bg": "#f9f2f4",
+    "@kbd-color": "#fff",
+    "@kbd-bg": "#333",
+    "@pre-bg": "#f5f5f5",
+    "@pre-color": "@gray-dark",
+    "@pre-border-color": "#ccc",
+    "@pre-scrollable-max-height": "340px",
+    "@component-offset-horizontal": "180px",
+    "@text-muted": "@gray-light",
+    "@abbr-border-color": "@gray-light",
+    "@headings-small-color": "@gray-light",
+    "@blockquote-small-color": "@gray-light",
+    "@blockquote-font-size": "(@font-size-base * 1.25)",
+    "@blockquote-border-color": "@gray-lighter",
+    "@page-header-border-color": "@gray-lighter",
+    "@dl-horizontal-offset": "@component-offset-horizontal",
+    "@hr-border": "@gray-lighter"
+  },
+  "css": [
+    "print.less",
+    "type.less",
+    "code.less",
+    "grid.less",
+    "tables.less",
+    "forms.less",
+    "buttons.less",
+    "responsive-utilities.less",
+    "glyphicons.less",
+    "button-groups.less",
+    "input-groups.less",
+    "navs.less",
+    "navbar.less",
+    "breadcrumbs.less",
+    "pagination.less",
+    "pager.less",
+    "labels.less",
+    "badges.less",
+    "jumbotron.less",
+    "thumbnails.less",
+    "alerts.less",
+    "progress-bars.less",
+    "media.less",
+    "list-group.less",
+    "panels.less",
+    "responsive-embed.less",
+    "wells.less",
+    "close.less",
+    "component-animations.less",
+    "dropdowns.less",
+    "tooltip.less",
+    "popovers.less",
+    "modals.less",
+    "carousel.less"
+  ],
+  "js": [
+    "alert.js",
+    "button.js",
+    "carousel.js",
+    "dropdown.js",
+    "modal.js",
+    "tooltip.js",
+    "popover.js",
+    "tab.js",
+    "affix.js",
+    "collapse.js",
+    "scrollspy.js",
+    "transition.js"
+  ],
+  "customizerUrl": "http://getbootstrap.com/customize/?id=78fc025c489715af3892"
+}

+ 473 - 0
sources/wifiwithme/static/bootstrap/css/bootstrap-theme.css

@@ -0,0 +1,473 @@
+/*!
+ * Bootstrap v3.3.1 (http://getbootstrap.com)
+ * Copyright 2011-2014 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ */
+
+/*!
+ * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=78fc025c489715af3892)
+ * Config saved to config.json and https://gist.github.com/78fc025c489715af3892
+ */
+.btn-default,
+.btn-primary,
+.btn-success,
+.btn-info,
+.btn-warning,
+.btn-danger {
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+.btn-default:active,
+.btn-primary:active,
+.btn-success:active,
+.btn-info:active,
+.btn-warning:active,
+.btn-danger:active,
+.btn-default.active,
+.btn-primary.active,
+.btn-success.active,
+.btn-info.active,
+.btn-warning.active,
+.btn-danger.active {
+  -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+  box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+}
+.btn-default .badge,
+.btn-primary .badge,
+.btn-success .badge,
+.btn-info .badge,
+.btn-warning .badge,
+.btn-danger .badge {
+  text-shadow: none;
+}
+.btn:active,
+.btn.active {
+  background-image: none;
+}
+.btn-default {
+  background-image: -webkit-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);
+  background-image: -o-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#ffffff), to(#e0e0e0));
+  background-image: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%);
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+  background-repeat: repeat-x;
+  border-color: #dbdbdb;
+  text-shadow: 0 1px 0 #fff;
+  border-color: #ccc;
+}
+.btn-default:hover,
+.btn-default:focus {
+  background-color: #e0e0e0;
+  background-position: 0 -15px;
+}
+.btn-default:active,
+.btn-default.active {
+  background-color: #e0e0e0;
+  border-color: #dbdbdb;
+}
+.btn-default:disabled,
+.btn-default[disabled] {
+  background-color: #e0e0e0;
+  background-image: none;
+}
+.btn-primary {
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
+  background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+  background-repeat: repeat-x;
+  border-color: #245580;
+}
+.btn-primary:hover,
+.btn-primary:focus {
+  background-color: #265a88;
+  background-position: 0 -15px;
+}
+.btn-primary:active,
+.btn-primary.active {
+  background-color: #265a88;
+  border-color: #245580;
+}
+.btn-primary:disabled,
+.btn-primary[disabled] {
+  background-color: #265a88;
+  background-image: none;
+}
+.btn-success {
+  background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
+  background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
+  background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+  background-repeat: repeat-x;
+  border-color: #3e8f3e;
+}
+.btn-success:hover,
+.btn-success:focus {
+  background-color: #419641;
+  background-position: 0 -15px;
+}
+.btn-success:active,
+.btn-success.active {
+  background-color: #419641;
+  border-color: #3e8f3e;
+}
+.btn-success:disabled,
+.btn-success[disabled] {
+  background-color: #419641;
+  background-image: none;
+}
+.btn-info {
+  background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
+  background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
+  background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+  background-repeat: repeat-x;
+  border-color: #28a4c9;
+}
+.btn-info:hover,
+.btn-info:focus {
+  background-color: #2aabd2;
+  background-position: 0 -15px;
+}
+.btn-info:active,
+.btn-info.active {
+  background-color: #2aabd2;
+  border-color: #28a4c9;
+}
+.btn-info:disabled,
+.btn-info[disabled] {
+  background-color: #2aabd2;
+  background-image: none;
+}
+.btn-warning {
+  background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
+  background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
+  background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+  background-repeat: repeat-x;
+  border-color: #e38d13;
+}
+.btn-warning:hover,
+.btn-warning:focus {
+  background-color: #eb9316;
+  background-position: 0 -15px;
+}
+.btn-warning:active,
+.btn-warning.active {
+  background-color: #eb9316;
+  border-color: #e38d13;
+}
+.btn-warning:disabled,
+.btn-warning[disabled] {
+  background-color: #eb9316;
+  background-image: none;
+}
+.btn-danger {
+  background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
+  background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
+  background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+  background-repeat: repeat-x;
+  border-color: #b92c28;
+}
+.btn-danger:hover,
+.btn-danger:focus {
+  background-color: #c12e2a;
+  background-position: 0 -15px;
+}
+.btn-danger:active,
+.btn-danger.active {
+  background-color: #c12e2a;
+  border-color: #b92c28;
+}
+.btn-danger:disabled,
+.btn-danger[disabled] {
+  background-color: #c12e2a;
+  background-image: none;
+}
+.thumbnail,
+.img-thumbnail {
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
+}
+.dropdown-menu > li > a:hover,
+.dropdown-menu > li > a:focus {
+  background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
+  background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
+  background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
+  background-color: #e8e8e8;
+}
+.dropdown-menu > .active > a,
+.dropdown-menu > .active > a:hover,
+.dropdown-menu > .active > a:focus {
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
+  background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
+  background-color: #2e6da4;
+}
+.navbar-default {
+  background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);
+  background-image: -o-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#ffffff), to(#f8f8f8));
+  background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+  border-radius: 4px;
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);
+}
+.navbar-default .navbar-nav > .open > a,
+.navbar-default .navbar-nav > .active > a {
+  background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
+  background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
+  background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
+  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);
+  box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);
+}
+.navbar-brand,
+.navbar-nav > li > a {
+  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
+}
+.navbar-inverse {
+  background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222222 100%);
+  background-image: -o-linear-gradient(top, #3c3c3c 0%, #222222 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222222));
+  background-image: linear-gradient(to bottom, #3c3c3c 0%, #222222 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+}
+.navbar-inverse .navbar-nav > .open > a,
+.navbar-inverse .navbar-nav > .active > a {
+  background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
+  background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
+  background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
+  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);
+  box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);
+}
+.navbar-inverse .navbar-brand,
+.navbar-inverse .navbar-nav > li > a {
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+}
+.navbar-static-top,
+.navbar-fixed-top,
+.navbar-fixed-bottom {
+  border-radius: 0;
+}
+@media (max-width: 767px) {
+  .navbar .navbar-nav .open .dropdown-menu > .active > a,
+  .navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
+  .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
+    color: #fff;
+    background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+    background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+    background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
+    background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
+    background-repeat: repeat-x;
+    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
+  }
+}
+.alert {
+  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);
+  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+.alert-success {
+  background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
+  background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
+  background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
+  border-color: #b2dba1;
+}
+.alert-info {
+  background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
+  background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
+  background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
+  border-color: #9acfea;
+}
+.alert-warning {
+  background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
+  background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
+  background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
+  border-color: #f5e79e;
+}
+.alert-danger {
+  background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
+  background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
+  background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
+  border-color: #dca7a7;
+}
+.progress {
+  background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
+  background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
+  background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
+}
+.progress-bar {
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
+  background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
+}
+.progress-bar-success {
+  background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
+  background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
+  background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
+}
+.progress-bar-info {
+  background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
+  background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
+  background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
+}
+.progress-bar-warning {
+  background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
+  background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
+  background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
+}
+.progress-bar-danger {
+  background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
+  background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
+  background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
+}
+.progress-bar-striped {
+  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+  background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+  background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+}
+.list-group {
+  border-radius: 4px;
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);
+}
+.list-group-item.active,
+.list-group-item.active:hover,
+.list-group-item.active:focus {
+  text-shadow: 0 -1px 0 #286090;
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
+  background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
+  border-color: #2b669a;
+}
+.list-group-item.active .badge,
+.list-group-item.active:hover .badge,
+.list-group-item.active:focus .badge {
+  text-shadow: none;
+}
+.panel {
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+.panel-default > .panel-heading {
+  background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
+  background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
+  background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
+}
+.panel-primary > .panel-heading {
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+  background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
+  background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
+}
+.panel-success > .panel-heading {
+  background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
+  background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6));
+  background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);
+}
+.panel-info > .panel-heading {
+  background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
+  background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3));
+  background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);
+}
+.panel-warning > .panel-heading {
+  background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
+  background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc));
+  background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);
+}
+.panel-danger > .panel-heading {
+  background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
+  background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc));
+  background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);
+}
+.well {
+  background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
+  background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5));
+  background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);
+  background-repeat: repeat-x;
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);
+  border-color: #dcdcdc;
+  -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);
+  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);
+}

File diff suppressed because it is too large
+ 1 - 0
sources/wifiwithme/static/bootstrap/css/bootstrap-theme.css.map


File diff suppressed because it is too large
+ 10 - 0
sources/wifiwithme/static/bootstrap/css/bootstrap-theme.min.css


File diff suppressed because it is too large
+ 6330 - 0
sources/wifiwithme/static/bootstrap/css/bootstrap.css


File diff suppressed because it is too large
+ 1 - 0
sources/wifiwithme/static/bootstrap/css/bootstrap.css.map


File diff suppressed because it is too large
+ 10 - 0
sources/wifiwithme/static/bootstrap/css/bootstrap.min.css


BIN
sources/wifiwithme/static/bootstrap/fonts/glyphicons-halflings-regular.eot


File diff suppressed because it is too large
+ 229 - 0
sources/wifiwithme/static/bootstrap/fonts/glyphicons-halflings-regular.svg


BIN
sources/wifiwithme/static/bootstrap/fonts/glyphicons-halflings-regular.ttf


BIN
sources/wifiwithme/static/bootstrap/fonts/glyphicons-halflings-regular.woff


File diff suppressed because it is too large
+ 2276 - 0
sources/wifiwithme/static/bootstrap/js/bootstrap.js


File diff suppressed because it is too large
+ 7 - 0
sources/wifiwithme/static/bootstrap/js/bootstrap.min.js


+ 13 - 0
sources/wifiwithme/static/bootstrap/js/npm.js

@@ -0,0 +1,13 @@
+// This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment.
+require('../../js/transition.js')
+require('../../js/alert.js')
+require('../../js/button.js')
+require('../../js/carousel.js')
+require('../../js/collapse.js')
+require('../../js/dropdown.js')
+require('../../js/modal.js')
+require('../../js/tooltip.js')
+require('../../js/popover.js')
+require('../../js/scrollspy.js')
+require('../../js/tab.js')
+require('../../js/affix.js')

+ 125 - 0
sources/wifiwithme/static/form.js

@@ -0,0 +1,125 @@
+$( document ).ready(function() {
+
+    // Defaults
+    defaults = {
+        lat: ($('#id_latitude').val()) ? $('#id_latitude').val() : parseFloat($('#map').attr("start_lat")),
+        lng: ($('#id_longitude').val()) ? $('#id_longitude').val() : parseFloat($('#map').attr("start_lon")),
+        zoom: $('#map').attr("start_zoom"),
+    }
+
+    // Create map
+    var map = L.map('map', {scrollWheelZoom: false}).setView([defaults.lat,defaults.lng], defaults.zoom);
+    L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+        attribution: 'Map data &copy; <a href="//openstreetmap.org">OpenStreetMap</a> contributors, <a href="//creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="//mapbox.com">Mapbox</a>',
+        maxZoom: 18
+    }).addTo(map);
+
+    // Add scale control
+    L.control.scale({
+        position: 'bottomleft',
+        metric: true,
+        imperial: false,
+        maxWidth: 150
+    }).addTo(map);
+
+    var marker = L.marker([defaults.lat, defaults.lng], {
+        draggable: true
+    }).addTo(map);
+
+    // every time the marker is dragged, update the coordinates container
+    marker.on('dragend', mapUpdateCoords);
+
+    map.on('click', (e) => {
+        marker.setLatLng(e.latlng)
+        mapUpdateCoords()
+    });
+
+    function mapUpdateCoords() {
+        var m = marker.getLatLng();
+        $('#id_latitude').val(m.lat);
+        $('#id_longitude').val(m.lng);
+    }
+
+    // Display tiny circles on existing public points
+    var GeoJsonPath = $('#map').data('json');
+    $.getJSON(GeoJsonPath, function(data){
+        var featureLayer = L.geoJson(data, {
+            pointToLayer: function (feature, latlng) {
+                return L.circleMarker(latlng, {color: '#00B300'});
+            }
+        }).addTo(map);
+    });
+
+    // Search sub form
+    $('#search-btn').click(function(e){
+        e.preventDefault();
+        var btn = $(this).button('loading');
+
+        // Geocoding
+        var searchString = $('#search').val();
+        $.getJSON('https://nominatim.openstreetmap.org/search?limit=5&format=json&q='+searchString, function(data){
+
+            var items = [];
+            $.each(data, function(key, val) {
+                items.push(
+                    "<li class='list-group-item'><a href='#' data-lat='"+val.lat+"' data-lng='"+val.lon+"'>" + val.display_name + '</a></li>'
+                );
+            });
+
+            $('#modal .modal-body').empty();
+            if (items.length != 0) {
+                $('<ul/>').addClass("list-group").html(items.join('')).appendTo('#modal .modal-body');
+            } else {
+                $('<p/>', { html: "Aucun résultat" }).appendTo('#modal .modal-body');
+            }
+            $('#modal').modal('show');
+
+            // Bind click on results and update coordinates
+            $('#modal .modal-body a').on('click', function(e){
+                e.preventDefault();
+
+                marker.setLatLng({lat:$(this).data('lat'), lng:$(this).data('lng')}).update();
+                map.panTo({lat:$(this).data('lat'), lng:$(this).data('lng')});
+                map.setZoom(16);
+                mapUpdateCoords();
+                $('#modal').modal('hide');
+            });
+
+            btn.button('reset');
+        }); // getJSON
+
+    }); // Search sub form
+
+    // Enter key on search form does not submit form,
+    // Trigger search button instead.
+    $('#search').keypress(function(e) {
+        if (e.which == '13') {
+            e.preventDefault();
+            $('#search-btn').trigger('click');
+        }
+    });
+
+
+    // Contrib share dynamic form
+    if ($('[name="contrib_type"]:checked').val() == 'share') { $('#contrib-type-share').show(); }
+    else { $('#contrib-type-share').hide(); }
+    // On change
+    $('[name="contrib_type"]').change(function(e){
+        $('#contrib-type-share').slideUp();
+        if ($(this).val() == 'share') { $('#contrib-type-share').slideDown(); }
+    });
+
+    // select/deselect all checkbox
+    $('#orientation-all').change(function(e){
+        $('input[name="orientations"]').prop('checked', $(e.target).is(':checked') );
+    });
+    $('.orientations').change(function(e){
+        if (! $(e.target).is(':checked')) {
+            $('input[name="orientation-all"]').prop('checked', false);
+        }
+        if ($('.orientations').filter(':not(:checked)').length == 0) {
+            $('input[name="orientation-all"]').prop('checked', true);
+        }
+    });
+
+});

BIN
sources/wifiwithme/static/img/background_main.jpg


BIN
sources/wifiwithme/static/img/background_main_767.jpg


BIN
sources/wifiwithme/static/img/background_main_991.jpg


File diff suppressed because it is too large
+ 4 - 0
sources/wifiwithme/static/jquery/jquery-1.11.0.min.js


+ 20 - 0
sources/wifiwithme/static/leaflet-semicircle/LICENSE

@@ -0,0 +1,20 @@
+Copyright 2013, Jieter
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 113 - 0
sources/wifiwithme/static/leaflet-semicircle/semicircle.js

@@ -0,0 +1,113 @@
+/**
+ * Semicircle extension for L.Circle.
+ * Jan Pieter Waagmeester <jieter@jieter.nl>
+ */
+
+/*jshint browser:true, strict:false, globalstrict:false, indent:4, white:true, smarttabs:true*/
+/*global L:true*/
+
+(function (L) {
+
+	// save original getPathString function to draw a full circle.
+	var original_getPathString = L.Circle.prototype.getPathString;
+
+	L.Circle = L.Circle.extend({
+		options: {
+			startAngle: 0,
+			stopAngle: 359.9999
+		},
+
+		// make sure 0 degrees is up (North) and convert to radians.
+		_fixAngle: function (angle) {
+			return (angle - 90) * L.LatLng.DEG_TO_RAD;
+		},
+		startAngle: function () {
+			return this._fixAngle(this.options.startAngle);
+		},
+		stopAngle: function () {
+			return this._fixAngle(this.options.stopAngle);
+		},
+
+		//rotate point x,y+r around x,y with angle.
+		rotated: function (angle, r) {
+			return this._point.add(
+				L.point(Math.cos(angle), Math.sin(angle)).multiplyBy(r)
+			).round();
+		},
+
+		getPathString: function () {
+			var center = this._point,
+			    r = this._radius;
+
+			// If we want a circle, we use the original function
+			if (this.options.startAngle === 0 && this.options.stopAngle > 359) {
+				return original_getPathString.call(this);
+			}
+
+			var start = this.rotated(this.startAngle(), r),
+				end = this.rotated(this.stopAngle(), r);
+
+			if (this._checkIfEmpty()) {
+				return '';
+			}
+
+			if (L.Browser.svg) {
+				var largeArc = (this.options.stopAngle - this.options.startAngle >= 180) ? '1' : '0';
+				//move to center
+				var ret = "M" + center.x + "," + center.y;
+				//lineTo point on circle startangle from center
+				ret += "L " + start.x + "," + start.y;
+				//make circle from point start - end:
+				ret += "A " + r + "," + r + ",0," + largeArc + ",1," + end.x + "," + end.y + " z";
+
+				return ret;
+			} else {
+				//TODO: fix this for semicircle...
+				center._round();
+				r = Math.round(r);
+				return "A " + center.x + "," + center.y + " " + r + "," + r + " 0," + (65535 * 360);
+			}
+		},
+		setStartAngle: function (angle) {
+			this.options.startAngle = angle;
+			return this.redraw();
+		},
+		setStopAngle: function (angle) {
+			this.options.stopAngle = angle;
+			return this.redraw();
+		},
+		setDirection: function (direction, degrees) {
+			if (degrees === undefined) {
+				degrees = 10;
+			}
+			this.options.startAngle = direction - (degrees / 2);
+			this.options.stopAngle = direction + (degrees / 2);
+
+			return this.redraw();
+		}
+	});
+	L.Circle.include(!L.Path.CANVAS ? {} : {
+		_drawPath: function () {
+			var center = this._point,
+			    r = this._radius;
+
+			var start = this.rotated(this.startAngle(), r);
+
+			this._ctx.beginPath();
+			this._ctx.moveTo(center.x, center.y);
+			this._ctx.lineTo(start.x, start.y);
+
+			this._ctx.arc(center.x, center.y, this._radius,
+				this.startAngle(), this.stopAngle(), false);
+			this._ctx.lineTo(center.x, center.y);
+		}
+
+		// _containsPoint: function (p) {
+		// TODO: fix for semicircle.
+		// var center = this._point,
+		//     w2 = this.options.stroke ? this.options.weight / 2 : 0;
+
+		//  return (p.distanceTo(center) <= this._radius + w2);
+		// }
+	});
+})(L);

BIN
sources/wifiwithme/static/leaflet/images/marker-icon-2x.png


BIN
sources/wifiwithme/static/leaflet/images/marker-icon-green.png


BIN
sources/wifiwithme/static/leaflet/images/marker-icon-red.png


BIN
sources/wifiwithme/static/leaflet/images/marker-icon-yellow.png


BIN
sources/wifiwithme/static/leaflet/images/marker-icon.png


BIN
sources/wifiwithme/static/leaflet/images/marker-shadow.png


+ 478 - 0
sources/wifiwithme/static/leaflet/leaflet.css

@@ -0,0 +1,478 @@
+/* required styles */
+
+.leaflet-map-pane,
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-tile-pane,
+.leaflet-tile-container,
+.leaflet-overlay-pane,
+.leaflet-shadow-pane,
+.leaflet-marker-pane,
+.leaflet-popup-pane,
+.leaflet-overlay-pane svg,
+.leaflet-zoom-box,
+.leaflet-image-layer,
+.leaflet-layer {
+	position: absolute;
+	left: 0;
+	top: 0;
+	}
+.leaflet-container {
+	overflow: hidden;
+	-ms-touch-action: none;
+	}
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+	-webkit-user-select: none;
+	   -moz-user-select: none;
+	        user-select: none;
+	-webkit-user-drag: none;
+	}
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+	display: block;
+	}
+/* map is broken in FF if you have max-width: 100% on tiles */
+.leaflet-container img {
+	max-width: none !important;
+	}
+/* stupid Android 2 doesn't understand "max-width: none" properly */
+.leaflet-container img.leaflet-image-layer {
+	max-width: 15000px !important;
+	}
+.leaflet-tile {
+	filter: inherit;
+	visibility: hidden;
+	}
+.leaflet-tile-loaded {
+	visibility: inherit;
+	}
+.leaflet-zoom-box {
+	width: 0;
+	height: 0;
+	}
+/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
+.leaflet-overlay-pane svg {
+	-moz-user-select: none;
+	}
+
+.leaflet-tile-pane    { z-index: 2; }
+.leaflet-objects-pane { z-index: 3; }
+.leaflet-overlay-pane { z-index: 4; }
+.leaflet-shadow-pane  { z-index: 5; }
+.leaflet-marker-pane  { z-index: 6; }
+.leaflet-popup-pane   { z-index: 7; }
+
+.leaflet-vml-shape {
+	width: 1px;
+	height: 1px;
+	}
+.lvml {
+	behavior: url(#default#VML);
+	display: inline-block;
+	position: absolute;
+	}
+
+
+/* control positioning */
+
+.leaflet-control {
+	position: relative;
+	z-index: 7;
+	pointer-events: auto;
+	}
+.leaflet-top,
+.leaflet-bottom {
+	position: absolute;
+	z-index: 1000;
+	pointer-events: none;
+	}
+.leaflet-top {
+	top: 0;
+	}
+.leaflet-right {
+	right: 0;
+	}
+.leaflet-bottom {
+	bottom: 0;
+	}
+.leaflet-left {
+	left: 0;
+	}
+.leaflet-control {
+	float: left;
+	clear: both;
+	}
+.leaflet-right .leaflet-control {
+	float: right;
+	}
+.leaflet-top .leaflet-control {
+	margin-top: 10px;
+	}
+.leaflet-bottom .leaflet-control {
+	margin-bottom: 10px;
+	}
+.leaflet-left .leaflet-control {
+	margin-left: 10px;
+	}
+.leaflet-right .leaflet-control {
+	margin-right: 10px;
+	}
+
+
+/* zoom and fade animations */
+
+.leaflet-fade-anim .leaflet-tile,
+.leaflet-fade-anim .leaflet-popup {
+	opacity: 0;
+	-webkit-transition: opacity 0.2s linear;
+	   -moz-transition: opacity 0.2s linear;
+	     -o-transition: opacity 0.2s linear;
+	        transition: opacity 0.2s linear;
+	}
+.leaflet-fade-anim .leaflet-tile-loaded,
+.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
+	opacity: 1;
+	}
+
+.leaflet-zoom-anim .leaflet-zoom-animated {
+	-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
+	   -moz-transition:    -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
+	     -o-transition:      -o-transform 0.25s cubic-bezier(0,0,0.25,1);
+	        transition:         transform 0.25s cubic-bezier(0,0,0.25,1);
+	}
+.leaflet-zoom-anim .leaflet-tile,
+.leaflet-pan-anim .leaflet-tile,
+.leaflet-touching .leaflet-zoom-animated {
+	-webkit-transition: none;
+	   -moz-transition: none;
+	     -o-transition: none;
+	        transition: none;
+	}
+
+.leaflet-zoom-anim .leaflet-zoom-hide {
+	visibility: hidden;
+	}
+
+
+/* cursors */
+
+.leaflet-clickable {
+	cursor: pointer;
+	}
+.leaflet-container {
+	cursor: -webkit-grab;
+	cursor:    -moz-grab;
+	}
+.leaflet-popup-pane,
+.leaflet-control {
+	cursor: auto;
+	}
+.leaflet-dragging .leaflet-container,
+.leaflet-dragging .leaflet-clickable {
+	cursor: move;
+	cursor: -webkit-grabbing;
+	cursor:    -moz-grabbing;
+	}
+
+
+/* visual tweaks */
+
+.leaflet-container {
+	background: #ddd;
+	outline: 0;
+	}
+.leaflet-container a {
+	color: #0078A8;
+	}
+.leaflet-container a.leaflet-active {
+	outline: 2px solid orange;
+	}
+.leaflet-zoom-box {
+	border: 2px dotted #38f;
+	background: rgba(255,255,255,0.5);
+	}
+
+
+/* general typography */
+.leaflet-container {
+	font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
+	}
+
+
+/* general toolbar styles */
+
+.leaflet-bar {
+	box-shadow: 0 1px 5px rgba(0,0,0,0.65);
+	border-radius: 4px;
+	}
+.leaflet-bar a,
+.leaflet-bar a:hover {
+	background-color: #fff;
+	border-bottom: 1px solid #ccc;
+	width: 26px;
+	height: 26px;
+	line-height: 26px;
+	display: block;
+	text-align: center;
+	text-decoration: none;
+	color: black;
+	}
+.leaflet-bar a,
+.leaflet-control-layers-toggle {
+	background-position: 50% 50%;
+	background-repeat: no-repeat;
+	display: block;
+	}
+.leaflet-bar a:hover {
+	background-color: #f4f4f4;
+	}
+.leaflet-bar a:first-child {
+	border-top-left-radius: 4px;
+	border-top-right-radius: 4px;
+	}
+.leaflet-bar a:last-child {
+	border-bottom-left-radius: 4px;
+	border-bottom-right-radius: 4px;
+	border-bottom: none;
+	}
+.leaflet-bar a.leaflet-disabled {
+	cursor: default;
+	background-color: #f4f4f4;
+	color: #bbb;
+	}
+
+.leaflet-touch .leaflet-bar a {
+	width: 30px;
+	height: 30px;
+	line-height: 30px;
+	}
+
+
+/* zoom control */
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+	font: bold 18px 'Lucida Console', Monaco, monospace;
+	text-indent: 1px;
+	}
+.leaflet-control-zoom-out {
+	font-size: 20px;
+	}
+
+.leaflet-touch .leaflet-control-zoom-in {
+	font-size: 22px;
+	}
+.leaflet-touch .leaflet-control-zoom-out {
+	font-size: 24px;
+	}
+
+
+/* layers control */
+
+.leaflet-control-layers {
+	box-shadow: 0 1px 5px rgba(0,0,0,0.4);
+	background: #fff;
+	border-radius: 5px;
+	}
+.leaflet-control-layers-toggle {
+	background-image: url(images/layers.png);
+	width: 36px;
+	height: 36px;
+	}
+.leaflet-retina .leaflet-control-layers-toggle {
+	background-image: url(images/layers-2x.png);
+	background-size: 26px 26px;
+	}
+.leaflet-touch .leaflet-control-layers-toggle {
+	width: 44px;
+	height: 44px;
+	}
+.leaflet-control-layers .leaflet-control-layers-list,
+.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
+	display: none;
+	}
+.leaflet-control-layers-expanded .leaflet-control-layers-list {
+	display: block;
+	position: relative;
+	}
+.leaflet-control-layers-expanded {
+	padding: 6px 10px 6px 6px;
+	color: #333;
+	background: #fff;
+	}
+.leaflet-control-layers-selector {
+	margin-top: 2px;
+	position: relative;
+	top: 1px;
+	}
+.leaflet-control-layers label {
+	display: block;
+	}
+.leaflet-control-layers-separator {
+	height: 0;
+	border-top: 1px solid #ddd;
+	margin: 5px -10px 5px -6px;
+	}
+
+
+/* attribution and scale controls */
+
+.leaflet-container .leaflet-control-attribution {
+	background: #fff;
+	background: rgba(255, 255, 255, 0.7);
+	margin: 0;
+	}
+.leaflet-control-attribution,
+.leaflet-control-scale-line {
+	padding: 0 5px;
+	color: #333;
+	}
+.leaflet-control-attribution a {
+	text-decoration: none;
+	}
+.leaflet-control-attribution a:hover {
+	text-decoration: underline;
+	}
+.leaflet-container .leaflet-control-attribution,
+.leaflet-container .leaflet-control-scale {
+	font-size: 11px;
+	}
+.leaflet-left .leaflet-control-scale {
+	margin-left: 5px;
+	}
+.leaflet-bottom .leaflet-control-scale {
+	margin-bottom: 5px;
+	}
+.leaflet-control-scale-line {
+	border: 2px solid #777;
+	border-top: none;
+	line-height: 1.1;
+	padding: 2px 5px 1px;
+	font-size: 11px;
+	white-space: nowrap;
+	overflow: hidden;
+	-moz-box-sizing: content-box;
+	     box-sizing: content-box;
+
+	background: #fff;
+	background: rgba(255, 255, 255, 0.5);
+	}
+.leaflet-control-scale-line:not(:first-child) {
+	border-top: 2px solid #777;
+	border-bottom: none;
+	margin-top: -2px;
+	}
+.leaflet-control-scale-line:not(:first-child):not(:last-child) {
+	border-bottom: 2px solid #777;
+	}
+
+.leaflet-touch .leaflet-control-attribution,
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+	box-shadow: none;
+	}
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+	border: 2px solid rgba(0,0,0,0.2);
+	background-clip: padding-box;
+	}
+
+
+/* popup */
+
+.leaflet-popup {
+	position: absolute;
+	text-align: center;
+	}
+.leaflet-popup-content-wrapper {
+	padding: 1px;
+	text-align: left;
+	border-radius: 12px;
+	}
+.leaflet-popup-content {
+	margin: 13px 19px;
+	line-height: 1.4;
+	}
+.leaflet-popup-content p {
+	margin: 18px 0;
+	}
+.leaflet-popup-tip-container {
+	margin: 0 auto;
+	width: 40px;
+	height: 20px;
+	position: relative;
+	overflow: hidden;
+	}
+.leaflet-popup-tip {
+	width: 17px;
+	height: 17px;
+	padding: 1px;
+
+	margin: -10px auto 0;
+
+	-webkit-transform: rotate(45deg);
+	   -moz-transform: rotate(45deg);
+	    -ms-transform: rotate(45deg);
+	     -o-transform: rotate(45deg);
+	        transform: rotate(45deg);
+	}
+.leaflet-popup-content-wrapper,
+.leaflet-popup-tip {
+	background: white;
+
+	box-shadow: 0 3px 14px rgba(0,0,0,0.4);
+	}
+.leaflet-container a.leaflet-popup-close-button {
+	position: absolute;
+	top: 0;
+	right: 0;
+	padding: 4px 4px 0 0;
+	text-align: center;
+	width: 18px;
+	height: 14px;
+	font: 16px/14px Tahoma, Verdana, sans-serif;
+	color: #c3c3c3;
+	text-decoration: none;
+	font-weight: bold;
+	background: transparent;
+	}
+.leaflet-container a.leaflet-popup-close-button:hover {
+	color: #999;
+	}
+.leaflet-popup-scrolled {
+	overflow: auto;
+	border-bottom: 1px solid #ddd;
+	border-top: 1px solid #ddd;
+	}
+
+.leaflet-oldie .leaflet-popup-content-wrapper {
+	zoom: 1;
+	}
+.leaflet-oldie .leaflet-popup-tip {
+	width: 24px;
+	margin: 0 auto;
+
+	-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
+	filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
+	}
+.leaflet-oldie .leaflet-popup-tip-container {
+	margin-top: -1px;
+	}
+
+.leaflet-oldie .leaflet-control-zoom,
+.leaflet-oldie .leaflet-control-layers,
+.leaflet-oldie .leaflet-popup-content-wrapper,
+.leaflet-oldie .leaflet-popup-tip {
+	border: 1px solid #999;
+	}
+
+
+/* div icon */
+
+.leaflet-div-icon {
+	background: #fff;
+	border: 1px solid #666;
+	}

File diff suppressed because it is too large
+ 9 - 0
sources/wifiwithme/static/leaflet/leaflet.js


+ 171 - 0
sources/wifiwithme/static/main.css

@@ -0,0 +1,171 @@
+
+.main-header h1 a,
+.main-header h1 a:visited,
+.main-header h1 a:link {
+    color: #333;
+}
+
+.back-link {
+    display: inline-block;
+    margin-bottom: 30px;
+}
+html {
+  min-height: 100%;
+}
+
+body {
+  background-image: url("img/background_main_489.jpg");
+  background-repeat: no-repeat;
+  background-position: 50% 55px;
+  background-size: 100%;
+  position: relative;
+  min-height: 1500px;
+  padding-bottom: 200px;
+}
+
+
+body > .jumbotron {
+  background-color: transparent;
+  padding: 3.5em 0;
+}
+
+body > section {
+  background-color: #eee;
+  background-color: rgba(250,250,250,0.7);
+  padding-bottom: 2em;
+}
+
+body.form > section {background-color: transparent;}
+
+body.form section header {
+  margin-top: 365px;
+  margin-bottom: 70px;
+  background-color: rgba(250,250,250,0.5);
+}
+
+body > footer {
+    /* See jumbotron */
+    margin-top: 30px;
+    padding: 48px 0;
+    background-color: #eee;
+    text-align: center;
+
+    position: absolute;
+    bottom: 0;left: 0;right: 0;top: auto;
+}
+
+/** Form **/
+#map {
+    min-height: 300px;
+}
+
+#search-results {
+    margin-top: 1em;
+}
+
+form > h2 {
+    border-top: 1px solid #eee;
+    padding-top: 1em;
+    margin-top: 1em;
+}
+form > h2:first-child {
+    border-top: none;
+    padding-top: 0;
+    margin-top: 0;
+}
+
+
+/** Form errors **/
+#errors, .errorlist {
+    padding: 1em;
+    border: 1px solid #a94442;
+    border-radius: 4px;
+}
+
+/* non-field errorfields */
+#errors ul.errorlist {
+  border: 0;
+}
+
+#errors ul {
+    margin: 0 0 0 1em;
+    padding: 0;
+}
+
+ .errorlist {
+  list-style-type: none;
+ }
+
+/** Results **/
+#map.results {
+    min-height: 500px;
+    margin-bottom: 1em;
+}
+
+.results .leaflet-popup-content {
+    margin: 1em;
+}
+.results .leaflet-popup-content h2 {
+    font-size: 20px;
+}
+.results .leaflet-popup-content ul {
+    margin: 0.5em 0;
+    padding-left: 1.5em;
+}
+
+
+/** Media-queries **
+*
+* Focused on readability (where background and text overlap) and avoiding
+* big vertical empty spaces.
+*/
+@media (min-width: 0px) {
+    h1 {
+        max-width: 500px;
+        background-color: rgba(250, 250, 250, 0.5);
+    }
+    body.form section header {
+        margin-top: 0px
+    }
+    body {
+        background-image: url("img/background_main_767.jpg");
+    }
+}
+
+@media (min-width: 480px) {
+    h1 {
+        max-width: 400px
+    }
+}
+
+@media (min-width: 768px) {
+    h1 {
+        max-width: 500px
+    }
+    body {
+        background-image: url("img/background_main_991.jpg");
+    }
+}
+
+@media (min-width: 992px) {
+    h1 {
+        max-width: 650px;
+    }
+    body.form section header {
+        margin-top: 265px
+    }
+    body {
+        background-image: url("img/background_main.jpg");
+    }
+}
+
+@media (min-width: 1200px) {
+    h1 {
+        max-width: none;
+        background-color: transparent;
+
+    }
+    body.form section header {
+        margin-top: 365px
+    }
+}

+ 139 - 0
sources/wifiwithme/static/map.js

@@ -0,0 +1,139 @@
+$( document ).ready(function() {
+
+    // Defaults
+    defaults = {
+        lat: parseFloat($('#map').attr("start_lat")),
+        lng: parseFloat($('#map').attr("start_lon")),
+        zoom: $('#map').attr("start_zoom"),
+    }
+
+    // Icons
+    var leecherIcon = L.icon({
+    iconUrl: '../assets/leaflet/images/marker-icon.png',
+        iconSize: [25, 41],
+        iconAnchor: [12, 41],
+        popupAnchor: [0, -28]
+    });
+
+
+    var seederIcon = L.icon({
+    iconUrl: '../assets/leaflet/images/marker-icon-red.png',
+        iconSize: [25, 41],
+        iconAnchor: [12, 41],
+        popupAnchor: [0, -28]
+    });
+
+
+    // Create map
+    var map = L.map('map', {scrollWheelZoom: false}).setView([defaults.lat,defaults.lng], defaults.zoom);
+    L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+        attribution: 'Map data &copy; <a href="//openstreetmap.org">OpenStreetMap</a> contributors, <a href="//creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="//mapbox.com">Mapbox</a>',
+        maxZoom: 18
+    }).addTo(map);
+
+    // Add scale control
+    L.control.scale({
+        position: 'bottomleft',
+        metric: true,
+        imperial: false,
+        maxWidth: 200
+    }).addTo(map);
+
+    // Get JSON
+    var GeoJsonPath = $('#map').data('json')
+    $.getJSON(GeoJsonPath, function(data){
+
+        function buildPopupContent(feature, layer) {
+            feature.properties.popupContent = '';
+
+            var featureIdLink = '<a href="#' + feature.id + '">#' + feature.id + '</a>';
+            if (feature.properties.name) {
+                feature.properties.popupContent += '<h2>'+featureIdLink+': '+feature.properties.name+'</h2>';
+            }
+            else {
+                feature.properties.popupContent += '<h2>'+ featureIdLink +'</h2>';
+            }
+
+            if (feature.properties.place) {
+                feature.properties.popupContent += '<ul>';
+                if (feature.properties.place.hasOwnProperty('floor')) feature.properties.popupContent += '<li>Étage: '+feature.properties.place.floor+'</li>';
+                if (feature.properties.place.orientations[0]) feature.properties.popupContent += '<li>Orientation: '+feature.properties.place.orientations.join(', ')+'</li>';
+                if (feature.properties.place.roof) feature.properties.popupContent += '<li>Accès au toit'+'</li>';
+                feature.properties.popupContent += '</ul>';
+            }
+
+            if (feature.properties.comment) {
+                feature.properties.popupContent += '<p>'+feature.properties.comment+'</p>';
+            }
+
+            layer.bindPopup(feature.properties.popupContent);
+            layer.id = feature.id;
+        }
+
+        function drawSemiCircles(feature, layer) {
+            if (feature.properties.place) {
+                feature.properties.place.angles.map(function(angles) {
+                    // Strangely enough, we need to invert the coordinates.
+                    L.circle([feature.geometry.coordinates[1],
+                              feature.geometry.coordinates[0]], 150, {
+                                  startAngle: angles[0],
+                                  stopAngle: angles[1]
+                              }).addTo(map);
+                });
+            }
+        }
+
+        // Add to map
+        var featureLayer = L.geoJson(data, {
+            onEachFeature: function(feature, layer) {
+                buildPopupContent(feature, layer);
+                drawSemiCircles(feature, layer);
+            },
+            pointToLayer: function(feature, latlng) {
+                var icon;
+                if (feature.properties.contrib_type == 'connect') {
+                    icon = leecherIcon;
+                } else {
+                    icon = seederIcon;
+                }
+                return L.marker(latlng, {icon: icon});
+            }
+        }).addTo(map);
+
+
+        function openMarker(id) {
+            for (var i in featureLayer._layers) {
+                if (featureLayer._layers[i].id == id) {
+                    // Get desired marker
+                    var thisMarker = featureLayer._layers[i];
+                    // Center map on marker and zoom
+                    map.panTo(thisMarker.getLatLng());
+                    map.setZoom(16);
+                    //  Open popup
+                    thisMarker.openPopup();
+                }
+            }
+        }
+
+        // Open popup if hash is present.
+        if (window.location.hash) {
+            var id = window.location.hash.substr(-1);
+            openMarker(id);
+        }
+        else {
+            // Auto Zoom
+            // Strange leaflet bug, we need to set a null timeout
+            setTimeout(function () {
+                map.fitBounds(featureLayer.getBounds())
+            }, 2);
+        }
+
+        // Bind window hash change
+        window.onhashchange = function() {
+            var id = window.location.hash.substr(-1);
+            openMarker(id);
+        }
+
+    });
+
+});

+ 16 - 0
sources/wifiwithme/wsgi.py

@@ -0,0 +1,16 @@
+"""
+WSGI config for wifiwithme project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "wifiwithme.settings")
+
+application = get_wsgi_application()