├── .bowerrc ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .jshintrc ├── CONTRIBUTING.rst ├── Gruntfile.js ├── LICENSE.txt ├── Procfile ├── README.rst ├── Vagrantfile ├── app ├── .buildignore ├── .htaccess ├── 404.html ├── data │ ├── population_novillages.json │ ├── tz_districts.topojson │ ├── tz_regions.topojson │ └── tz_wards.topojson ├── favicon.ico ├── images │ ├── Tanzania.png │ └── spinner.gif ├── index.html ├── po │ └── sw_TZ.po ├── robots.txt ├── scripts │ ├── app.coffee │ ├── controllers.coffee │ ├── mapctrl.coffee │ ├── natdashboardctrl.coffee │ ├── plots.js │ ├── regdashboardctrl.coffee │ └── services.coffee ├── styles │ ├── main.css │ └── prunecluster.css ├── vendor │ └── dynamic-forms.js └── views │ ├── dashboard.html │ ├── edit.html │ ├── main.html │ ├── requests.html │ ├── spinnerdlg.html │ └── triage.html ├── bootstrap.sh ├── bower.json ├── install.sh ├── manage.py ├── package-lock.json ├── package.json ├── requirements.txt ├── requirements ├── base.txt ├── deploy.txt └── dev.txt ├── runtime.txt ├── scripts ├── mongoshell.py └── openrefine.json ├── setup.py ├── startserver ├── taarifa_waterpoints ├── __init__.py ├── schemas.py └── taarifa_waterpoints.py └── tox.ini /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "analytics": false, 3 | "directory": "app/bower_components" 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behaviour, in case users don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Whitespace 5 | * whitespace=tab-in-indent,space-before-tab,trailing-space,tabwidth=2 6 | *.{py,pyx,pxd,pxi} whitespace=tab-in-indent,space-before-tab,trailing-space,tabwidth=4 7 | Makefile whitespace=space-before-tab,trailing-space,tabwidth=2 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | *.out 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | *.pot 33 | app/scripts/translations.js 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | 40 | # Angular 41 | node_modules 42 | dist 43 | .tmp 44 | .sass-cache 45 | app/bower_components 46 | 47 | # Vagrant 48 | .vagrant 49 | 50 | # Virtualenv 51 | .venv 52 | 53 | # Heroku 54 | .env 55 | 56 | # VSCode 57 | .vscode 58 | 59 | # Temporary, backup & MacOS dir 60 | *~ 61 | *.lock 62 | *.DS_Store 63 | *.swp 64 | *.swo 65 | .ropeproject 66 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "browser": true, 4 | "esnext": true, 5 | "bitwise": true, 6 | "camelcase": true, 7 | "curly": true, 8 | "eqeqeq": true, 9 | "immed": true, 10 | "indent": 2, 11 | "latedef": true, 12 | "newcap": true, 13 | "noarg": true, 14 | "quotmark": "single", 15 | "regexp": true, 16 | "undef": true, 17 | "unused": true, 18 | "strict": true, 19 | "trailing": true, 20 | "smarttabs": true, 21 | "globals": { 22 | "angular": false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to TaarifaWaterpoints 2 | ================================== 3 | 4 | We value third-party contributions. To keep things simple for you and 5 | us, please adhere to the following contributing guidelines. 6 | 7 | Getting Started 8 | --------------- 9 | 10 | - You will need a `GitHub account`_. 11 | - Submit a `ticket for your issue `_, assuming one does not 12 | already exist. 13 | - Clearly describe the issue including steps to reproduce when it is a 14 | bug. 15 | - Make sure you specify the version that you know has the issue. 16 | - Bonus points for submitting a failing test along with the ticket. 17 | - If you don't have push access, fork the repository on GitHub. 18 | 19 | Making Changes 20 | -------------- 21 | 22 | - Create a topic branch for your feature or bug fix. 23 | - Make commits of logical units. 24 | - Make sure your commits adhere to the coding guidelines below. 25 | - Make sure your commit messages are in the proper 26 | `Git commit message format`_: 27 | 28 | * The first line of the message should be a 50 characters or less 29 | summary of the change, start with a capital letter and not end with 30 | a period. It is separated by a blank line from the (optional) body. 31 | * The body should be wrapped at 70 characters and paragraphs separated 32 | by blank lines. Bulleted lists are also fine. 33 | * The commit message describes what the changeset introduces and is 34 | written in *present tense* and using the passive form. 35 | - Make sure you have added the necessary tests for your changes. 36 | - Run *all* the tests to assure nothing else was accidentally broken. 37 | 38 | Coding guidelines 39 | ----------------- 40 | 41 | `PEP 0008`_ is enforced, with the exception of `E501`_ and `E226`_: 42 | 43 | * Indent by 4 spaces, tabs are *strictly forbidden*. 44 | * Lines should not exceed 79 characters where possible without severely 45 | impacting legibility. If breaking a line would make the code much 46 | less readable it's fine to overrun by a little bit. 47 | * No trailing whitespace at EOL or trailing blank lines at EOF. 48 | 49 | Checking your commit conforms to coding guidelines 50 | -------------------------------------------------- 51 | 52 | Install a Git pre-commit hook automatically checking for tab and 53 | whitespace errors before committing and also calls ``flake8`` on your 54 | changed files. In the ``.git/hooks`` directory of your local Git 55 | repository, run the following: :: 56 | 57 | git config --local core.whitespace "space-before-tab, tab-in-indent, trailing-space, tabwidth=4" 58 | wget https://gist.github.com/kynan/d233073b66e860c41484/raw/pre-commit 59 | chmod +x pre-commit 60 | 61 | Make sure the ``pre-commit.sample`` hook is still in place, since it is 62 | required. 63 | 64 | Submitting Changes 65 | ------------------ 66 | 67 | - We can only accept your contribution if you have signed the 68 | Contributor License Agreement (CLA). 69 | - Push your changes to a topic branch in your fork of the repository. 70 | - Submit a pull request to the repository in the Taarifa organization. 71 | 72 | .. _GitHub account: https://github.com/signup/free 73 | .. _issues: https://github.com/taarifa/TaarifaWaterpoint/issues 74 | .. _Git commit message format: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 75 | .. _PEP 0008: http://www.python.org/dev/peps/pep-0008/ 76 | .. _E501: http://pep8.readthedocs.org/en/latest/intro.html#error-codes 77 | .. _E226: http://pep8.readthedocs.org/en/latest/intro.html#error-codes 78 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | // Generated on 2014-05-05 using generator-angular 0.8.0 2 | 'use strict'; 3 | 4 | // # Globbing 5 | // for performance reasons we're only matching one level down: 6 | // 'test/spec/{,*/}*.js' 7 | // use this if you want to recursively match all subfolders: 8 | // 'test/spec/**/*.js' 9 | 10 | module.exports = function (grunt) { 11 | 12 | // Load grunt tasks automatically 13 | require('load-grunt-tasks')(grunt); 14 | 15 | // Time how long tasks take. Can help when optimizing build times 16 | require('time-grunt')(grunt); 17 | 18 | // Define the configuration for all the tasks 19 | grunt.initConfig({ 20 | 21 | // Project settings 22 | yeoman: { 23 | // configurable paths 24 | app: 'app', 25 | dist: 'dist' 26 | }, 27 | 28 | // Watches files for changes and runs tasks based on the changed files 29 | watch: { 30 | coffee: { 31 | files: ['<%= yeoman.app %>/scripts/{,*/}*.{coffee,litcoffee,coffee.md}'], 32 | tasks: ['newer:coffee:dist'] 33 | }, 34 | coffeeTest: { 35 | files: ['test/spec/{,*/}*.{coffee,litcoffee,coffee.md}'], 36 | tasks: ['newer:coffee:test', 'karma'] 37 | }, 38 | styles: { 39 | files: ['<%= yeoman.app %>/styles/{,*/}*.css'], 40 | tasks: ['newer:copy:styles', 'autoprefixer'] 41 | }, 42 | gruntfile: { 43 | files: ['Gruntfile.js'] 44 | }, 45 | livereload: { 46 | options: { 47 | livereload: '<%= connect.options.livereload %>' 48 | }, 49 | files: [ 50 | '<%= yeoman.app %>/{,*/}*.html', 51 | '<%= yeoman.app %>/vendor/{,*/}*.js', 52 | '.tmp/styles/{,*/}*.css', 53 | '.tmp/scripts/{,*/}*.js', 54 | '<%= yeoman.app %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}' 55 | ] 56 | } 57 | }, 58 | 59 | bower: { 60 | install: { 61 | options: { 62 | copy: false 63 | } 64 | } 65 | }, 66 | 67 | // The actual grunt server settings 68 | connect: { 69 | proxies: [ 70 | { 71 | context: ['/api', '/data'], 72 | host: grunt.option('host') || '0.0.0.0', 73 | port: grunt.option('proxy') || 5000, 74 | https: false, 75 | changeOrigin: false 76 | } 77 | ], 78 | options: { 79 | port: grunt.option('port') || 9000, 80 | hostname: grunt.option('host') || '0.0.0.0', 81 | livereload: grunt.option('livereload-port') || 35729, 82 | useAvailablePort: true 83 | }, 84 | livereload: { 85 | options: { 86 | hostname: grunt.option('host') || '0.0.0.0', 87 | open: grunt.option('open'), 88 | base: [ 89 | '.tmp', 90 | '<%= yeoman.app %>' 91 | ], 92 | middleware: function (connect) { 93 | return [ 94 | require('grunt-connect-proxy/lib/utils').proxyRequest, 95 | connect.static(require('path').resolve('.tmp')), 96 | connect.static(require('path').resolve('app')) 97 | ]; 98 | } 99 | } 100 | }, 101 | test: { 102 | options: { 103 | port: 9001, 104 | base: [ 105 | '.tmp', 106 | 'test', 107 | '<%= yeoman.app %>' 108 | ] 109 | } 110 | }, 111 | dist: { 112 | options: { 113 | base: '<%= yeoman.dist %>' 114 | } 115 | } 116 | }, 117 | 118 | // Make sure code styles are up to par and there are no obvious mistakes 119 | jshint: { 120 | options: { 121 | jshintrc: '.jshintrc', 122 | reporter: require('jshint-stylish') 123 | }, 124 | all: [ 125 | 'Gruntfile.js' 126 | ] 127 | }, 128 | 129 | // Empties folders to start fresh 130 | clean: { 131 | dist: { 132 | files: [{ 133 | dot: true, 134 | src: [ 135 | '.tmp', 136 | '<%= yeoman.dist %>/*', 137 | '!<%= yeoman.dist %>/.git*' 138 | ] 139 | }] 140 | }, 141 | server: '.tmp' 142 | }, 143 | 144 | // Add vendor prefixed styles 145 | autoprefixer: { 146 | options: { 147 | browsers: ['last 1 version'] 148 | }, 149 | dist: { 150 | files: [{ 151 | expand: true, 152 | cwd: '.tmp/styles/', 153 | src: '{,*/}*.css', 154 | dest: '.tmp/styles/' 155 | }] 156 | } 157 | }, 158 | 159 | // Compiles CoffeeScript to JavaScript 160 | coffee: { 161 | options: { 162 | sourceMap: true, 163 | sourceRoot: '' 164 | }, 165 | dist: { 166 | files: [{ 167 | expand: true, 168 | cwd: '<%= yeoman.app %>/scripts', 169 | src: '{,*/}*.coffee', 170 | dest: '.tmp/scripts', 171 | ext: '.js' 172 | }] 173 | }, 174 | test: { 175 | files: [{ 176 | expand: true, 177 | cwd: 'test/spec', 178 | src: '{,*/}*.coffee', 179 | dest: '.tmp/spec', 180 | ext: '.js' 181 | }] 182 | } 183 | }, 184 | 185 | // Renames files for browser caching purposes 186 | rev: { 187 | dist: { 188 | files: { 189 | src: [ 190 | '<%= yeoman.dist %>/scripts/{,*/}*.js', 191 | '<%= yeoman.dist %>/styles/{,*/}*.css', 192 | '<%= yeoman.dist %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}', 193 | '<%= yeoman.dist %>/styles/fonts/*' 194 | ] 195 | } 196 | } 197 | }, 198 | 199 | // Reads HTML for usemin blocks to enable smart builds that automatically 200 | // concat, minify and revision files. Creates configurations in memory so 201 | // additional tasks can operate on them 202 | useminPrepare: { 203 | html: '<%= yeoman.app %>/index.html', 204 | options: { 205 | dest: '<%= yeoman.dist %>', 206 | flow: { 207 | html: { 208 | steps: { 209 | js: ['concat'/*, 'uglifyjs'*/], 210 | css: ['cssmin'] 211 | }, 212 | post: {} 213 | } 214 | } 215 | } 216 | }, 217 | 218 | // Performs rewrites based on rev and the useminPrepare configuration 219 | usemin: { 220 | html: ['<%= yeoman.dist %>/{,*/}*.html'], 221 | css: ['<%= yeoman.dist %>/styles/{,*/}*.css'], 222 | options: { 223 | assetsDirs: ['<%= yeoman.dist %>'], 224 | patterns: { 225 | css: [ 226 | [/(images\/.*?\.(?:gif|jpeg|jpg|png|webp|svg))/gm, 'Update the CSS to reference our revved images'] 227 | ] 228 | } 229 | } 230 | }, 231 | 232 | // The following *-min tasks produce minified files in the dist folder 233 | cssmin: { 234 | options: { 235 | root: '<%= yeoman.app %>' 236 | } 237 | }, 238 | 239 | imagemin: { 240 | dist: { 241 | files: [{ 242 | expand: true, 243 | cwd: '<%= yeoman.app %>/images', 244 | src: '{,*/}*.{png,jpg,jpeg,gif}', 245 | dest: '<%= yeoman.dist %>/images' 246 | }] 247 | } 248 | }, 249 | 250 | svgmin: { 251 | dist: { 252 | files: [{ 253 | expand: true, 254 | cwd: '<%= yeoman.app %>/images', 255 | src: '{,*/}*.svg', 256 | dest: '<%= yeoman.dist %>/images' 257 | }] 258 | } 259 | }, 260 | 261 | htmlmin: { 262 | dist: { 263 | options: { 264 | collapseWhitespace: true, 265 | collapseBooleanAttributes: true, 266 | removeCommentsFromCDATA: true, 267 | removeOptionalTags: true 268 | }, 269 | files: [{ 270 | expand: true, 271 | cwd: '<%= yeoman.dist %>', 272 | src: ['*.html', 'views/{,*/}*.html'], 273 | dest: '<%= yeoman.dist %>' 274 | }] 275 | } 276 | }, 277 | 278 | // Replace Google CDN references 279 | cdnify: { 280 | dist: { 281 | html: ['<%= yeoman.dist %>/*.html'] 282 | } 283 | }, 284 | 285 | // Copies remaining files to places other tasks can use 286 | copy: { 287 | dist: { 288 | files: [{ 289 | expand: true, 290 | dot: true, 291 | cwd: '<%= yeoman.app %>', 292 | dest: '<%= yeoman.dist %>', 293 | src: [ 294 | '*.{ico,png,txt}', 295 | '.htaccess', 296 | '*.html', 297 | 'views/{,*/}*.html', 298 | 'images/{,*/}*.{png,jpg,jpeg,gif,webp}', 299 | 'fonts/*', 300 | 'data/{,*/}*.{csv,json,topojson}' 301 | ] 302 | }, { 303 | expand: true, 304 | cwd: '.tmp/images', 305 | dest: '<%= yeoman.dist %>/images', 306 | src: ['generated/*'] 307 | }] 308 | }, 309 | styles: { 310 | expand: true, 311 | cwd: '<%= yeoman.app %>/styles', 312 | dest: '.tmp/styles/', 313 | src: '{,*/}*.css' 314 | } 315 | }, 316 | 317 | // Run some tasks in parallel to speed up the build process 318 | concurrent: { 319 | server: [ 320 | 'coffee:dist', 321 | 'copy:styles' 322 | ], 323 | test: [ 324 | 'coffee', 325 | 'copy:styles' 326 | ], 327 | dist: [ 328 | 'coffee', 329 | 'copy:styles', 330 | 'svgmin' 331 | ] 332 | }, 333 | 334 | // By default, your `index.html`'s will take care of 335 | // minification. These next options are pre-configured if you do not wish 336 | // to use the Usemin blocks. 337 | // cssmin: { 338 | // dist: { 339 | // files: { 340 | // '<%= yeoman.dist %>/styles/main.css': [ 341 | // '.tmp/styles/{,*/}*.css', 342 | // '<%= yeoman.app %>/styles/{,*/}*.css' 343 | // ] 344 | // } 345 | // } 346 | // }, 347 | // uglify: { 348 | // dist: { 349 | // files: { 350 | // '<%= yeoman.dist %>/scripts/scripts.js': [ 351 | // '<%= yeoman.dist %>/scripts/scripts.js' 352 | // ] 353 | // } 354 | // } 355 | // }, 356 | // concat: { 357 | // dist: {} 358 | // }, 359 | 360 | // Test settings 361 | karma: { 362 | unit: { 363 | configFile: 'karma.conf.js', 364 | singleRun: true 365 | } 366 | }, 367 | 368 | //Translation string extraction 369 | nggettext_extract: { 370 | pot: { 371 | files: { 372 | 'app/po/template.pot': ['app/*.html','app/views/*.html','.tmp/scripts/*.js','app/scripts/plots.js','app/scripts/*.coffee'] 373 | } 374 | }, 375 | }, 376 | 377 | //Compile translated po files into js 378 | nggettext_compile: { 379 | all: { 380 | options: { 381 | 'module': "taarifaWaterpointsApp" 382 | }, 383 | files: { 384 | 'app/scripts/translations.js': ['app/po/*.po'] 385 | } 386 | }, 387 | } 388 | }); 389 | 390 | grunt.loadNpmTasks('grunt-angular-gettext'); 391 | 392 | grunt.loadNpmTasks('grunt-bower-task'); 393 | 394 | grunt.registerTask('serve', function (target) { 395 | if (target === 'dist') { 396 | return grunt.task.run(['build', 'connect:dist:keepalive']); 397 | } 398 | 399 | grunt.task.run([ 400 | 'clean:server', 401 | 'concurrent:server', 402 | 'autoprefixer', 403 | 'configureProxies:server', 404 | 'connect:livereload', 405 | 'watch' 406 | ]); 407 | }); 408 | 409 | grunt.registerTask('server', function (target) { 410 | grunt.log.warn('The `server` task has been deprecated. Use `grunt serve` to start a server.'); 411 | grunt.task.run(['serve:' + target]); 412 | }); 413 | 414 | grunt.registerTask('test', [ 415 | 'clean:server', 416 | 'concurrent:test', 417 | 'autoprefixer', 418 | 'connect:test', 419 | 'karma' 420 | ]); 421 | 422 | grunt.registerTask('build', [ 423 | 'clean:dist', 424 | 'bower', 425 | 'nggettext_extract', 426 | 'nggettext_compile', 427 | 'useminPrepare', 428 | 'concurrent:dist', 429 | 'autoprefixer', 430 | 'concat', 431 | 'copy:dist', 432 | 'cssmin', 433 | // 'uglify', 434 | 'rev', 435 | 'usemin', 436 | 'htmlmin' 437 | ]); 438 | 439 | grunt.registerTask('default', [ 440 | 'newer:jshint', 441 | 'test', 442 | 'build' 443 | ]); 444 | }; 445 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2015 Taarifa Association 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn taarifa_waterpoints:app 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Taarifa Waterpoints 2 | =================== 3 | 4 | .. contents:: Table of Contents 5 | Background 6 | Waterpoints 7 | Prequisites 8 | Linux 9 | Windows 10 | Installation 11 | Installation using a virtual machine 12 | Usage 13 | Deployment to Heroku 14 | Contribute 15 | 16 | 17 | 18 | 19 | Background 20 | ========== 21 | 22 | Taarifa_ is an open source platform for the crowd sourced reporting and 23 | triaging of infrastructure related issues. Think of it as a bug tracker 24 | for the real world which helps to engage citizens with their local 25 | government. 26 | 27 | The Taarifa platform is built around the `Taarifa API`_, a RESTful 28 | service offering that clients can interact with to create and triage 29 | 'bugreports' relating to public infrastructure (e.g., the public toilet 30 | is broken). 31 | 32 | For information on how to get inovoled, scroll to the Contributing section 33 | at the bottom of the page. 34 | 35 | Waterpoints 36 | =========== 37 | 38 | This repository contains an example application around Waterpoint 39 | Management built on top of the core API. It contains scripts to import 40 | Waterpoint data (resources) which then become available through the API 41 | to file requests against (e.g., waterpoint 2345 is broken and needs 42 | fixing). 43 | 44 | There is also an angularjs_ web application that illustrates how a user 45 | can interact with the API and data through a browser. 46 | 47 | 48 | Prerequisites 49 | ============= 50 | 51 | .. note:: 52 | You may choose to install into a virtual machine as described further down. 53 | 54 | Taarifa requires Python_, pip_, nodejs_, npm_ and MongoDB_ to be available on 55 | the system. 56 | 57 | .. note:: 58 | pip_ >= 1.5 is required. if you have an older version you can upgrade (may 59 | require ``sudo``) with :: 60 | 61 | pip install -U pip 62 | 63 | Linux 64 | ----- 65 | 66 | If you are running Ubuntu you can simply execute the `install.sh` script :: 67 | 68 | ./install.sh 69 | 70 | Some of the commands are executed with sudo and require sudo permission for the 71 | currently logged in user. 72 | 73 | On other distributions, use the package manager to install the packages 74 | corresponding to those listed in `install.sh`. 75 | 76 | 77 | Windows 78 | ------- 79 | 80 | Windows can be used as the platform to run Taarifa - the main caveat here 81 | however is that as you install the dependencies, these are not added to the 82 | `$PATH` variable - this needs to be added manually. 83 | 84 | Details for the following steps are the same as for MacOS/Linux (except for 85 | the actual package installation): 86 | 87 | 1. Install Python_. 88 | 2. Install pip_. 89 | 3. Install nodejs_ and npm_ (these need to be added to the $PATH variable!) 90 | 4. Install and run MongoDB_, which does not automagically come with a service, 91 | so it needs to be started manually. Open a command prompt and run: :: 92 | 93 | "c:\Program Files\MongoDB 2.6 Standard\bin\mongod.exe" --dbpath c:\mongo_databases\taarifa\ 94 | 95 | 96 | Installation 97 | ============ 98 | 99 | .. note:: 100 | The following steps are part of the `bootstrap.sh` script, so you may choose 101 | to execute that instead. 102 | 103 | Requires Python, pip and the `Taarifa API`_ to be installed and MongoDB to 104 | be running. 105 | 106 | To ease development and debugging we suggest you use virtualenv_. 107 | Install virtualenv_ and virtualenvwrapper (you might need admin rights for this): :: 108 | 109 | pip install virtualenv virtualenvwrapper 110 | 111 | For windows only, install virtualenvwrapper-win_ using pip: :: 112 | 113 | pip install virtualenvwrapper-win 114 | 115 | `Set up virtualenvwrapper`_ according to your shell and create a virtualenv: :: 116 | 117 | mkvirtualenv TaarifaAPI 118 | 119 | If you already created the virtualenv for the `Taarifa API`_, activate it: :: 120 | 121 | workon TaarifaAPI 122 | 123 | Clone the repository :: 124 | 125 | git clone https://github.com/taarifa/TaarifaWaterpoints 126 | 127 | Change into directory and install the requirements :: 128 | 129 | cd TaarifaWaterpoints 130 | pip install -r requirements/dev.txt 131 | 132 | Ensure you have node.js and npm installed. Then, from the 133 | ``TaarifaWaterpoints`` directory, install the npm dependencies: :: 134 | 135 | npm install 136 | 137 | Install the Grunt_ and Bower_ command line interface 138 | You may need to have administrator (root) rights to run some of the commands below. 139 | On Windows this typically means running the command prompt window (CMD) as Administrator..: :: 140 | 141 | npm install -g grunt-cli 142 | npm install -g bower 143 | 144 | Finally, install the frontend dependencies using Bower_: :: 145 | 146 | bower install 147 | 148 | Continue with the usage section. 149 | 150 | Installation using a virtual machine 151 | ------------------------------------- 152 | 153 | Instead of following the installation instructions above you may choose to 154 | set up a virtual machine with all dependencies installed. This process is fully 155 | automated using Vagrant_ and the provided Vagrantfile_. Note that the 156 | Vagrantfile is included in the repository and needs not be downloaded. 157 | 158 | Install VirtualBox_ and Vagrant_ for your platform. 159 | 160 | Clone the repositories into the same root folder. This is required since these 161 | local folders are mounted in the VM such that you can edit files either on the 162 | host or in the VM. :: 163 | 164 | git clone https://github.com/taarifa/TaarifaAPI 165 | git clone https://github.com/taarifa/TaarifaWaterpoints 166 | cd TaarifaWaterpoints 167 | 168 | Start the VM. This may take quite a while the very first time as the VM image 169 | needs to be downloaded (~360MB) and the VM provisioned with all dependencies. 170 | On every subsequent use these steps are skipped. :: 171 | 172 | vagrant up 173 | 174 | In case provisioning fails due to e.g. loss of network connection, run the 175 | provisioning scripts again until successful: :: 176 | 177 | vagrant provision 178 | 179 | Connect to the virtual machine and change into the `TaarifaWaterpoints` 180 | folder: :: 181 | 182 | vagrant ssh 183 | cd TaarifaWaterpoints 184 | 185 | You can then continue with the usage section below. The ports are automatically 186 | forwarded so you can access the API and frontend from your host browser. Note 187 | that both the `TaarifaAPI` and the `TaarifaWaterpoints` folders in the VM are 188 | mounted from the host i.e. changes made on the host are immediately reflected in 189 | the VM and vice versa. This allows you to work on the code either on the host or 190 | in the VM according to your preference. 191 | 192 | Usage 193 | ===== 194 | 195 | .. note:: 196 | When using a virtual machine, run the following commands in the VM. 197 | 198 | Make sure the virtualenv is active: :: 199 | 200 | workon TaarifaAPI 201 | 202 | From the TaarifaWaterpoints directory run the following commands to 203 | create the waterpoint schemas: :: 204 | 205 | python manage.py create_facility 206 | python manage.py create_service 207 | 208 | Then upload the `waterpoint data`_: :: 209 | 210 | python manage.py upload_waterpoints 211 | 212 | Start the application from the TaarifaWaterpoints directory by running: :: 213 | 214 | python manage.py runserver -r -d 215 | 216 | By default the API server is only accessible from the local machine. If access 217 | from the outside is required (e.g. when running from inside a VM), run: :: 218 | 219 | python manage.py runserver -h 0.0.0.0 -r -d 220 | 221 | The flags ``-r`` and ``-d`` cause the server to run in debug mode and reload 222 | automatically when files are changed. 223 | 224 | To verify things are working, open a browser (on the host when using the VM) 225 | and navigate to: :: 226 | 227 | http://localhost:5000/api/waterpoints 228 | 229 | This should show a list of all the waterpoint resources currently in the 230 | database. 231 | 232 | To work on the frontend web application start the `grunt` server (with the API 233 | server running on port 5000) using: :: 234 | 235 | grunt serve --watch 236 | 237 | Then navigate to (on the host when using the VM): :: 238 | 239 | http://localhost:9000 240 | 241 | Grunt watches the `app` folder for changes and automatically reloads the 242 | frontend in your browser as soon as you make changes. 243 | 244 | To build the frontend (which is automatically done on deployment), use: :: 245 | 246 | grunt build 247 | 248 | This creates a distribution in the `dist` folder, which is served via the 249 | Flask development server running on port 5000. The build step needs to be run 250 | again whenever the frontend in the `app` folder changes. Running `grunt serve` 251 | is not required in this case. 252 | 253 | 254 | Requirements 255 | ------------ 256 | 257 | Taarifa uses pip to install and manage python dependencies. 258 | 259 | Conventionally this uses `requirements.txt`, but Heroku automatically installs 260 | from there. Therefore a `requirements` folder is used as following: 261 | 262 | * Dev and deploy requirements in `requirements/base.txt` 263 | * Development *only* in `requirements/dev.txt` 264 | * Deployment *only* in `requirements/deploy.txt` 265 | 266 | 267 | Deployment to Heroku 268 | ==================== 269 | 270 | To deploy to Heroku_, make sure the `Heroku tool belt`_ is installed. From the 271 | TaarifaWaterpoints root folder, create a new app: :: 272 | 273 | heroku app:create 274 | 275 | This will add a new Git remote `heroku`, which is used to deploy the app. Run 276 | `git remote -v` to check. To add the remote manually, do: :: 277 | 278 | git remote add heroku git@heroku.com:.git 279 | 280 | Since Taarifa uses Python for the API and Node.js to build the frontend, Heroku 281 | build packs for both stacks are required. heroku-buildpack-multi_ enables the 282 | use of multiple build packs, configured via the `.buildpacks` file. Before 283 | deploying for the first time, the app needs to be configured to use it: :: 284 | 285 | heroku config:set BUILDPACK_URL=https://github.com/ddollar/heroku-buildpack-multi.git 286 | 287 | Add the MongoLab Sandbox to provide the MongoDB database :: 288 | 289 | heroku addons:add mongolab 290 | 291 | To be able to import the data into the MongoLab database, copy down the heroku 292 | configuration to a `.env` file you can use with `foreman`: :: 293 | 294 | heroku config:pull 295 | 296 | Make sure the virtualenv is active: :: 297 | 298 | workon TaarifaAPI 299 | 300 | Create the waterpoint schemas and upload the `waterpoint data`_, which may take 301 | several hours: :: 302 | 303 | foreman run python manage.py create_facility 304 | foreman run python manage.py create_service 305 | foreman run python manage.py upload_waterpoints 306 | 307 | Alternatively, you can import a dump of your local database and import it. If 308 | `mongod` is not running, create a dump directly from the database files in a 309 | `dump` folder in your current directory: :: 310 | 311 | sudo -u mongodb mongodump --journal --db TaarifaAPI --dbpath /var/lib/mongodb 312 | 313 | This assumes you have followed the `MongoDB installation instructions`_ on 314 | Ubuntu. Otherwise you might not need to run the command as the `mongodb` user 315 | and your database directory might be `/data/db`. 316 | 317 | Import the dump into your MongoLab database, running the following command: :: 318 | 319 | mongorestore -h -d -u -p /path/to/dump/TaarifaAPI/ 320 | 321 | Extract host, database, user and password from the `MONGOLAB_URI` Heroku 322 | configuration variable: :: 323 | 324 | heroku config:get MONGOLAB_URI 325 | 326 | Once finished you are ready to deploy: :: 327 | 328 | git push heroku master 329 | 330 | To set up a custom domain for the deployed app, register with heroku: :: 331 | 332 | heroku domains:add 333 | 334 | and add a DNS record for it: :: 335 | 336 | . 10800 IN CNAME .herokuapp.com. 337 | 338 | Contribute 339 | ========== 340 | 341 | There is still much left do do and Taarifa is currently undergoing rapid 342 | development. We aspire to be a very friendly and welcoming community to 343 | all skill levels. 344 | 345 | To get started send a message to the taarifa-dev_ mailinglist introducing 346 | yourself and your interest in Taarifa. With some luck you should also be 347 | able to find somebody on our `IRC channel`_. 348 | 349 | If you are comfortable you can also take a look at the github issues and 350 | comment/fix to you heart's content. 351 | 352 | We use the github pull request model for all contributions. Refer to the `contributing 353 | guidelines`_ for further details. 354 | 355 | .. _IRC channel: http://gwob.org/taarifa-irc 356 | .. _Taarifa: http://taarifa.org 357 | .. _taarifa-dev: https://groups.google.com/forum/#!forum/taarifa-dev 358 | .. _Taarifa API: http://github.com/taarifa/TaarifaAPI 359 | .. _angularjs: https://angularjs.org/ 360 | .. _Python: http://python.org 361 | .. _pip: https://pip.pypa.io/en/latest/installing.html 362 | .. _nodejs: http://nodejs.org 363 | .. _npm: http://npmjs.org 364 | .. _MongoDB: http://mongodb.org 365 | .. _virtualenv: http://virtualenv.org 366 | .. _Set up virtualenvwrapper: http://virtualenvwrapper.readthedocs.org/en/latest/install.html#shell-startup-file 367 | .. _Grunt: http://gruntjs.com 368 | .. _Bower: http://bower.io 369 | .. _Vagrant: http://vagrantup.com 370 | .. _Vagrantfile: Vagrantfile 371 | .. _VirtualBox: https://www.virtualbox.org 372 | .. _waterpoint data: https://drive.google.com/file/d/0B5dKo9igl8W4UmpHdjNGV09FZmM/view?usp=sharing 373 | .. _Heroku: https://toolbelt.heroku.com 374 | .. _Heroku tool belt: https://toolbelt.heroku.com 375 | .. _heroku-buildpack-multi: https://github.com/ddollar/heroku-buildpack-multi 376 | .. _MongoDB installation instructions: http://docs.mongodb.org/manual/tutorial/install-mongodb-on-ubuntu/ 377 | .. _contributing guidelines: CONTRIBUTING.rst 378 | .. _virtualenvwrapper-win: https://pypi.python.org/pypi/virtualenvwrapper-win 379 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | # All Vagrant configuration is done here. The most common configuration 6 | # options are documented and commented below. For a complete reference, 7 | # please see the online documentation at vagrantup.com. 8 | 9 | # Every Vagrant virtual environment requires a box to build off of. 10 | config.vm.box = "ubuntu/trusty64" 11 | 12 | # The url from where the 'config.vm.box' box will be fetched if it 13 | # doesn't already exist on the user's system. 14 | # config.vm.box_url = "https://vagrantcloud.com/ubuntu/trusty64/version/1/provider/virtualbox.box" 15 | 16 | # Sync the TaarifaAPI repository 17 | config.vm.synced_folder "../TaarifaAPI", "/home/vagrant/TaarifaAPI" 18 | 19 | # Sync the TaarifaWaterpoints repository 20 | config.vm.synced_folder ".", "/home/vagrant/TaarifaWaterpoints" 21 | 22 | # Forward the default flask port 23 | config.vm.network "forwarded_port", guest: 5000, host: 5000 24 | 25 | # Forward the grunt development server port 26 | config.vm.network "forwarded_port", guest: 9000, host: 9000 27 | 28 | # Forward the livereload port 29 | config.vm.network "forwarded_port", guest: 35729, host: 35729 30 | 31 | # Provision the VM 32 | config.vm.provision "shell", path: "install.sh", :privileged => false 33 | config.vm.provision "shell", path: "bootstrap.sh", :privileged => false 34 | end 35 | -------------------------------------------------------------------------------- /app/.buildignore: -------------------------------------------------------------------------------- 1 | *.coffee -------------------------------------------------------------------------------- /app/.htaccess: -------------------------------------------------------------------------------- 1 | # Apache Configuration File 2 | 3 | # (!) Using `.htaccess` files slows down Apache, therefore, if you have access 4 | # to the main server config file (usually called `httpd.conf`), you should add 5 | # this logic there: http://httpd.apache.org/docs/current/howto/htaccess.html. 6 | 7 | # ############################################################################## 8 | # # CROSS-ORIGIN RESOURCE SHARING (CORS) # 9 | # ############################################################################## 10 | 11 | # ------------------------------------------------------------------------------ 12 | # | Cross-domain AJAX requests | 13 | # ------------------------------------------------------------------------------ 14 | 15 | # Enable cross-origin AJAX requests. 16 | # http://code.google.com/p/html5security/wiki/CrossOriginRequestSecurity 17 | # http://enable-cors.org/ 18 | 19 | # 20 | # Header set Access-Control-Allow-Origin "*" 21 | # 22 | 23 | # ------------------------------------------------------------------------------ 24 | # | CORS-enabled images | 25 | # ------------------------------------------------------------------------------ 26 | 27 | # Send the CORS header for images when browsers request it. 28 | # https://developer.mozilla.org/en/CORS_Enabled_Image 29 | # http://blog.chromium.org/2011/07/using-cross-domain-images-in-webgl-and.html 30 | # http://hacks.mozilla.org/2011/11/using-cors-to-load-webgl-textures-from-cross-domain-images/ 31 | 32 | 33 | 34 | 35 | SetEnvIf Origin ":" IS_CORS 36 | Header set Access-Control-Allow-Origin "*" env=IS_CORS 37 | 38 | 39 | 40 | 41 | # ------------------------------------------------------------------------------ 42 | # | Web fonts access | 43 | # ------------------------------------------------------------------------------ 44 | 45 | # Allow access from all domains for web fonts 46 | 47 | 48 | 49 | Header set Access-Control-Allow-Origin "*" 50 | 51 | 52 | 53 | 54 | # ############################################################################## 55 | # # ERRORS # 56 | # ############################################################################## 57 | 58 | # ------------------------------------------------------------------------------ 59 | # | 404 error prevention for non-existing redirected folders | 60 | # ------------------------------------------------------------------------------ 61 | 62 | # Prevent Apache from returning a 404 error for a rewrite if a directory 63 | # with the same name does not exist. 64 | # http://httpd.apache.org/docs/current/content-negotiation.html#multiviews 65 | # http://www.webmasterworld.com/apache/3808792.htm 66 | 67 | Options -MultiViews 68 | 69 | # ------------------------------------------------------------------------------ 70 | # | Custom error messages / pages | 71 | # ------------------------------------------------------------------------------ 72 | 73 | # You can customize what Apache returns to the client in case of an error (see 74 | # http://httpd.apache.org/docs/current/mod/core.html#errordocument), e.g.: 75 | 76 | ErrorDocument 404 /404.html 77 | 78 | 79 | # ############################################################################## 80 | # # INTERNET EXPLORER # 81 | # ############################################################################## 82 | 83 | # ------------------------------------------------------------------------------ 84 | # | Better website experience | 85 | # ------------------------------------------------------------------------------ 86 | 87 | # Force IE to render pages in the highest available mode in the various 88 | # cases when it may not: http://hsivonen.iki.fi/doctype/ie-mode.pdf. 89 | 90 | 91 | Header set X-UA-Compatible "IE=edge" 92 | # `mod_headers` can't match based on the content-type, however, we only 93 | # want to send this header for HTML pages and not for the other resources 94 | 95 | Header unset X-UA-Compatible 96 | 97 | 98 | 99 | # ------------------------------------------------------------------------------ 100 | # | Cookie setting from iframes | 101 | # ------------------------------------------------------------------------------ 102 | 103 | # Allow cookies to be set from iframes in IE. 104 | 105 | # 106 | # Header set P3P "policyref=\"/w3c/p3p.xml\", CP=\"IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT\"" 107 | # 108 | 109 | # ------------------------------------------------------------------------------ 110 | # | Screen flicker | 111 | # ------------------------------------------------------------------------------ 112 | 113 | # Stop screen flicker in IE on CSS rollovers (this only works in 114 | # combination with the `ExpiresByType` directives for images from below). 115 | 116 | # BrowserMatch "MSIE" brokenvary=1 117 | # BrowserMatch "Mozilla/4.[0-9]{2}" brokenvary=1 118 | # BrowserMatch "Opera" !brokenvary 119 | # SetEnvIf brokenvary 1 force-no-vary 120 | 121 | 122 | # ############################################################################## 123 | # # MIME TYPES AND ENCODING # 124 | # ############################################################################## 125 | 126 | # ------------------------------------------------------------------------------ 127 | # | Proper MIME types for all files | 128 | # ------------------------------------------------------------------------------ 129 | 130 | 131 | 132 | # Audio 133 | AddType audio/mp4 m4a f4a f4b 134 | AddType audio/ogg oga ogg 135 | 136 | # JavaScript 137 | # Normalize to standard type (it's sniffed in IE anyways): 138 | # http://tools.ietf.org/html/rfc4329#section-7.2 139 | AddType application/javascript js jsonp 140 | AddType application/json json 141 | 142 | # Video 143 | AddType video/mp4 mp4 m4v f4v f4p 144 | AddType video/ogg ogv 145 | AddType video/webm webm 146 | AddType video/x-flv flv 147 | 148 | # Web fonts 149 | AddType application/font-woff woff 150 | AddType application/vnd.ms-fontobject eot 151 | 152 | # Browsers usually ignore the font MIME types and sniff the content, 153 | # however, Chrome shows a warning if other MIME types are used for the 154 | # following fonts. 155 | AddType application/x-font-ttf ttc ttf 156 | AddType font/opentype otf 157 | 158 | # Make SVGZ fonts work on iPad: 159 | # https://twitter.com/FontSquirrel/status/14855840545 160 | AddType image/svg+xml svg svgz 161 | AddEncoding gzip svgz 162 | 163 | # Other 164 | AddType application/octet-stream safariextz 165 | AddType application/x-chrome-extension crx 166 | AddType application/x-opera-extension oex 167 | AddType application/x-shockwave-flash swf 168 | AddType application/x-web-app-manifest+json webapp 169 | AddType application/x-xpinstall xpi 170 | AddType application/xml atom rdf rss xml 171 | AddType image/webp webp 172 | AddType image/x-icon ico 173 | AddType text/cache-manifest appcache manifest 174 | AddType text/vtt vtt 175 | AddType text/x-component htc 176 | AddType text/x-vcard vcf 177 | 178 | 179 | 180 | # ------------------------------------------------------------------------------ 181 | # | UTF-8 encoding | 182 | # ------------------------------------------------------------------------------ 183 | 184 | # Use UTF-8 encoding for anything served as `text/html` or `text/plain`. 185 | AddDefaultCharset utf-8 186 | 187 | # Force UTF-8 for certain file formats. 188 | 189 | AddCharset utf-8 .atom .css .js .json .rss .vtt .webapp .xml 190 | 191 | 192 | 193 | # ############################################################################## 194 | # # URL REWRITES # 195 | # ############################################################################## 196 | 197 | # ------------------------------------------------------------------------------ 198 | # | Rewrite engine | 199 | # ------------------------------------------------------------------------------ 200 | 201 | # Turning on the rewrite engine and enabling the `FollowSymLinks` option is 202 | # necessary for the following directives to work. 203 | 204 | # If your web host doesn't allow the `FollowSymlinks` option, you may need to 205 | # comment it out and use `Options +SymLinksIfOwnerMatch` but, be aware of the 206 | # performance impact: http://httpd.apache.org/docs/current/misc/perf-tuning.html#symlinks 207 | 208 | # Also, some cloud hosting services require `RewriteBase` to be set: 209 | # http://www.rackspace.com/knowledge_center/frequently-asked-question/why-is-mod-rewrite-not-working-on-my-site 210 | 211 | 212 | Options +FollowSymlinks 213 | # Options +SymLinksIfOwnerMatch 214 | RewriteEngine On 215 | # RewriteBase / 216 | 217 | 218 | # ------------------------------------------------------------------------------ 219 | # | Suppressing / Forcing the "www." at the beginning of URLs | 220 | # ------------------------------------------------------------------------------ 221 | 222 | # The same content should never be available under two different URLs especially 223 | # not with and without "www." at the beginning. This can cause SEO problems 224 | # (duplicate content), therefore, you should choose one of the alternatives and 225 | # redirect the other one. 226 | 227 | # By default option 1 (no "www.") is activated: 228 | # http://no-www.org/faq.php?q=class_b 229 | 230 | # If you'd prefer to use option 2, just comment out all the lines from option 1 231 | # and uncomment the ones from option 2. 232 | 233 | # IMPORTANT: NEVER USE BOTH RULES AT THE SAME TIME! 234 | 235 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 236 | 237 | # Option 1: rewrite www.example.com → example.com 238 | 239 | 240 | RewriteCond %{HTTPS} !=on 241 | RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] 242 | RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L] 243 | 244 | 245 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 246 | 247 | # Option 2: rewrite example.com → www.example.com 248 | 249 | # Be aware that the following might not be a good idea if you use "real" 250 | # subdomains for certain parts of your website. 251 | 252 | # 253 | # RewriteCond %{HTTPS} !=on 254 | # RewriteCond %{HTTP_HOST} !^www\..+$ [NC] 255 | # RewriteRule ^ http://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L] 256 | # 257 | 258 | 259 | # ############################################################################## 260 | # # SECURITY # 261 | # ############################################################################## 262 | 263 | # ------------------------------------------------------------------------------ 264 | # | Content Security Policy (CSP) | 265 | # ------------------------------------------------------------------------------ 266 | 267 | # You can mitigate the risk of cross-site scripting and other content-injection 268 | # attacks by setting a Content Security Policy which whitelists trusted sources 269 | # of content for your site. 270 | 271 | # The example header below allows ONLY scripts that are loaded from the current 272 | # site's origin (no inline scripts, no CDN, etc). This almost certainly won't 273 | # work as-is for your site! 274 | 275 | # To get all the details you'll need to craft a reasonable policy for your site, 276 | # read: http://html5rocks.com/en/tutorials/security/content-security-policy (or 277 | # see the specification: http://w3.org/TR/CSP). 278 | 279 | # 280 | # Header set Content-Security-Policy "script-src 'self'; object-src 'self'" 281 | # 282 | # Header unset Content-Security-Policy 283 | # 284 | # 285 | 286 | # ------------------------------------------------------------------------------ 287 | # | File access | 288 | # ------------------------------------------------------------------------------ 289 | 290 | # Block access to directories without a default document. 291 | # Usually you should leave this uncommented because you shouldn't allow anyone 292 | # to surf through every directory on your server (which may includes rather 293 | # private places like the CMS's directories). 294 | 295 | 296 | Options -Indexes 297 | 298 | 299 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 300 | 301 | # Block access to hidden files and directories. 302 | # This includes directories used by version control systems such as Git and SVN. 303 | 304 | 305 | RewriteCond %{SCRIPT_FILENAME} -d [OR] 306 | RewriteCond %{SCRIPT_FILENAME} -f 307 | RewriteRule "(^|/)\." - [F] 308 | 309 | 310 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 311 | 312 | # Block access to backup and source files. 313 | # These files may be left by some text editors and can pose a great security 314 | # danger when anyone has access to them. 315 | 316 | 317 | Order allow,deny 318 | Deny from all 319 | Satisfy All 320 | 321 | 322 | # ------------------------------------------------------------------------------ 323 | # | Secure Sockets Layer (SSL) | 324 | # ------------------------------------------------------------------------------ 325 | 326 | # Rewrite secure requests properly to prevent SSL certificate warnings, e.g.: 327 | # prevent `https://www.example.com` when your certificate only allows 328 | # `https://secure.example.com`. 329 | 330 | # 331 | # RewriteCond %{SERVER_PORT} !^443 332 | # RewriteRule ^ https://example-domain-please-change-me.com%{REQUEST_URI} [R=301,L] 333 | # 334 | 335 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 336 | 337 | # Force client-side SSL redirection. 338 | 339 | # If a user types "example.com" in his browser, the above rule will redirect him 340 | # to the secure version of the site. That still leaves a window of opportunity 341 | # (the initial HTTP connection) for an attacker to downgrade or redirect the 342 | # request. The following header ensures that browser will ONLY connect to your 343 | # server via HTTPS, regardless of what the users type in the address bar. 344 | # http://www.html5rocks.com/en/tutorials/security/transport-layer-security/ 345 | 346 | # 347 | # Header set Strict-Transport-Security max-age=16070400; 348 | # 349 | 350 | # ------------------------------------------------------------------------------ 351 | # | Server software information | 352 | # ------------------------------------------------------------------------------ 353 | 354 | # Avoid displaying the exact Apache version number, the description of the 355 | # generic OS-type and the information about Apache's compiled-in modules. 356 | 357 | # ADD THIS DIRECTIVE IN THE `httpd.conf` AS IT WILL NOT WORK IN THE `.htaccess`! 358 | 359 | # ServerTokens Prod 360 | 361 | 362 | # ############################################################################## 363 | # # WEB PERFORMANCE # 364 | # ############################################################################## 365 | 366 | # ------------------------------------------------------------------------------ 367 | # | Compression | 368 | # ------------------------------------------------------------------------------ 369 | 370 | 371 | 372 | # Force compression for mangled headers. 373 | # http://developer.yahoo.com/blogs/ydn/posts/2010/12/pushing-beyond-gzipping 374 | 375 | 376 | SetEnvIfNoCase ^(Accept-EncodXng|X-cept-Encoding|X{15}|~{15}|-{15})$ ^((gzip|deflate)\s*,?\s*)+|[X~-]{4,13}$ HAVE_Accept-Encoding 377 | RequestHeader append Accept-Encoding "gzip,deflate" env=HAVE_Accept-Encoding 378 | 379 | 380 | 381 | # Compress all output labeled with one of the following MIME-types 382 | # (for Apache versions below 2.3.7, you don't need to enable `mod_filter` 383 | # and can remove the `` and `` lines 384 | # as `AddOutputFilterByType` is still in the core directives). 385 | 386 | AddOutputFilterByType DEFLATE application/atom+xml \ 387 | application/javascript \ 388 | application/json \ 389 | application/rss+xml \ 390 | application/vnd.ms-fontobject \ 391 | application/x-font-ttf \ 392 | application/x-web-app-manifest+json \ 393 | application/xhtml+xml \ 394 | application/xml \ 395 | font/opentype \ 396 | image/svg+xml \ 397 | image/x-icon \ 398 | text/css \ 399 | text/html \ 400 | text/plain \ 401 | text/x-component \ 402 | text/xml 403 | 404 | 405 | 406 | 407 | # ------------------------------------------------------------------------------ 408 | # | Content transformations | 409 | # ------------------------------------------------------------------------------ 410 | 411 | # Prevent some of the mobile network providers from modifying the content of 412 | # your site: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.5. 413 | 414 | # 415 | # Header set Cache-Control "no-transform" 416 | # 417 | 418 | # ------------------------------------------------------------------------------ 419 | # | ETag removal | 420 | # ------------------------------------------------------------------------------ 421 | 422 | # Since we're sending far-future expires headers (see below), ETags can 423 | # be removed: http://developer.yahoo.com/performance/rules.html#etags. 424 | 425 | # `FileETag None` is not enough for every server. 426 | 427 | Header unset ETag 428 | 429 | 430 | FileETag None 431 | 432 | # ------------------------------------------------------------------------------ 433 | # | Expires headers (for better cache control) | 434 | # ------------------------------------------------------------------------------ 435 | 436 | # The following expires headers are set pretty far in the future. If you don't 437 | # control versioning with filename-based cache busting, consider lowering the 438 | # cache time for resources like CSS and JS to something like 1 week. 439 | 440 | 441 | 442 | ExpiresActive on 443 | ExpiresDefault "access plus 1 month" 444 | 445 | # CSS 446 | ExpiresByType text/css "access plus 1 year" 447 | 448 | # Data interchange 449 | ExpiresByType application/json "access plus 0 seconds" 450 | ExpiresByType application/xml "access plus 0 seconds" 451 | ExpiresByType text/xml "access plus 0 seconds" 452 | 453 | # Favicon (cannot be renamed!) 454 | ExpiresByType image/x-icon "access plus 1 week" 455 | 456 | # HTML components (HTCs) 457 | ExpiresByType text/x-component "access plus 1 month" 458 | 459 | # HTML 460 | ExpiresByType text/html "access plus 0 seconds" 461 | 462 | # JavaScript 463 | ExpiresByType application/javascript "access plus 1 year" 464 | 465 | # Manifest files 466 | ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds" 467 | ExpiresByType text/cache-manifest "access plus 0 seconds" 468 | 469 | # Media 470 | ExpiresByType audio/ogg "access plus 1 month" 471 | ExpiresByType image/gif "access plus 1 month" 472 | ExpiresByType image/jpeg "access plus 1 month" 473 | ExpiresByType image/png "access plus 1 month" 474 | ExpiresByType video/mp4 "access plus 1 month" 475 | ExpiresByType video/ogg "access plus 1 month" 476 | ExpiresByType video/webm "access plus 1 month" 477 | 478 | # Web feeds 479 | ExpiresByType application/atom+xml "access plus 1 hour" 480 | ExpiresByType application/rss+xml "access plus 1 hour" 481 | 482 | # Web fonts 483 | ExpiresByType application/font-woff "access plus 1 month" 484 | ExpiresByType application/vnd.ms-fontobject "access plus 1 month" 485 | ExpiresByType application/x-font-ttf "access plus 1 month" 486 | ExpiresByType font/opentype "access plus 1 month" 487 | ExpiresByType image/svg+xml "access plus 1 month" 488 | 489 | 490 | 491 | # ------------------------------------------------------------------------------ 492 | # | Filename-based cache busting | 493 | # ------------------------------------------------------------------------------ 494 | 495 | # If you're not using a build process to manage your filename version revving, 496 | # you might want to consider enabling the following directives to route all 497 | # requests such as `/css/style.12345.css` to `/css/style.css`. 498 | 499 | # To understand why this is important and a better idea than `*.css?v231`, read: 500 | # http://stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring 501 | 502 | # 503 | # RewriteCond %{REQUEST_FILENAME} !-f 504 | # RewriteCond %{REQUEST_FILENAME} !-d 505 | # RewriteRule ^(.+)\.(\d+)\.(js|css|png|jpg|gif)$ $1.$3 [L] 506 | # 507 | 508 | # ------------------------------------------------------------------------------ 509 | # | File concatenation | 510 | # ------------------------------------------------------------------------------ 511 | 512 | # Allow concatenation from within specific CSS and JS files, e.g.: 513 | # Inside of `script.combined.js` you could have 514 | # 515 | # 516 | # and they would be included into this single file. 517 | 518 | # 519 | # 520 | # Options +Includes 521 | # AddOutputFilterByType INCLUDES application/javascript application/json 522 | # SetOutputFilter INCLUDES 523 | # 524 | # 525 | # Options +Includes 526 | # AddOutputFilterByType INCLUDES text/css 527 | # SetOutputFilter INCLUDES 528 | # 529 | # 530 | 531 | # ------------------------------------------------------------------------------ 532 | # | Persistent connections | 533 | # ------------------------------------------------------------------------------ 534 | 535 | # Allow multiple requests to be sent over the same TCP connection: 536 | # http://httpd.apache.org/docs/current/en/mod/core.html#keepalive. 537 | 538 | # Enable if you serve a lot of static content but, be aware of the 539 | # possible disadvantages! 540 | 541 | # 542 | # Header set Connection Keep-Alive 543 | # 544 | -------------------------------------------------------------------------------- /app/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Not Found :( 6 | 141 | 142 | 143 |
144 |

Not found :(

145 |

Sorry, but the page you were trying to view does not exist.

146 |

It looks like this was the result of either:

147 |
    148 |
  • a mistyped address
  • 149 |
  • an out-of-date link
  • 150 |
151 | 154 | 155 |
156 | 157 | 158 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taarifa/TaarifaWaterpoints/9dcd6a2889ba862848a911ae3338da9ff3c153c0/app/favicon.ico -------------------------------------------------------------------------------- /app/images/Tanzania.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taarifa/TaarifaWaterpoints/9dcd6a2889ba862848a911ae3338da9ff3c153c0/app/images/Tanzania.png -------------------------------------------------------------------------------- /app/images/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taarifa/TaarifaWaterpoints/9dcd6a2889ba862848a911ae3338da9ff3c153c0/app/images/spinner.gif -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 |
42 | 43 |
44 | 60 |

Tanzania Waterpoints

61 |
62 |
63 | 64 |
65 | 66 | Success: 67 | {{flash.message}} 68 |
69 | 70 |
71 | 72 | Error: 73 | {{flash.message}} 74 |
75 | 76 |
77 | 78 | Info: 79 | {{flash.message}} 80 |
81 | 82 |
83 |
84 | 85 | 86 | 95 | 96 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /app/po/sw_TZ.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: TaarifaWaterpoints\n" 4 | "POT-Creation-Date: \n" 5 | "PO-Revision-Date: \n" 6 | "Last-Translator: \n" 7 | "Language-Team: Taarifa \n" 8 | "Language: sw_TZ\n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=UTF-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "X-Generator: Poedit 1.6.8\n" 13 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 14 | 15 | #: .tmp/scripts/regdashboardctrl.js 16 | msgid "% Functional" 17 | msgstr "Vinavyofanya kazi" 18 | 19 | #: app/views/triage.html 20 | msgid "Accept change" 21 | msgstr "Kubali mabadiliko" 22 | 23 | #: app/index.html 24 | msgid "Add Waterpoint" 25 | msgstr "Ongeza kituo cha maji" 26 | 27 | #: app/views/triage.html 28 | msgid "Agency responsible" 29 | msgstr "Wakala husika" 30 | 31 | #: app/views/triage.html 32 | msgid "Attribute" 33 | msgstr "Sifa" 34 | 35 | #: .tmp/scripts/regdashboardctrl.js 36 | msgid "Average Payment" 37 | msgstr "Wastani wa Malipo" 38 | 39 | #: .tmp/scripts/regdashboardctrl.js 40 | msgid "Average Payment Per Ward" 41 | msgstr "wastani wa malipo kwa Kata" 42 | 43 | #: .tmp/scripts/regdashboardctrl.js 44 | msgid "Average payment" 45 | msgstr "Wastani wa malipo" 46 | 47 | #: .tmp/scripts/regdashboardctrl.js 48 | msgid "Breakdown Year" 49 | msgstr "Vilivyoharibika kwa Mwaka" 50 | 51 | #: app/views/dashboard.html 52 | msgid "Charts" 53 | msgstr "Chati" 54 | 55 | #: app/views/triage.html 56 | msgid "Comments" 57 | msgstr "Maoni" 58 | 59 | #: .tmp/scripts/regdashboardctrl.js 60 | msgid "Construction Year" 61 | msgstr "Vilivyojengwa kwa mwaka" 62 | 63 | #: app/views/dashboard.html 64 | msgid "Coverage" 65 | msgstr "Sehemu inayohudumiwa" 66 | 67 | #: app/views/triage.html 68 | msgid "Current value" 69 | msgstr "Hali ya sasa" 70 | 71 | #: app/index.html 72 | msgid "Dashboard" 73 | msgstr "" 74 | 75 | #: app/views/dashboard.html 76 | msgid "Data Table" 77 | msgstr "Jedwali la data" 78 | 79 | #: app/views/main.html 80 | msgid "District" 81 | msgstr "Wilaya" 82 | 83 | #: app/views/triage.html 84 | msgid "Expected completion date" 85 | msgstr "Tarehe inayotarajiwa kumalizika" 86 | 87 | #: .tmp/scripts/regdashboardctrl.js 88 | msgid "Extraction Type" 89 | msgstr "Aina ya uzalishaji" 90 | 91 | #: .tmp/scripts/controllers.js 92 | msgid "Failed to create waterpoint" 93 | msgstr "Imeshindikana kuunda kituo cha maji" 94 | 95 | #: app/views/main.html 96 | msgid "Filter map view" 97 | msgstr "Chuja muonekano wa ramani" 98 | 99 | #: app/views/requests.html 100 | msgid "Filter requests" 101 | msgstr "Chuja ripoti" 102 | 103 | #: app/views/dashboard.html .tmp/scripts/regdashboardctrl.js 104 | msgid "Functional" 105 | msgstr "Vinavyofanya kazi" 106 | 107 | #: .tmp/scripts/regdashboardctrl.js 108 | msgid "Functional Population Served" 109 | msgstr "Wanaohudumiwa na Vinavyofanya kazi" 110 | 111 | #: .tmp/scripts/regdashboardctrl.js 112 | msgid "Functionality" 113 | msgstr "Ufanyaji kazi" 114 | 115 | #: .tmp/scripts/regdashboardctrl.js 116 | msgid "Functionality by District" 117 | msgstr "Ufanyaji kazi kwa Wilaya" 118 | 119 | #: .tmp/scripts/regdashboardctrl.js 120 | msgid "Functionality by Extraction" 121 | msgstr "Ufanyaji kazi kwa aina ya uzalishaji" 122 | 123 | #: .tmp/scripts/regdashboardctrl.js 124 | msgid "Functionality by Management" 125 | msgstr "Ufanyaji kazi kwa wakala" 126 | 127 | #: .tmp/scripts/regdashboardctrl.js 128 | msgid "Functionality by Ward" 129 | msgstr "Ufanyaji kazi kwa Kata" 130 | 131 | #: .tmp/scripts/regdashboardctrl.js 132 | msgid "Functionality vs Cost" 133 | msgstr "Ufanyaji kazi dhidi ya Gharama" 134 | 135 | #: .tmp/scripts/regdashboardctrl.js 136 | msgid "Funder" 137 | msgstr "Mfadhili" 138 | 139 | #: app/views/triage.html 140 | msgid "Further information or comments about the problem" 141 | msgstr "Maelezo zaidi au maoni kuhusu tatizo" 142 | 143 | #: .tmp/scripts/controllers.js 144 | msgid "Geolocation failed:" 145 | msgstr "Imeshindikana kupata eneo la kijiografia" 146 | 147 | #: .tmp/scripts/controllers.js 148 | msgid "Geolocation succeeded: got coordinates" 149 | msgstr "Eneo la kijiografia limepatikana" 150 | 151 | #: app/views/dashboard.html 152 | msgid "Group By" 153 | msgstr "Kundi kwa" 154 | 155 | #: app/index.html 156 | msgid "Home" 157 | msgstr "Nyumbani" 158 | 159 | #: .tmp/scripts/regdashboardctrl.js 160 | msgid "Installer" 161 | msgstr "Mkandarasi" 162 | 163 | #: app/views/spinnerdlg.html 164 | msgid "Loading Data..." 165 | msgstr "Inapakia data.." 166 | 167 | #: app/views/spinnerdlg.html 168 | msgid "Loading waterpoint data." 169 | msgstr "inapakia data za vituo vya maji" 170 | 171 | #: app/views/edit.html 172 | msgid "Location" 173 | msgstr "Eneo" 174 | 175 | #: .tmp/scripts/regdashboardctrl.js 176 | msgid "Management" 177 | msgstr "Wakala" 178 | 179 | #: app/views/main.html 180 | msgid "Max no." 181 | msgstr "Idadi" 182 | 183 | #: app/views/requests.html 184 | msgid "Metadata" 185 | msgstr "" 186 | 187 | #: app/views/dashboard.html 188 | msgid "National" 189 | msgstr "Kitaifa" 190 | 191 | #: .tmp/scripts/regdashboardctrl.js 192 | msgid "Needs repair" 193 | msgstr "vinavyohitaji matengenezo" 194 | 195 | #: .tmp/scripts/controllers.js 196 | msgid "No request matching the criteria!" 197 | msgstr "Idadi ya maombi yanayokidhi vigezo!" 198 | 199 | #: .tmp/scripts/controllers.js 200 | msgid "No waterpoints match your filter criteria!" 201 | msgstr "Hakuna kituo cha maji kilichokidhi vigezo ulivyochagua!" 202 | 203 | #: .tmp/scripts/regdashboardctrl.js 204 | msgid "Not functional" 205 | msgstr "Visivyofanya kazi" 206 | 207 | #: app/scripts/plots.js 208 | msgid "Number of Waterpoints" 209 | msgstr "Idadi ya vituo vya maji" 210 | 211 | #: .tmp/scripts/regdashboardctrl.js 212 | msgid "Payment Method" 213 | msgstr "Njia ya malipo" 214 | 215 | #: .tmp/scripts/natdashboardctrl.js 216 | msgid "Performance Table: % Functional" 217 | msgstr "Utendaji: % ya vinavyofanya kazi" 218 | 219 | #: .tmp/scripts/natdashboardctrl.js 220 | msgid "Performance Table: % Served" 221 | msgstr "Utendaji: % ya wanaohudumiwa" 222 | 223 | #: app/views/triage.html 224 | msgid "Planned actions" 225 | msgstr "Hatua zilizopangwa" 226 | 227 | #: .tmp/scripts/regdashboardctrl.js 228 | msgid "Population" 229 | msgstr "Kiasi cha wanaohudumiwa" 230 | 231 | #: app/views/dashboard.html 232 | msgid "Print" 233 | msgstr "Chapicha" 234 | 235 | #: app/views/dashboard.html 236 | msgid "Publish" 237 | msgstr "Sambaza" 238 | 239 | #: app/views/dashboard.html app/views/main.html 240 | msgid "Region" 241 | msgstr "Mkoa" 242 | 243 | #: app/views/dashboard.html 244 | msgid "Regional" 245 | msgstr "Kimkoa" 246 | 247 | #: app/views/triage.html 248 | msgid "Report status" 249 | msgstr "Hali ya ombi" 250 | 251 | #: app/views/triage.html 252 | msgid "Reported value" 253 | msgstr "hali iliyo ripotiwa" 254 | 255 | #: app/views/requests.html 256 | msgid "Request status" 257 | msgstr "Hali ya ombi" 258 | 259 | #: .tmp/scripts/controllers.js 260 | msgid "Request successfully created!" 261 | msgstr "Ombi limetumwa kwa mafanikio" 262 | 263 | #: app/views/main.html 264 | msgid "Reset" 265 | msgstr "Seti upya" 266 | 267 | #: app/views/dashboard.html 268 | msgid "Reset all filters" 269 | msgstr "Safisha chujio" 270 | 271 | #: app/views/dashboard.html 272 | msgid "Selected" 273 | msgstr "Vilivyochaguliwa" 274 | 275 | #: app/views/main.html 276 | msgid "Status" 277 | msgstr "Hali" 278 | 279 | #: app/views/triage.html 280 | msgid "Steps that will be taken to fix the problem" 281 | msgstr "Hatua zitakazochukuliwa kumaliza tatizo" 282 | 283 | #: app/index.html 284 | msgid "Tanzania Waterpoints" 285 | msgstr "Vitup vya maji Tanzania" 286 | 287 | #: app/views/spinnerdlg.html 288 | msgid "This may take a while depending on your connection" 289 | msgstr "Hii inaweza kuchukua muda kutegemeana na hali ya mtandao wako" 290 | 291 | #: app/views/dashboard.html .tmp/scripts/regdashboardctrl.js 292 | msgid "Top Problems" 293 | msgstr "Matatizo Makuu" 294 | 295 | #: app/index.html 296 | msgid "Triage" 297 | msgstr "Ripoti (Triage)" 298 | 299 | #: app/views/triage.html 300 | msgid "Update report" 301 | msgstr "Sasisha ripoti" 302 | 303 | #: app/views/requests.html 304 | msgid "User provided fields" 305 | msgstr "Yaliyoainishwa na aliyeripoti" 306 | 307 | #: app/views/requests.html 308 | msgid "WP id" 309 | msgstr "Id ya kituo cha maji" 310 | 311 | #: app/views/requests.html 312 | msgid "WP status" 313 | msgstr "Hali vya Kituo cha maji" 314 | 315 | #: app/views/main.html 316 | msgid "WPs with reports" 317 | msgstr "Vituo vya maji vyenye ripoti" 318 | 319 | #: .tmp/scripts/regdashboardctrl.js 320 | msgid "Water Quality" 321 | msgstr "Ubora wa Maji" 322 | 323 | #: .tmp/scripts/regdashboardctrl.js 324 | msgid "Water Quantity" 325 | msgstr "Kiasi cha Maji" 326 | 327 | #: app/views/requests.html app/views/triage.html 328 | msgid "Waterpoint" 329 | msgstr "Kituo cha maji" 330 | 331 | #: .tmp/scripts/regdashboardctrl.js 332 | msgid "Waterpoint Locations" 333 | msgstr "Maeneo vilipo vituo vya maji" 334 | 335 | #: .tmp/scripts/natdashboardctrl.js 336 | msgid "Waterpoint status (ordered by % Functional)" 337 | msgstr "Hali vya vituo vya maji" 338 | 339 | #: .tmp/scripts/controllers.js 340 | msgid "Waterpoint successfully created!" 341 | msgstr "Kituo cha maji kimeingizwa kwa mafanikio!" 342 | 343 | #: .tmp/scripts/controllers.js 344 | msgid "Waterpoint successfully updated!" 345 | msgstr "Kituo cha maji kimesasishwa kwa mafanikio!" 346 | 347 | #: .tmp/scripts/natdashboardctrl.js 348 | msgid "functional" 349 | msgstr "vinavyofanya kazi" 350 | 351 | #: .tmp/scripts/natdashboardctrl.js 352 | msgid "needs repair" 353 | msgstr "vinavyohitaji matengenezo" 354 | 355 | #: .tmp/scripts/natdashboardctrl.js 356 | msgid "not functional" 357 | msgstr "visivyofanya kazi" 358 | 359 | #: app/views/dashboard.html 360 | msgid "out of" 361 | msgstr "kati ya" 362 | 363 | #: .tmp/scripts/natdashboardctrl.js 364 | msgid "population cover" 365 | msgstr "kiasi cha wanaohudumiwa" 366 | 367 | #: app/views/requests.html app/views/triage.html 368 | msgid "submitted on" 369 | msgstr "ilitumwa" 370 | 371 | #: app/views/dashboard.html 372 | msgid "waterpoints" 373 | msgstr "vituo vya maji" 374 | 375 | #~ msgid "Functionality by LGA" 376 | #~ msgstr "Ufanyaji kazi kwa Halmashauri" 377 | -------------------------------------------------------------------------------- /app/robots.txt: -------------------------------------------------------------------------------- 1 | # robotstxt.org 2 | 3 | User-agent: * 4 | -------------------------------------------------------------------------------- /app/scripts/app.coffee: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | app = angular 4 | .module('taarifaWaterpointsApp', [ 5 | 'ui.bootstrap' 6 | 'gridster', 7 | 'ngResource', 8 | 'ngRoute', 9 | 'ngCookies', 10 | 'dynform', 11 | 'angular-flash.service', 12 | 'angular-flash.flash-alert-directive', 13 | 'geolocation', 14 | 'gettext' 15 | ]) 16 | 17 | .config ($routeProvider, $httpProvider, flashProvider) -> 18 | $routeProvider 19 | .when '/', 20 | templateUrl: 'views/main.html' 21 | controller: 'MainCtrl' 22 | reloadOnSearch: false 23 | .when '/waterpoints/edit/:id', 24 | templateUrl: 'views/edit.html' 25 | controller: 'WaterpointEditCtrl' 26 | .when '/waterpoints/new', 27 | templateUrl: 'views/edit.html' 28 | controller: 'WaterpointCreateCtrl' 29 | .when '/requests', 30 | templateUrl: 'views/requests.html' 31 | controller: 'RequestListCtrl' 32 | .when '/requests/new', 33 | templateUrl: 'views/edit.html' 34 | controller: 'RequestCreateCtrl' 35 | .when '/requests/:id', 36 | templateUrl: 'views/triage.html' 37 | controller: 'RequestTriageCtrl' 38 | .when '/dashboard', 39 | templateUrl: 'views/dashboard.html' 40 | controller: 'DashboardCtrl' 41 | .otherwise 42 | redirectTo: '/' 43 | $httpProvider.defaults.headers.patch = 44 | 'Content-Type': 'application/json;charset=utf-8' 45 | flashProvider.errorClassnames.push 'alert-danger' 46 | 47 | .filter('titlecase', () -> 48 | return (s) -> 49 | return s.toString().toLowerCase().replace( /\b([a-z])/g, (ch) -> return ch.toUpperCase())) 50 | 51 | .run ($rootScope, flash) -> 52 | $rootScope.$on '$locationChangeSuccess', -> 53 | # Clear all flash messages on route change 54 | flash.info = '' 55 | flash.success = '' 56 | flash.warn = '' 57 | flash.error = '' 58 | -------------------------------------------------------------------------------- /app/scripts/controllers.coffee: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | angular.module('taarifaWaterpointsApp') 4 | 5 | .controller 'NavCtrl', ($scope, $location) -> 6 | $scope.location = $location 7 | 8 | .controller 'LocaleCtrl', ($scope, $cookies, $rootScope, gettextCatalog) -> 9 | # get the current language from the cookie if available 10 | $cookies.locale = 'en' unless !!$cookies.locale 11 | gettextCatalog.currentLanguage = $cookies.locale 12 | 13 | # Save the catalog on the root scope so others can access it 14 | # e.g., in event handler 15 | # FIXME: feels clunky, surprised you cant get at it from the event obj 16 | $rootScope.langCatalog = gettextCatalog 17 | 18 | $scope.languages = 19 | current: gettextCatalog.currentLanguage 20 | available: 21 | en: "English" 22 | sw_TZ: "Swahili" 23 | 24 | $scope.$watch "languages.current", (lang) -> 25 | if not lang then return 26 | # Update the cookie 27 | $cookies.locale = lang 28 | # Using the setter function ensures the gettextLanguageChanged event gets fired 29 | gettextCatalog.setCurrentLanguage(lang) 30 | 31 | .controller 'MainCtrl', ($scope, $http, $location, Waterpoint, Map, flash, gettext) -> 32 | map = Map "wpMap", showScale:true 33 | $scope.where = $location.search() 34 | $scope.where.max_results = parseInt($scope.where.max_results) || 100 35 | $scope.where.reports_only = parseInt($scope.where.reports_only) || 0 36 | $http.get('/api/waterpoints/values/region_name', cache: true).success (regions) -> 37 | $scope.regions = regions 38 | $http.get('/api/waterpoints/values/district_name', cache: true).success (districts) -> 39 | $scope.districts = districts 40 | 41 | $scope.resetParameters = -> 42 | $scope.where = 43 | max_results: 100 44 | reports_only: 0 45 | $http.get('/api/waterpoints/values/region_name', cache: true).success (regions) -> 46 | $scope.regions = regions 47 | $http.get('/api/waterpoints/values/district_name', cache: true).success (districts) -> 48 | $scope.districts = districts 49 | 50 | $scope.clearDistrict = -> 51 | $scope.where.district_name = null 52 | $location.search 'district_name', null 53 | 54 | $scope.updateMap = (nozoom) -> 55 | $location.search($scope.where) 56 | where = {} 57 | if $scope.where.region_name 58 | where.region_name = $scope.where.region_name 59 | # Filter districts based on selected Region 60 | $http.get('/api/waterpoints/values/district_name', params: {region_name: where.region_name}, cache: true).success (districts) -> 61 | $scope.districts = districts 62 | else 63 | $http.get('/api/waterpoints/values/district_name', cache: true).success (districts) -> 64 | $scope.districts = districts 65 | if $scope.where.district_name 66 | where.district_name = $scope.where.district_name 67 | if $scope.where.status_group 68 | where.status_group = $scope.where.status_group 69 | if $scope.where.reports_only 70 | $http.get('/api/waterpoints/requests').success (requests) -> 71 | where.wptcode = "$in": requests 72 | query where, $scope.where.max_results, nozoom 73 | else 74 | query where, $scope.where.max_results, nozoom 75 | 76 | $scope.reset = -> 77 | $scope.resetParameters() 78 | $scope.updateMap() 79 | 80 | query = (where, max_results, nozoom) -> 81 | map.clearMarkers() 82 | Waterpoint.query 83 | max_results: max_results 84 | where: where 85 | projection: 86 | _id: 1 87 | district_name: 1 88 | location: 1 89 | wptcode: 1 90 | status_group: 1 91 | strip: 1 92 | , (waterpoints) -> 93 | if waterpoints._items.length == 0 94 | flash.info = gettext('No waterpoints match your filter criteria!') 95 | return 96 | map.addWaterpoints(waterpoints._items) 97 | map.zoomToMarkers() unless nozoom 98 | $scope.updateMap() 99 | 100 | .controller 'DashboardCtrl', ($scope) -> 101 | $scope.dashTabs = 102 | national: 103 | active: true 104 | regional: 105 | active: false 106 | 107 | .controller 'ModalSpinnerCtrl', ($modal, $scope, msg, status) -> 108 | $scope.spinnerDialog = msg 109 | $scope.spinnerStatus = status 110 | 111 | .controller 'WaterpointCreateCtrl', ($scope, Waterpoint, FacilityForm, 112 | Map, flash, gettext, geolocation, modalSpinner) -> 113 | $scope.formTemplate = FacilityForm 'wpf001' 114 | # Default to today 115 | d = new Date() 116 | today = d.toGMTString() 117 | 118 | # FIXME: Should not hardcode the facility code here 119 | $scope.form = 120 | facility_code: "wpf001" 121 | date_recorded: today 122 | 123 | modalSpinner.open(" ", "Finding your location...") 124 | geolocation.getLocation().then (data) -> 125 | modalSpinner.close() 126 | flash.success = gettext("Geolocation succeeded: got coordinates") + " #{data.coords.longitude.toPrecision(4)}, #{data.coords.latitude.toPrecision(4)}" 127 | $scope.form.location = coordinates: [data.coords.longitude, data.coords.latitude] 128 | map = Map("editMap", {}) 129 | map.clearMarkers() 130 | map.addWaterpoints([$scope.form]) 131 | map.zoomToMarkers() 132 | , (reason) -> 133 | flash.error = gettext("Geolocation failed:") + " #{reason}" 134 | $scope.save = () -> 135 | Waterpoint.save $scope.form, (waterpoint) -> 136 | if waterpoint._status == 'OK' 137 | console.log "Successfully created waterpoint", waterpoint 138 | flash.success = gettext('Waterpoint successfully created!') 139 | if waterpoint._status == 'ERR' 140 | console.log gettext("Failed to create waterpoint"), waterpoint 141 | for field, message of waterpoint._issues 142 | flash.error = "#{field}: #{message}" 143 | 144 | .controller 'WaterpointEditCtrl', ($scope, $routeParams, 145 | Map, Waterpoint, FacilityForm) -> 146 | $scope.wp = Waterpoint 147 | 148 | map = Map("editMap", {}) 149 | 150 | Waterpoint.get id: $routeParams.id, (waterpoint) -> 151 | # We are editing a waterpoint so set the date_recorded 152 | # field to today, should it be saved. 153 | d = new Date() 154 | waterpoint.date_recorded = d.toGMTString() 155 | 156 | $scope.form = waterpoint 157 | map.clearMarkers() 158 | map.addWaterpoints([waterpoint]) 159 | map.zoomToMarkers() 160 | 161 | $scope.formTemplate = FacilityForm 'wpf001' 162 | $scope.save = () -> 163 | Waterpoint.update($routeParams.id, $scope.form) 164 | 165 | .controller 'RequestCreateCtrl', ($scope, $location, $routeParams, Request, gettext, 166 | $timeout, Waterpoint, Map, RequestForm, flash) -> 167 | map = Map("editMap") 168 | 169 | Waterpoint.get where: {wptcode: $routeParams.waterpoint_id}, (wp) -> 170 | map.clearMarkers() 171 | # FIXME: assumes wptcode is unique! 172 | if wp._items.length 173 | map.addWaterpoints([wp._items[0]]) 174 | map.zoomToMarkers() 175 | else 176 | $scope.disableSubmit = true 177 | flash.error = "Could not find Waterpoint with code #{$routeParams.waterpoint_id}" 178 | 179 | $scope.formTemplate = RequestForm 'wps001', $location.search() 180 | # FIXME: Should not hardcode the service code here 181 | $scope.form = {} 182 | $scope.save = () -> 183 | form = 184 | service_code: "wps001" 185 | attribute: $scope.form 186 | Request.save form, (request) -> 187 | if request._status == 'OK' 188 | console.log "Successfully created request", request 189 | flash.success = gettext('Request successfully created!') 190 | if request._status == 'ERR' 191 | console.log "Failed to create request", request 192 | for field, message of request._issues.attribute 193 | flash.error = "#{field}: #{message}" 194 | 195 | .controller 'RequestListCtrl', ($scope, $location, Request, flash, gettext) -> 196 | $scope.where = $location.search() 197 | $scope.filterStatus = () -> 198 | $location.search($scope.where) 199 | query = where: {} 200 | if $scope.where.status 201 | query.where.status = $scope.where.status 202 | if $scope.where.status_group 203 | query.where['attribute.status_group'] = $scope.where.status_group 204 | if $scope.where.waterpoint_id 205 | query.where['attribute.waterpoint_id'] = $scope.where.waterpoint_id 206 | Request.query query, (requests) -> 207 | $scope.requests = requests._items 208 | if $scope.requests.length == 0 209 | flash.info = gettext("No request matching the criteria!") 210 | $scope.filterStatus() 211 | 212 | .controller 'RequestTriageCtrl', ($scope, $routeParams, $filter, 213 | Request, Waterpoint, flash, gettext) -> 214 | $scope.apply = {} 215 | Request.get id: $routeParams.id, (request) -> 216 | if request.expected_datetime 217 | $scope.expected_datetime = new Date(request.expected_datetime) 218 | $scope.request = request 219 | Waterpoint.get where: {wptcode: request.attribute.waterpoint_id}, (waterpoint) -> 220 | $scope.waterpoint = waterpoint._items[0] 221 | if not request.agency_responsible 222 | request.agency_responsible = $scope.waterpoint.management 223 | $scope.saveRequest = () -> 224 | d = {} 225 | for key of $scope.apply 226 | d[key] = $scope.request.attribute[key] 227 | Waterpoint.patch($scope.waterpoint._id, d, $scope.waterpoint._etag) 228 | .success (data, status) -> 229 | if status == 200 and data._status == 'OK' 230 | flash.success = gettext('Waterpoint successfully updated!') 231 | $scope.waterpoint._etag = data._etag 232 | for key of $scope.apply 233 | $scope.waterpoint[key] = $scope.request.attribute[key] 234 | $scope.apply = {} 235 | if $scope.expected_datetime 236 | $scope.request.expected_datetime = $filter('date') $scope.expected_datetime, "EEE, dd MMM yyyy hh:mm:ss 'GMT'" 237 | Request.update($routeParams.id, $scope.request) 238 | if status == 200 and data._status == 'ERR' 239 | for field, message of data._issues 240 | flash.error = "#{field}: #{message}" 241 | -------------------------------------------------------------------------------- /app/scripts/mapctrl.coffee: -------------------------------------------------------------------------------- 1 | angular.module('taarifaWaterpointsApp') 2 | 3 | .controller 'DashMapCtrl', ($scope, $http, $q, $timeout, modalSpinner, waterpointStats, Waterpoint) -> 4 | 5 | $scope.hoverText = "" 6 | $scope.choroChoice = "percFun" 7 | 8 | # FIXME: "ward" and "region" should be defined elsewhere I think 9 | getFeaturedItem = (feature) -> 10 | res = {} 11 | props = feature.properties 12 | 13 | if props.hasOwnProperty "Region_Nam" 14 | res.type = "region" 15 | res.name = props.Region_Nam 16 | res.code = +props.Region_Cod 17 | else if props.hasOwnProperty "Ward_Name" 18 | res.type = "ward" 19 | res.name = props.Ward_Name 20 | res.code = +props.Ward_Code 21 | else if props.hasOwnProperty "District_N" 22 | res.type = "district" 23 | res.name = props.District_N 24 | res.code = +props.District_C 25 | else 26 | throw new Error("Unknown geo layer") 27 | 28 | # FIXME: looking up by name which not correct. Should really be by 29 | # code by this causes issues down the line as the population data 30 | # does not contain any codes 31 | res.item = $scope[res.type + "Map"][res.name.toLowerCase()] 32 | res 33 | 34 | initMap = (waterpoints, mapCenter) -> 35 | 36 | ###################### 37 | ### EVENT HANDLERS ### 38 | ###################### 39 | mouseOver = (e) -> 40 | it = getFeaturedItem(e.target.feature) 41 | 42 | if it.item 43 | hoverText = it.name + ": " + it.item[$scope.choroChoice].toPrecision(3) + "%" 44 | else 45 | hoverText = it.name + ": unknown" 46 | 47 | $scope.$apply (scope) -> 48 | scope.hoverText = hoverText 49 | 50 | layer = e.target 51 | layer.setStyle 52 | weight: 2 53 | color: '#666' 54 | fillOpacity: 0.8 55 | 56 | onClick = (e) -> 57 | it = getFeaturedItem(e.target.feature) 58 | if it.item 59 | $scope.drillDown(it.item[it.type + "_name"], it.type + "_name", true) 60 | 61 | ############## 62 | ### STYLES ### 63 | ############## 64 | colScale = d3.scale.linear() 65 | .domain([0,50,100]) 66 | .range(["red","orange","green"]) 67 | 68 | style = (feature) -> 69 | it = getFeaturedItem(feature) 70 | color = unless it.item then "gray" else colScale(it.item[$scope.choroChoice]) 71 | 72 | s = 73 | fillColor: color 74 | weight: 2 75 | opacity: 1 76 | color: 'white' 77 | dashArray: '3' 78 | fillOpacity: 0.65 79 | 80 | ############### 81 | ### HELPERS ### 82 | ############### 83 | getTopoJsonLayer = (url, featureName, doClick) -> 84 | $http.get(url, cache: true).then (response) -> 85 | features = topojson.feature(response.data, response.data.objects[featureName]).features 86 | geojson = L.geoJson(features, { 87 | style 88 | onEachFeature: (feature, layer) -> 89 | layer.on 90 | mouseover: mouseOver 91 | mouseout: (e) -> 92 | geojson.resetStyle(e.target) 93 | $scope.$apply (scope) -> 94 | scope.hoverText = "" 95 | click: onClick if doClick 96 | }) 97 | [features, geojson] 98 | 99 | makeLegend = (map) -> 100 | legend = L.control( 101 | position: 'bottomright' 102 | ) 103 | 104 | legend.onAdd = (map) -> 105 | div = L.DomUtil.create('div', 'legend') 106 | 107 | [0,25,50,75,100].forEach((x) -> 108 | div.innerHTML += ' ' + x + "%
" 109 | ) 110 | 111 | return div 112 | 113 | legend.addTo(map) 114 | 115 | ############## 116 | ### LAYERS ### 117 | ############## 118 | osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 119 | attribution: '(c) OpenStreetMap' 120 | ) 121 | 122 | satLayer = L.tileLayer('https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', 123 | attribution: '(c) Esri' 124 | ) 125 | 126 | categoryMap = 127 | "functional" : 0 128 | "not functional" : 1 129 | "needs repair" : 2 130 | 131 | clusterLayer = new PruneClusterForLeaflet() 132 | clusterLayer.Cluster.Size = 100 133 | 134 | waterpoints.forEach((x) -> 135 | coords = x.location.coordinates 136 | m = new PruneCluster.Marker(coords[1], coords[0]) 137 | m.category = categoryMap[x.status_group] 138 | clusterLayer.RegisterMarker(m)) 139 | 140 | ################ 141 | ### OVERLAYS ### 142 | ################ 143 | $q.all([ 144 | getTopoJsonLayer("data/tz_regions.topojson", "tz_regions", true) 145 | getTopoJsonLayer("data/tz_districts.topojson", "tz_districts", false) 146 | getTopoJsonLayer("data/tz_wards.topojson", "tz_wards", false) 147 | ]).then((data) -> 148 | 149 | [regions, regionLayer] = data[0] 150 | [districts, districtLayer]= data[1] 151 | [wards, wardLayer] = data[2] 152 | 153 | ################ 154 | ### MAKE MAP ### 155 | ################ 156 | baseMaps = 157 | "Open Street Map": osmLayer 158 | "Satellite": satLayer 159 | 160 | overlayControls = 161 | "Regions": regionLayer 162 | "Districts": districtLayer 163 | "Wards": wardLayer 164 | 165 | map = L.map('nationalDashMap', 166 | center: mapCenter 167 | zoom: 5 168 | fullscreenControl: true 169 | layers: [satLayer, regionLayer, clusterLayer] 170 | ) 171 | makeLegend(map) 172 | 173 | # Add a layer selector 174 | layerSelector = L.control.layers(baseMaps, overlayControls).addTo(map) 175 | 176 | # Start watching 177 | $scope.$watch('choroChoice', (val) -> 178 | return unless val 179 | 180 | layers = [regionLayer, districtLayer, wardLayer] 181 | 182 | layers.forEach (l) -> 183 | l.setStyle(style) 184 | if map.hasLayer(l) 185 | map.removeLayer(l) 186 | map.addLayer(l) 187 | 188 | regionLayer.setStyle(style) 189 | districtLayer.setStyle(style) 190 | wardLayer.setStyle(style) 191 | ) 192 | 193 | $scope.$watch('params.region', (val) -> 194 | # find the matching geojson feature and refocus the map 195 | return unless val 196 | 197 | # only 26 regions so a simple linear search is ok 198 | for f in regions 199 | it = getFeaturedItem(f) 200 | 201 | if it.name.toLowerCase() == val.toLowerCase() 202 | # Note: only assumes two different nesting levels 203 | if f.geometry.coordinates[0][0].length == 2 204 | numToUnpack = 1 205 | else 206 | numToUnpack = 2 207 | points = L.GeoJSON.coordsToLatLngs(f.geometry.coordinates, numToUnpack) 208 | # instantiate as multipolygon to get the bounds 209 | bounds = L.multiPolygon(points).getBounds() 210 | map.fitBounds(bounds) 211 | return 212 | ) 213 | # FIXME: find a better solution than this "magic number" for timeout 214 | $timeout -> 215 | map.invalidateSize() 216 | , 2000 217 | return map 218 | ) 219 | 220 | modalSpinner.open() 221 | 222 | # Get the boundaries and layers 223 | $q.all([ 224 | # FIXME: assumes unique names which is not the case 225 | waterpointStats.getStats(null, null, null, "region_name", true) 226 | waterpointStats.getStats(null, null, null, "district_name", true) 227 | waterpointStats.getStats(null, null, null, "ward_name", true) 228 | ]).then((data) -> 229 | # Add the regions and wards to the template scope 230 | addToScope = (stats, name) -> 231 | tmp = _.pluck(stats, name + "_name").map((x) -> x.toLowerCase()) 232 | $scope[name + "Map"] = _.object(tmp, stats) 233 | 234 | # data contains stats for region and ward 235 | addToScope(data[0], "region") 236 | addToScope(data[1], "district") 237 | addToScope(data[2], "ward") 238 | 239 | # Initialise the map 240 | initMap([], new L.LatLng(-6.3153, 35.15625)) 241 | modalSpinner.close() 242 | ) 243 | -------------------------------------------------------------------------------- /app/scripts/natdashboardctrl.coffee: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | angular.module('taarifaWaterpointsApp') 4 | 5 | .controller 'NationalDashboardCtrl', ($scope, $http, $timeout, modalSpinner, 6 | gettextCatalog, gettext, populationData, waterpointStats) -> 7 | 8 | # should http calls be cached 9 | # FIXME: should be application level setting 10 | cacheHttp = false 11 | 12 | # a flag to keep track if the plots should be redrawn 13 | # next time the tab is made visible 14 | plotsDirty = false 15 | 16 | $scope.gridsterOpts = { 17 | margins: [10, 10], 18 | columns: 12, 19 | floating: true, 20 | pushing: true, 21 | draggable: { 22 | enabled: true 23 | }, 24 | resizable: { 25 | enabled: true, 26 | stop: (event, uiWidget, $el) -> 27 | isplot = jQuery($el.children()[0]).hasClass("plot") 28 | if isplot then drawPlots() 29 | } 30 | } 31 | 32 | $scope.gridLayout = { 33 | tiles: [ 34 | { sizeX: 2, sizeY: 2, row: 0, col: 0 }, 35 | { sizeX: 2, sizeY: 2, row: 0, col: 2 }, 36 | { sizeX: 2, sizeY: 2, row: 0, col: 4 }, 37 | ], 38 | map: 39 | { sizeX: 6, sizeY: 6, row: 0, col: 6 } 40 | problems: 41 | { sizeX: 6, sizeY: 4, row: 2, col: 0 } 42 | plots: [ 43 | { sizeX: 12, sizeY: 5, row: 6, col: 0 }, 44 | { sizeX: 6, sizeY: 5, row: 11, col: 0 } 45 | { sizeX: 6, sizeY: 5, row: 11, col: 6 } 46 | ] 47 | } 48 | 49 | 50 | $scope.plots = [ 51 | {id:"statusSummary", title: gettext("Waterpoint status (ordered by % Functional)")}, 52 | {id:"percFunLeaders", title: gettext("Performance Table: % Functional")}, 53 | {id:"popReach", title: gettext("Performance Table: % Served")}] 54 | 55 | $scope.groups = ['region_name', 'district_name', 56 | 'ward_name', 'source_group', 57 | 'construction_year', 'quantity_group', 58 | 'quality_group', 'extraction_group', 59 | 'breakdown_year', 'payment_group', 'funder', 60 | 'installer', 'management', 'hardware_problem'] 61 | 62 | # default group by to region 63 | $scope.params = 64 | group: $scope.groups[0] 65 | 66 | getRegion = () -> 67 | $http.get('/api/waterpoints/values/region_name', cache: cacheHttp) 68 | .success (data, status, headers, config) -> 69 | $scope.regions = data.sort() 70 | 71 | getWard = () -> 72 | modalSpinner.open() 73 | $http.get('/api/waterpoints/values/ward_name', 74 | cache: cacheHttp 75 | params: 76 | region_name: $scope.params?.region 77 | district_name: $scope.params?.district) 78 | .success (data, status, headers, config) -> 79 | $scope.wards = data.sort() 80 | modalSpinner.close() 81 | 82 | # get the top 5 hardware problems 83 | getProblems = () -> 84 | modalSpinner.open() 85 | $http.get('/api/waterpoints/stats_by/hardware_problem', 86 | cache: cacheHttp 87 | params: 88 | region_name: $scope.params?.region 89 | district_name: $scope.params?.district 90 | ward_name: $scope.params?.ward) 91 | .success (data, status, headers, config) -> 92 | $scope.problems = data.sort((a,b) -> 93 | return b.count - a.count 94 | ) 95 | $scope.problems = $scope.problems.filter((x) -> 96 | x.hardware_problem != 'none').slice(0,5) 97 | modalSpinner.close() 98 | 99 | lookupSelectedPop = () -> 100 | popData.lookup( 101 | $scope.params.region 102 | $scope.params.district 103 | $scope.params.ward) 104 | 105 | $scope.getStatus = () -> 106 | $scope.statusChoice = "all" 107 | 108 | modalSpinner.open() 109 | 110 | $http.get('/api/waterpoints/stats_by/status_group' 111 | cache: cacheHttp 112 | params: 113 | region_name: $scope.params.region 114 | district_name: $scope.params.district 115 | ward_name: $scope.params.ward) 116 | .success (data, status, headers, config) -> 117 | total = d3.sum(data, (x) -> x.count) 118 | data.forEach( (x) -> x.percent = x.count / total * 100) 119 | 120 | # index by status_group for convenience 121 | statusMap = _.object(_.pluck(data,"status_group"), data) 122 | $scope.status = statusMap 123 | 124 | # ensure all three statusses are always represented 125 | empty = {count: 0, percent: 0} 126 | statusses = [gettext("functional"), gettext("not functional"), gettext("needs repair")] 127 | statusses.forEach((x) -> statusMap[x] = statusMap[x] || empty) 128 | 129 | # the population covered 130 | if statusMap.functional.waterpoints 131 | funPop = statusMap.functional.waterpoints[0].population 132 | else 133 | # will happen for an invalid selection 134 | funPop = 0 135 | 136 | pop = lookupSelectedPop() 137 | percent = if pop > 0 then funPop/pop*100 else "unknown" 138 | 139 | popCover = {count: funPop, percent: percent} 140 | 141 | $scope.tiles = _.pairs(_.pick(statusMap,'functional','needs repair')) 142 | $scope.tiles.push([gettext('population cover'), popCover]) 143 | modalSpinner.close() 144 | 145 | getProblems() 146 | drawPlots() 147 | 148 | $scope.groupBy = () -> 149 | # the grouping field has changed, reset the selected status 150 | $scope.statusChoice = "all" 151 | drawPlots() 152 | 153 | $scope.drillDown = (fieldVal, fieldType, clearFilters) -> 154 | groupField = fieldType || $scope.params.group 155 | geoField = _.contains(['region_name','district_name','ward_name'], groupField) 156 | 157 | if !geoField then return 158 | 159 | gforder = 160 | region_name: "district_name" 161 | district_name: "ward_name" 162 | ward_name: "region_name" 163 | 164 | # Using timeout of zero instead of $scope.apply() in order to avoid 165 | # this error: https://docs.angularjs.org/error/$rootScope/inprog?p0=$apply 166 | # This happens, for example, when drillDown is called from the geojson feature 167 | # click handler 168 | # FIXME: a workaround, better solution? 169 | $timeout(() -> 170 | if !$scope.params then $scope.params = {} 171 | 172 | newgf = gforder[groupField] 173 | $scope.params.group = newgf 174 | 175 | if clearFilters || newgf == "region_name" 176 | $scope.params.region = null 177 | $scope.params.district = null 178 | $scope.params.ward = null 179 | 180 | if newgf == "region_name" 181 | $scope.getStatus() 182 | else 183 | # Note: groupField can be region_name or ward_name but 184 | # in the params object they are just called region or ward 185 | $scope.params[groupField.split("_")[0]] = fieldVal 186 | $scope.getStatus() 187 | ,0) 188 | 189 | barDblClick = (d) -> 190 | groupField = $scope.params.group 191 | $scope.drillDown(d[groupField]) 192 | 193 | $scope.statusChoice = "all" 194 | $scope.statusColor = statusColor 195 | $scope.statusses = statusColor.domain().concat(["all"]) 196 | 197 | # FIXME: for some reason this watch never gets triggered beyond first load... 198 | # resorting to ugly click event workaround 199 | # Note: using ngChange only paritally solves this. If you click 200 | # between radio buttons too quickly it stops working all together 201 | #$scope.$watch "statusChoice", (oldval, newval) -> 202 | # console.log(oldval + "->" + newval) 203 | 204 | $scope.selectStatusClicked = (event) -> 205 | status = event.target.attributes.value.value 206 | $scope.statusChoice = status 207 | 208 | translate = (x) -> gettextCatalog.getString(x) 209 | region = $scope.params?.region 210 | district = $scope.params?.district 211 | ward = $scope.params?.ward 212 | groupfield = $scope.params?.group || "region_name" 213 | 214 | plotStatusSummary("#statusSummary", $scope.statusSumData, groupfield, 215 | barDblClick, translate, status) 216 | 217 | drawPlots = () -> 218 | modalSpinner.open() 219 | 220 | translate = (s) -> gettextCatalog.getString(s) 221 | 222 | region = $scope.params?.region 223 | district = $scope.params?.district 224 | ward = $scope.params?.ward 225 | groupfield = $scope.params?.group || "region_name" 226 | status = $scope.statusChoice 227 | 228 | promise = waterpointStats.getStats(region, district, ward, groupfield, cacheHttp) 229 | promise.then( (data) -> 230 | 231 | # save a reference to the data so we have it when the selected status is changed 232 | $scope.statusSumData = data 233 | 234 | plotStatusSummary("#statusSummary", data, groupfield, barDblClick, translate, status) 235 | 236 | if _.contains(['region_name','district_name','ward_name'], groupfield) 237 | leaderChart("#percFunLeaders", data, groupfield, (x) -> x.percFun) 238 | 239 | data = _.sortBy(data, (x) -> -x.popReach) 240 | leaderChart("#popReach", data, groupfield, (x) -> x.popReach) 241 | 242 | plotsDirty = false 243 | modalSpinner.close()) 244 | 245 | $scope.$on "gettextLanguageChanged", (e) -> 246 | # redraw the plots so axis labels, etc are translated 247 | 248 | # will only work if the tab is visible (else d3 fails) 249 | if $scope.dashTabs.national.active 250 | drawPlots() 251 | else 252 | # we have to remember to redraw the plots when the tab 253 | # finally does become active 254 | plotsDirty = true 255 | 256 | $scope.$watch "dashTabs.national.active", (val) -> 257 | if val and plotsDirty 258 | drawPlots() 259 | 260 | # access object to the population data 261 | # FIXME: better handled by a $resource perhaps? 262 | popData = null 263 | 264 | initView = () -> 265 | populationData.then((data) -> 266 | popData = data 267 | $scope.getStatus()) 268 | 269 | getProblems() 270 | 271 | initView() 272 | -------------------------------------------------------------------------------- /app/scripts/plots.js: -------------------------------------------------------------------------------- 1 | //to prevent creating overcrowded plots 2 | var minColWidth = 20; 3 | //FIXME: hardcoded list of possible status fields 4 | var statusColor = d3.scale.ordinal() 5 | .domain(["functional","needs repair", "not functional"]) 6 | .range(["#0a871f","orange","#d50000"]); 7 | 8 | function isFunctional(s) { 9 | return s.status == "functional"; 10 | } 11 | 12 | function getDimensions(selector, wMargin, hMargin){ 13 | // Compensate for well margins (20px) 14 | var pn = d3.select(selector).node().parentNode; 15 | 16 | //var h = d3.select(selector).style('height').replace('px', '') - 40; 17 | //var w = d3.select(selector).style('width').replace('px', '') - 40; 18 | var h = d3.select(pn).style('height').replace('px', '') - (hMargin || 60); 19 | var w = d3.select(pn).style('width').replace('px', '') - (wMargin || 40); 20 | 21 | return {h: h, w: w}; 22 | } 23 | 24 | function createTip(getter) { 25 | var tip = d3.tip().style("z-index",100).attr('class', 'd3-tip').html(getter); 26 | return tip; 27 | } 28 | 29 | function closeOpenTips() { 30 | $('.d3-tip').filter(function(){ 31 | var $this = $(this); 32 | return $this.css('opacity') == 1; 33 | }).hide(); 34 | } 35 | 36 | /* 37 | * Stacked bar chart summarizing the status (functional/non functional) 38 | * of all the waterpoints by the given group field 39 | */ 40 | function plotStatusSummary(selector, data, groupField, dblClickHandler, translate, selectedStatus) { 41 | //rename as gettext so string extraction will work 42 | gettext = translate; 43 | 44 | data.forEach(function(group) { 45 | var y0 = 0; 46 | //status type is not always in the same order due to mongo, sort here 47 | group.waterpoints = _.sortBy(group.waterpoints, "status"); 48 | group.waterpoints.forEach(function(x) { 49 | //only calculate the rectangle offsets for the requested status 50 | if(selectedStatus == "all" || x.status == selectedStatus) { 51 | x.y0 = y0; 52 | x.y1 = (y0 += x.count); 53 | } 54 | }); 55 | }); 56 | //data.sort(function(a, b) { return b.count - a.count; }); 57 | 58 | var dims = getDimensions(selector); 59 | var h = dims.h, w = dims.w; 60 | 61 | var margin = { 62 | top: 10, 63 | right: 20, 64 | bottom: 110, 65 | left: 70 66 | }, 67 | width = w - margin.left - margin.right, 68 | height = h - margin.top - margin.bottom; 69 | 70 | //to prevent creating overcrowded plots 71 | data = data.slice(0,Math.floor(width/minColWidth)); 72 | 73 | var x = d3.scale.ordinal() 74 | .rangeRoundBands([0, width], .1); 75 | 76 | var y = d3.scale.linear() 77 | .rangeRound([height, 0]); 78 | 79 | x.domain(_.pluck(data, groupField)); 80 | y.domain([0, d3.max(data, function(d) { 81 | return d.count; 82 | })]); 83 | 84 | var xAxis = d3.svg.axis() 85 | .scale(x) 86 | .orient("bottom") 87 | .tickFormat(function(d){return shorten(d); }); 88 | 89 | var yAxis = d3.svg.axis() 90 | .scale(y) 91 | .orient("left"); 92 | 93 | //create the svg if it does not already exist 94 | var svg = d3.select(selector + " svg"); 95 | 96 | if (!svg[0][0]) { 97 | 98 | svg = d3.select(selector).append("svg") 99 | .attr("width", width + margin.left + margin.right) 100 | .attr("height", height + margin.top + margin.bottom) 101 | //transform within the margins 102 | .append("g") 103 | .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); 104 | 105 | svg.append("g") 106 | .attr("class", "x axis") 107 | .attr("transform", "translate(0," + height + ")"); 108 | 109 | svg.append("g") 110 | .attr("class", "y axis") 111 | .call(yAxis) 112 | .append("text") 113 | .attr("class", "axislabel") 114 | .attr("transform", "rotate(-90)") 115 | .attr("y", -70) 116 | .attr("dy", ".71em") 117 | .style("text-anchor", "end") 118 | .text(""); 119 | } else { 120 | //Note width/height may have changed 121 | svg.attr("width", width + margin.left + margin.right) 122 | .attr("height", height + margin.top + margin.bottom); 123 | svg = svg.select('g'); 124 | } 125 | 126 | var tip = createTip(function(d) { 127 | s = d[groupField]; 128 | 129 | d.waterpoints.map(function(x){return _.pick(x,["status","count"]);}) 130 | .forEach(function(x){ 131 | s += '
' + x.status + ': ' + x.count + '';}) 132 | 133 | return s 134 | }); 135 | 136 | svg.call(tip); 137 | 138 | var filterSelected = function(waterpoints) { 139 | var wp = waterpoints.filter(function(x){ 140 | if(selectedStatus == "all"){ 141 | return true; 142 | }else{ 143 | return x.status == selectedStatus; 144 | } 145 | }); 146 | 147 | return wp; 148 | } 149 | 150 | //bind the data to a group 151 | var state = svg.selectAll(".group") 152 | .data(data, function(d) { 153 | return d[groupField] + "_" + selectedStatus; 154 | //return groupField + "_" + d[groupField] + "_" + d.count; 155 | }); 156 | 157 | //bind to each rect within the group 158 | var rects = state.selectAll("rect") 159 | .data(function(d) { 160 | //only keep the waterpoint groups with the status we want 161 | return filterSelected(d.waterpoints); 162 | }, function(d) { 163 | return d.status; 164 | //return d.status + "_" + d.count; 165 | }); 166 | 167 | //new groups 168 | var statesEnter = state.enter() 169 | .append("g") 170 | .attr("class", "group") 171 | .attr("transform", function(d) { 172 | return "translate(" + x(d[groupField]) + ",0)"; 173 | }) 174 | .on('dblclick', function(d,i){ 175 | tip.hide(d,i); 176 | dblClickHandler(d); 177 | }) 178 | .on('mouseover', tip.show) 179 | .on('mouseout', tip.hide); 180 | 181 | //new rects in new groups 182 | var rectsEnter = statesEnter.selectAll("rect") 183 | .data(function(d) { 184 | //only keep the waterpoint groups with the status we want 185 | return filterSelected(d.waterpoints); 186 | }, function(d) { 187 | return d.status; 188 | //return d.status + "_" + d.count; 189 | }) 190 | 191 | //update existing groups 192 | state.attr("transform", function(d) { 193 | return "translate(" + x(d[groupField]) + ",0)"; 194 | }); 195 | 196 | //update existing rects 197 | rects 198 | .style("fill", function(d) { 199 | return statusColor(d.status); 200 | }) 201 | .transition() 202 | .duration(1000) 203 | .attr("width", x.rangeBand()) 204 | .attr("y", function(d) { 205 | return y(d.y1); 206 | }) 207 | .attr("height", function(d) { 208 | return y(d.y0) - y(d.y1); 209 | }); 210 | 211 | //remove old rects 212 | rects.exit() 213 | .transition() 214 | .duration(1000) 215 | .attr("y", y(0)) 216 | .attr("height", 0) 217 | .call(tip.hide); 218 | // .remove(); 219 | 220 | //remove old groups 221 | state.exit() 222 | .on('mouseover', null) 223 | .on('mouseout', null) 224 | .transition() 225 | .duration(1000) 226 | .style("opacity", 0) 227 | .remove(); 228 | 229 | //add new rects 230 | rectsEnter.enter().append("rect") 231 | .attr("width", x.rangeBand()) 232 | .style("fill", function(d) { 233 | return statusColor(d.status); 234 | }) 235 | .attr("y", y(0)) 236 | .attr("height", 0) 237 | .transition() 238 | .duration(1000) 239 | .attr("y", function(d) { 240 | return y(d.y1); 241 | }) 242 | .attr("height", function(d) { 243 | return y(d.y0) - y(d.y1); 244 | }); 245 | 246 | //Update the axes 247 | svg.select("g.x.axis").transition().duration(1000).call(xAxis) 248 | .attr("transform", "translate(0," + height + ")") 249 | .selectAll("text") 250 | .style("text-anchor", "end") 251 | .attr("dx", "-.8em") 252 | .attr("dy", ".15em") 253 | .attr("transform", function(d) { 254 | return "rotate(-65)" 255 | }); 256 | 257 | svg.select("g.y.axis text.axislabel").text(gettext("Number of Waterpoints")); 258 | svg.select("g.y.axis").transition().call(yAxis); 259 | } 260 | 261 | function leaderChart(selector, data, groupField, getter) { 262 | 263 | var dims = getDimensions(selector); 264 | var h = dims.h, w = dims.w; 265 | 266 | var margin = { 267 | top: 10, 268 | right: 20, 269 | bottom: 20, 270 | left: 20 271 | }, 272 | width = w - margin.left - margin.right, 273 | height = h - margin.top - margin.bottom; 274 | 275 | //to prevent creating overcrowded plots 276 | data = data.slice(0,Math.floor(height/minColWidth)); 277 | 278 | var x = d3.scale.linear() 279 | .domain([0, 100]) 280 | .rangeRound([0, width]); 281 | 282 | var y = d3.scale.ordinal() 283 | .domain(_.pluck(data, groupField)) 284 | .rangeRoundBands([0,height], .1); 285 | 286 | var xAxis = d3.svg.axis() 287 | .scale(x) 288 | .orient("bottom"); 289 | 290 | var yAxis = d3.svg.axis() 291 | .scale(y) 292 | .orient("left") 293 | .tickFormat(""); 294 | 295 | var color = d3.scale.category20(); 296 | 297 | svg = d3.select(selector + " svg"); 298 | if (!svg[0][0]) { 299 | svg = d3.select(selector).append("svg") 300 | .attr("width", width + margin.left + margin.right) 301 | .attr("height", height + margin.top + margin.bottom) 302 | //transform within the margins 303 | .append("g") 304 | .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); 305 | 306 | svg.append("g") 307 | .attr("class", "x axis") 308 | .attr("transform", "translate(0," + height + ")"); 309 | 310 | svg.append("g") 311 | .attr("class", "y axis") 312 | .call(yAxis) 313 | .append("text") 314 | .attr("transform", "rotate(-90)") 315 | .attr("y", -70) 316 | .attr("dy", ".71em") 317 | .style("text-anchor", "end"); 318 | } else { 319 | //Note width/height may have changed 320 | svg.attr("width", width + margin.left + margin.right) 321 | .attr("height", height + margin.top + margin.bottom); 322 | svg = svg.select('g'); 323 | } 324 | 325 | var tip = createTip(function(d) { 326 | s = d[groupField] + ": " + getter(d).toPrecision(4) + " %"; 327 | return s; 328 | }); 329 | 330 | svg.call(tip); 331 | 332 | var rects = svg.selectAll("rect") 333 | .data(data, function(d) { 334 | return d[groupField]; 335 | }); 336 | 337 | var labels = svg.selectAll(".hor-bar-label") 338 | .data(data, function(d) { 339 | return d[groupField]; 340 | }); 341 | 342 | rects 343 | .transition() 344 | .duration(1000) 345 | .attr("height", y.rangeBand()) 346 | .attr("y", function(d) { 347 | return y(d[groupField]); 348 | }) 349 | .attr("x", function(d) { 350 | return x(0); 351 | }) 352 | .attr("width", function(d) { 353 | return x(getter(d)); 354 | }); 355 | 356 | labels 357 | .transition() 358 | .duration(1000) 359 | .attr("y", function(d) { 360 | return y(d[groupField]) + y.rangeBand(d)/2; 361 | }) 362 | .attr("x", function(d) { 363 | return x(0) + 5; 364 | }) 365 | .text(function(d){ 366 | return d[groupField]; 367 | }); 368 | 369 | rects.enter() 370 | .append("rect") 371 | .attr("class","hor-bar") 372 | .attr("height", y.rangeBand()) 373 | .attr("y", function(d) { 374 | return y(d[groupField]); 375 | }) 376 | .attr("x", x(0)) 377 | .attr("width", 0) 378 | .on('mouseover', tip.show) 379 | .on('mouseout', tip.hide) 380 | .transition() 381 | .duration(1000) 382 | .attr("width", function(d) { 383 | return x(getter(d)); 384 | }); 385 | 386 | labels.enter() 387 | .append("text") 388 | .attr("class","hor-bar-label") 389 | .text(function(d){ 390 | return d[groupField]; 391 | }) 392 | .style("opacity", 0) 393 | .attr("y", function(d) { 394 | return y(d[groupField]) + y.rangeBand(d)/2; 395 | }) 396 | .attr("dy", ".36em") 397 | .attr("x", function(d) { 398 | return x(0) + 5; 399 | }) 400 | .transition() 401 | .duration(1000) 402 | .style("opacity", 1); 403 | 404 | rects.exit() 405 | .transition() 406 | .duration(1000) 407 | .attr("width",0) 408 | .style("opacity", 0) 409 | .remove(); 410 | 411 | labels.exit() 412 | .transition() 413 | .duration(1000) 414 | .style("opacity", 0) 415 | .remove(); 416 | 417 | //Update the axes 418 | svg.select("g.x.axis").transition().duration(1000).call(xAxis) 419 | .attr("transform", "translate(0," + height + ")"); 420 | 421 | svg.select("g.y.axis").transition().call(yAxis); 422 | 423 | } 424 | 425 | function plotSpendSummary(selector, data, groupField) { 426 | 427 | //TODO: need real data 428 | data.forEach(function(x) { 429 | x.spend = 10+(Math.random() * 10000 / x.count); 430 | }); 431 | 432 | //data.sort(function(a, b) { return a.spend - b.spend; }); 433 | 434 | var dims = getDimensions(selector); 435 | var h = dims.h, w = dims.w; 436 | 437 | var margin = { 438 | top: 20, 439 | right: 20, 440 | bottom: 90, 441 | left: 70 442 | }, 443 | width = w - margin.left - margin.right, 444 | height = h - margin.top - margin.bottom; 445 | 446 | //to prevent creating overcrowded plots 447 | data = data.slice(0,Math.floor(width/minColWidth)); 448 | 449 | var x = d3.scale.ordinal() 450 | .rangeRoundBands([0, width], .1); 451 | 452 | var y = d3.scale.linear() 453 | .rangeRound([height, 0]); 454 | 455 | x.domain(_.pluck(data, groupField)); 456 | y.domain([0, d3.max(data, function(d) { 457 | return d.spend; 458 | })]); 459 | 460 | var xAxis = d3.svg.axis() 461 | .scale(x) 462 | .orient("bottom") 463 | .tickFormat(function(d){return shorten(d); }); 464 | 465 | var yAxis = d3.svg.axis() 466 | .scale(y) 467 | .orient("left"); 468 | 469 | var color = d3.scale.category20(); 470 | 471 | svg = d3.select(selector + " svg"); 472 | if (!svg[0][0]) { 473 | svg = d3.select(selector).append("svg") 474 | .attr("width", width + margin.left + margin.right) 475 | .attr("height", height + margin.top + margin.bottom) 476 | //transform within the margins 477 | .append("g") 478 | .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); 479 | 480 | svg.append("g") 481 | .attr("class", "x axis") 482 | .attr("transform", "translate(0," + height + ")"); 483 | 484 | svg.append("g") 485 | .attr("class", "y axis") 486 | .call(yAxis) 487 | .append("text") 488 | .attr("transform", "rotate(-90)") 489 | .attr("y", -70) 490 | .attr("dy", ".71em") 491 | .style("text-anchor", "end") 492 | .text("Spend per Waterpoint ($)"); 493 | } else { 494 | //Note width/height may have changed 495 | svg.attr("width", width + margin.left + margin.right) 496 | .attr("height", height + margin.top + margin.bottom); 497 | svg = svg.select('g'); 498 | } 499 | 500 | var tip = d3.tip().attr('class', 'd3-tip').html(function(d) { 501 | return d[groupField]; 502 | }); 503 | 504 | svg.call(tip); 505 | 506 | var rects = svg.selectAll("rect") 507 | .data(data, function(d) { 508 | return d[groupField]; 509 | }); 510 | 511 | rects 512 | .style("fill", function(d) { 513 | return color(d[groupField]); 514 | }) 515 | .transition() 516 | .duration(1000) 517 | .attr("width", x.rangeBand()) 518 | .attr("x", function(d) { 519 | return x(d[groupField]); 520 | }) 521 | .attr("y", function(d) { 522 | return y(d.spend); 523 | }) 524 | .attr("height", function(d) { 525 | return height - y(d.spend); 526 | }); 527 | 528 | rects.enter() 529 | .append("rect") 530 | .style("fill", function(d) { 531 | return color(d[groupField]); 532 | }) 533 | .attr("width", x.rangeBand()) 534 | .attr("x", function(d) { 535 | return x(d[groupField]); 536 | }) 537 | .attr("y", y(0)) 538 | .attr("height", 0) 539 | .on('mouseover', tip.show) 540 | .on('mouseout', tip.hide) 541 | .transition() 542 | .duration(1000) 543 | .attr("y", function(d) { 544 | return y(d.spend); 545 | }) 546 | .attr("height", function(d) { 547 | return height - y(d.spend); 548 | }); 549 | 550 | rects.exit() 551 | .transition() 552 | .duration(1000) 553 | .attr("y",y(0)) 554 | .attr("height",0) 555 | .style("opacity", 0) 556 | .remove(); 557 | 558 | //Update the axes 559 | svg.select("g.x.axis").transition().duration(1000).call(xAxis) 560 | .attr("transform", "translate(0," + height + ")") 561 | .selectAll("text") 562 | .style("text-anchor", "end") 563 | .attr("dx", "-.8em") 564 | .attr("dy", ".15em") 565 | .attr("transform", function(d) { 566 | return "rotate(-65)" 567 | }); 568 | 569 | svg.select("g.y.axis").transition().call(yAxis); 570 | } 571 | 572 | function plotSpendImpact(selector, wpdata, groupField) { 573 | 574 | //TODO: more made up data 575 | data = []; 576 | wpdata.forEach(function(x) { 577 | var functional = _.find(x.waterpoints, isFunctional); 578 | var d = { 579 | functional: functional.count / x.count * 100, 580 | pop_served: d3.sum(_.pluck(x.waterpoints, "pop_served")), 581 | spend: 10 + (Math.random() * 10000 / x.count) 582 | }; 583 | d[groupField] = x[groupField]; 584 | data.push(d); 585 | }); 586 | 587 | var dims = getDimensions(selector); 588 | var h = dims.h, w = dims.w; 589 | 590 | var margin = { 591 | top: 20, 592 | right: 20, 593 | bottom: 20, 594 | left: 40 595 | }, 596 | width = w - margin.left - margin.right, 597 | height = h - margin.top - margin.bottom; 598 | 599 | var x = d3.scale.linear() 600 | .range([0, width]) 601 | .domain(d3.extent(_.pluck(data, "spend"))); 602 | 603 | var y = d3.scale.linear() 604 | .range([height, 0]) 605 | .domain(d3.extent(_.pluck(data, "functional"))); 606 | 607 | var popScale = d3.scale.sqrt() 608 | .range([5, 15]) 609 | .domain(d3.extent(_.pluck(data, "pop_served"))); 610 | 611 | var xAxis = d3.svg.axis() 612 | .scale(x) 613 | .orient("bottom") 614 | .tickFormat(function(d){return shorten(d); }); 615 | 616 | var yAxis = d3.svg.axis() 617 | .scale(y) 618 | .orient("left"); 619 | 620 | var color = d3.scale.category20(); 621 | 622 | svg = d3.select(selector + " svg"); 623 | if (!svg[0][0]) { 624 | svg = d3.select(selector).append("svg") 625 | .attr("width", width + margin.left + margin.right) 626 | .attr("height", height + margin.top + margin.bottom) 627 | //transform within the margins 628 | .append("g") 629 | .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); 630 | 631 | svg.append("g") 632 | .attr("class", "x axis") 633 | .attr("transform", "translate(0," + height + ")") 634 | .call(xAxis) 635 | .append("text") 636 | .attr("class", "label") 637 | .attr("x", width) 638 | .attr("y", -6) 639 | .style("text-anchor", "end") 640 | .text("Spend per Waterpoint ($)"); 641 | 642 | svg.append("g") 643 | .attr("class", "y axis") 644 | .call(yAxis) 645 | .append("text") 646 | .attr("class", "label") 647 | .attr("transform", "rotate(-90)") 648 | .attr("y", -40) 649 | .attr("dy", ".71em") 650 | .style("text-anchor", "end") 651 | .text("% Functional"); 652 | } else { 653 | //Note width/height may have changed 654 | svg.attr("width", width + margin.left + margin.right) 655 | .attr("height", height + margin.top + margin.bottom); 656 | svg = svg.select('g'); 657 | } 658 | 659 | 660 | //TODO: use d3.tip 661 | //TODO: gets added each time 662 | var tooltip = d3.select("body").append("div") 663 | .attr("class", "tooltip") 664 | .style("opacity", 0); 665 | 666 | var dots = svg.selectAll(".dot") 667 | .data(data, function(d) { 668 | return d[groupField] 669 | }); 670 | 671 | 672 | dots 673 | .transition() 674 | .duration(1000) 675 | .attr("cx", function(d) { 676 | return x(d.spend); 677 | }) 678 | .attr("cy", function(d) { 679 | return y(d.functional); 680 | }) 681 | .attr("r", function(d) { 682 | return popScale(d.pop_served); 683 | }); 684 | 685 | 686 | dots.enter() 687 | .append("circle") 688 | .attr("class", "dot") 689 | .style("stroke-width","0") 690 | .attr("cx", function(d) { 691 | return x(d.spend); 692 | }) 693 | .attr("cy", function(d) { 694 | return y(d.functional); 695 | }) 696 | .style("fill", function(d) { 697 | return color(d[groupField]); 698 | }) 699 | .attr("r", 0) 700 | .style("opacity",0.6) 701 | .transition() 702 | .duration(1000) 703 | .attr("r", function(d) { 704 | return popScale(d.pop_served); 705 | }); 706 | 707 | dots.exit() 708 | .transition() 709 | .duration(1000) 710 | .attr("r", 0) 711 | .remove(); 712 | 713 | svg.select("g.x.axis").transition().duration(1000).call(xAxis) 714 | .attr("transform", "translate(0," + height + ")") 715 | .select('.label') 716 | .attr("x", width); 717 | 718 | svg.select("g.y.axis").transition().duration(1000).call(yAxis); 719 | 720 | dots.on("mouseover", function(d) { 721 | tooltip.transition() 722 | .duration(100) 723 | .style("opacity", .9); 724 | tooltip.html("" + d[groupField] + "" + "
Spend: " + d.spend.toPrecision(3) + "
Functional: " + d.functional.toPrecision(3) + " %" + "
Population: " + d.pop_served) 725 | .style("left", (d3.event.pageX + 15) + "px") 726 | .style("top", (d3.event.pageY - 28) + "px"); 727 | }) 728 | .on("mouseout", function(d) { 729 | 730 | tooltip.transition() 731 | .duration(500) 732 | .style("opacity", 0); 733 | }); 734 | } 735 | 736 | function shorten(s, maxlen) { 737 | if (!s) return s; 738 | if (!maxlen) maxlen = 10; 739 | return (s.length > maxlen) ? s.slice(0, maxlen - 3) + "..." : s; 740 | } 741 | -------------------------------------------------------------------------------- /app/scripts/services.coffee: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | angular.module('taarifaWaterpointsApp') 4 | 5 | .factory 'waterpointStats', ($http, $q, populationData) -> 6 | result = {} 7 | 8 | getStats = (region, district, ward, groupfield, cache) -> 9 | def = $q.defer() 10 | url = "/api/waterpoints/stats_by/" + groupfield 11 | filterFields = {"region_name":region, "district_name":district, "ward_name":ward} 12 | filters = [] 13 | 14 | _.keys(filterFields).forEach((x) -> 15 | if filterFields[x] then filters.push(x + "=" + filterFields[x])) 16 | 17 | filter = filters.join("&") 18 | 19 | if filter then url += "?" + filter 20 | 21 | # FIXME: use $cacheFactory to cache also the processed data 22 | $http.get(url, cache: cache) 23 | .success (data, status, headers, config) -> 24 | populationData.then( (popData) -> 25 | geoField = _.contains(['region_name','district_name','ward_name'], groupfield) 26 | 27 | data.forEach((x) -> 28 | f = _.find(x.waterpoints, isFunctional) 29 | 30 | # ensure there is always a functional entry 31 | if !f 32 | f = { 33 | status: "functional", 34 | population: 0, 35 | count: 0 36 | } 37 | x.waterpoints.push(f) 38 | 39 | x.percFun = f.count / x.count * 100 40 | 41 | x.popReach = 0 42 | 43 | if geoField 44 | pop = popData.lookup( 45 | if groupfield == "region_name" then x[groupfield] else null, 46 | if groupfield == "district_name" then x[groupfield] else null, 47 | if groupfield == "ward_name" then x[groupfield] else null 48 | ) 49 | if pop > 0 50 | x.popReach = f.population / pop * 100 51 | ) 52 | 53 | # sort by % functional waterpoints 54 | data.sort (a, b) -> 55 | if a.percFun == b.percFun 56 | b.count - a.count 57 | else 58 | b.percFun - a.percFun 59 | 60 | # all done, call the callback 61 | def.resolve(data) 62 | ) 63 | 64 | return def.promise 65 | 66 | result.getStats = getStats 67 | 68 | return result 69 | 70 | .factory 'modalSpinner', ($modal, $timeout, $q) -> 71 | modalInstance = null 72 | 73 | # shared counter to allow multiple invocations of 74 | # open/close 75 | ctr = {val: 0} 76 | 77 | return { 78 | open: (msg, status) -> 79 | ++ctr.val 80 | if ctr.val > 1 then return 81 | modalInstance = $modal.open 82 | controller: 'ModalSpinnerCtrl' 83 | templateUrl: '/views/spinnerdlg.html' 84 | backdrop: 'static' 85 | size: 'sm' 86 | resolve: 87 | msg: -> 88 | #FIXME this default shouldn't really be here but it's to prevent 89 | # regression bugs until it's sorted 90 | return msg or 'Loading waterpoint data.' 91 | status: -> 92 | return status or 'Loading data...' 93 | 94 | close: -> 95 | --ctr.val 96 | if ctr.val < 1 97 | modalInstance.opened.then -> 98 | modalInstance.close() 99 | ctr.val = 0 100 | } 101 | 102 | # FIXME: this is fundamentally flawed as lookups by name 103 | # cause collision problems. Really need new data that includes 104 | # codes. 105 | .factory 'populationData', ($http, $q) -> 106 | def = $q.defer() 107 | url = '/data/population_novillages.json' 108 | result = {} 109 | 110 | $http.get(url).then((data) -> 111 | #allGrouped = _.groupBy(data.data,"Region") 112 | #_.keys(grouped).forEach((r) -> 113 | # grouped[r] = _.groupBy(grouped[r],"District") 114 | # _.keys(grouped[r]).forEach((d) -> 115 | # grouped[r][d] = _.groupBy(grouped[r][d],"Ward"))) 116 | 117 | # create 3 indices on the data for convenience 118 | # we can do this since all names happen to be unique 119 | # FIXME: eventually should be delegated to a database 120 | regionGroups = _.groupBy(data.data, "Region") 121 | districtGroups = _.groupBy(data.data, "District") 122 | wardGroups = _.groupBy(data.data, "Ward") 123 | 124 | lookup = (r,d,w) -> 125 | try 126 | if w 127 | wardGroups[w][0].Both_Sexes 128 | else if d 129 | districtGroups[d].filter((d) -> 130 | d.Ward == "")[0].Both_Sexes 131 | else if r 132 | regionGroups[r].filter((d) -> 133 | !d.District)[0].Both_Sexes 134 | else 135 | d3.sum(_.chain(regionGroups) 136 | .values(regionGroups) 137 | .flatten() 138 | .filter((d) -> 139 | !d.District) 140 | .pluck("Both_Sexes") 141 | .value()) 142 | catch e 143 | return -1 144 | 145 | result.lookup = lookup 146 | 147 | def.resolve(result)) 148 | 149 | return def.promise 150 | 151 | .factory 'ApiResource', ($resource, $http, flash) -> 152 | (resource, args) -> 153 | Resource = $resource "/api/#{resource}/:id" 154 | , # Default arguments 155 | args 156 | , # Override methods 157 | query: 158 | method: 'GET' 159 | isArray: false 160 | Resource.update = (id, data) -> 161 | # We need to remove special attributes starting with _ since they are 162 | # not defined in the schema and the data will not validate and the 163 | # update be rejected 164 | putdata = {} 165 | for k, v of data when k[0] != '_' 166 | putdata[k] = v 167 | $http.put("/api/#{resource}/"+id, putdata, 168 | headers: {'If-Match': data._etag}) 169 | .success (res, status) -> 170 | if status == 200 and res._status == 'OK' 171 | flash.success = "#{resource} successfully updated!" 172 | data._etag = res._etag 173 | if status == 200 and res._status == 'ERR' 174 | for field, message of res._issues 175 | flash.error = "#{field}: #{message}" 176 | Resource.patch = (id, data, etag) -> 177 | $http 178 | method: 'PATCH' 179 | url: "/api/#{resource}/"+id 180 | data: data 181 | headers: {'If-Match': etag} 182 | return Resource 183 | 184 | .factory 'Waterpoint', (ApiResource) -> 185 | ApiResource 'waterpoints' 186 | 187 | .factory 'Facility', (ApiResource) -> 188 | ApiResource 'facilities' 189 | 190 | .factory 'Request', (ApiResource) -> 191 | ApiResource 'requests' 192 | 193 | .factory 'Service', (ApiResource) -> 194 | ApiResource 'services' 195 | 196 | .factory 'Map', ($filter) -> 197 | (id, opts) => 198 | 199 | defaults = 200 | clustering: false 201 | markerType: "regular" 202 | coverage: false 203 | heatmap: false 204 | showScale: false 205 | 206 | options = _.extend(defaults, opts) 207 | 208 | osmLayer = L.tileLayer( 209 | 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 210 | attribution: '(c) OpenStreetMap') 211 | 212 | satLayer = L.tileLayer( 213 | 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', 214 | attribution: '(c) Esri') 215 | 216 | baseMaps = 217 | "Open Street Map": osmLayer 218 | "Satellite": satLayer 219 | 220 | overlays = {} 221 | 222 | # FIXME: hardcoded categories 223 | categoryMap = 224 | "functional" : 0 225 | "not functional" : 1 226 | "needs repair" : 2 227 | 228 | if options.clustering 229 | markerLayer = new PruneClusterForLeaflet() 230 | markerLayer.Cluster.Size = 100 231 | markerLayer.PrepareLeafletMarker = (leafletMarker, data) -> 232 | if leafletMarker.getPopup() 233 | leafletMarker.setPopupContent data 234 | else 235 | leafletMarker.bindPopup data 236 | else 237 | markerLayer = L.featureGroup() 238 | 239 | overlays.Waterpoints = markerLayer 240 | defaultLayers = [osmLayer, markerLayer] 241 | 242 | if options.coverage 243 | coverageLayer = L.TileLayer.maskCanvas 244 | radius: 1000 245 | useAbsoluteRadius: true # true: r in meters, false: r in pixels 246 | color: '#000' # the color of the layer 247 | opacity: 0.5 # opacity of the not covered area 248 | noMask: false # true results in normal (filled) circled, instead masked circles 249 | lineColor: '#A00' # color of the circle outline if noMask is true 250 | 251 | overlays["Coverage (1km)"] = coverageLayer 252 | 253 | if options.heatmap 254 | heatmapLayer = new HeatmapOverlay 255 | radius: 15 256 | maxOpacity: .7 257 | scaleRadius: false 258 | useLocalExtrema: true 259 | 260 | overlays["Functionality Heatmap"] = heatmapLayer 261 | 262 | # we add the heatmap layer by default 263 | defaultLayers.push(heatmapLayer) 264 | 265 | map = L.map id, 266 | center: new L.LatLng -6.3153, 35.15625 267 | zoom: 5 268 | fullscreenControl: true 269 | layers: defaultLayers 270 | 271 | if options.heatmap 272 | # FIXME: remove the heatmap layer again to workaround 273 | # https://github.com/pa7/heatmap.js/issues/130 274 | map.removeLayer(heatmapLayer) 275 | 276 | # add a layer selector 277 | layerSelector = L.control.layers(baseMaps, overlays).addTo(map) 278 | 279 | # add a distance scale 280 | if options.showScale 281 | scale = L.control.scale().addTo(map) 282 | 283 | makePopup = (wp) -> 284 | cleanKey = (k) -> 285 | $filter('titlecase')(k.replace("_"," ")) 286 | 287 | cleanValue = (k,v) -> 288 | if v instanceof Date 289 | v.getFullYear() 290 | else if k == "location" 291 | v.coordinates.toString() 292 | else 293 | v 294 | 295 | header = '
' + wp.wptcode + ' (Edit)
' + 296 | 'Status: ' + wp.status_group + '
' + 297 | 'Show reports | ' + 298 | 'Submit report' + 299 | '
' 300 | 301 | # FIXME: can't this be offloaded to angular somehow? 302 | fields = _.keys(wp).sort().map((k) -> 303 | #cleanKey(k) + String(cleanValue(k, wp[k])) 304 | '' + cleanKey(k) + ': ' + 305 | '' + String(cleanValue(k,wp[k])) + '' 306 | ).join('
') 307 | 308 | html = '' 309 | 310 | @clearMarkers = () -> 311 | if options.clustering 312 | markerLayer.RemoveMarkers() 313 | else 314 | markerLayer.clearLayers() 315 | 316 | # FIXME: more hardcoded statusses 317 | makeAwesomeIcon = (status) -> 318 | if status == 'functional' 319 | color = 'blue' 320 | else if status == 'not functional' 321 | color = 'red' 322 | else if status == 'needs repair' 323 | color = 'orange' 324 | else 325 | color = 'black' 326 | 327 | icon = L.AwesomeMarkers.icon 328 | prefix: 'glyphicon', 329 | icon: 'tint', 330 | markerColor: color 331 | 332 | makeMarker = (wp) -> 333 | [lng,lat] = wp.location.coordinates 334 | mt = options.markerType 335 | 336 | if mt == "circle" 337 | m = L.circleMarker L.latLng(lat,lng), 338 | stroke: false 339 | radius: 5 340 | fillOpacity: 1 341 | fillColor: statusColor(wp.status_group) 342 | else 343 | m = L.marker L.latLng(lat,lng), 344 | icon: makeAwesomeIcon(wp.status_group) 345 | 346 | @addWaterpoints = (wps) -> 347 | wps.forEach (wp) -> 348 | [lng,lat] = wp.location.coordinates 349 | 350 | if options.clustering 351 | m = new PruneCluster.Marker lat, lng, popup 352 | m.category = categoryMap[wp.status_group] 353 | markerLayer.RegisterMarker m 354 | else 355 | m = makeMarker(wp) 356 | popup = makePopup(wp) 357 | m.bindPopup popup 358 | markerLayer.addLayer(m) 359 | 360 | if options.coverage 361 | coords = wps.map (x) -> [x.location.coordinates[1], x.location.coordinates[0]] 362 | coverageLayer.setData coords 363 | 364 | if options.heatmap 365 | costMap = 366 | functional: 0 367 | "needs repair": 1 368 | "not functional": 2 369 | 370 | coords = [] 371 | wps.forEach (x) -> 372 | if x.status_group != "functional" 373 | coords.push 374 | lat: x.location.coordinates[1] 375 | lng: x.location.coordinates[0] 376 | value: costMap[x.status_group] 377 | 378 | heatmapLayer.setData 379 | data: coords 380 | 381 | 382 | @zoomToMarkers = () -> 383 | if options.clustering 384 | markerLayer.FitBounds() 385 | else 386 | bounds = markerLayer.getBounds() 387 | if bounds.isValid() 388 | map.fitBounds(bounds) 389 | 390 | return this 391 | 392 | # Get an angular-dynamic-forms compatible form description from a Facility 393 | # given a facility code 394 | .factory 'FacilityForm', (Facility) -> 395 | (facility_code) -> 396 | Facility.get(facility_code: facility_code) 397 | # Return a promise since dynamic-forms needs the form template in 398 | # scope when the controller is invoked 399 | .$promise.then (facility) -> 400 | typemap = 401 | string: 'text' 402 | integer: 'number' 403 | # FIXME a number field assumes integers, therefore use text 404 | float: 'number' 405 | boolean: 'checkbox' 406 | mkfield = (type, label, step) -> 407 | type: type 408 | label: label 409 | step: step 410 | class: "form-control" 411 | wrapper: '
' 412 | fields = {} 413 | for f, v of facility._items[0].fields 414 | if v.type == 'point' 415 | fields.longitude = mkfield 'number', 'longitude', 'any' 416 | fields.latitude = mkfield 'number', 'latitude', 'any' 417 | fields.longitude.model = 'location.coordinates[0]' 418 | fields.latitude.model = 'location.coordinates[1]' 419 | else 420 | # Use the field name as label if no label was specified 421 | fields[f] = mkfield typemap[v.type] || v.type, v.label || f 422 | if v.type in ['float', 'number'] 423 | fields[f].step = 'any' 424 | if v.allowed? 425 | fields[f].type = 'select' 426 | options = {} 427 | options[label] = label: label for label in v.allowed 428 | fields[f].options = options 429 | fields.submit = 430 | type: "submit" 431 | label: "Save" 432 | class: "btn btn-primary" 433 | return fields 434 | 435 | # Get an angular-dynamic-forms compatible form description from a Service 436 | # given a service code 437 | .factory 'RequestForm', (Service) -> 438 | (service_code, params) -> 439 | Service.get(service_code: service_code) 440 | # Return a promise since dynamic-forms needs the form template in 441 | # scope when the controller is invoked 442 | .$promise.then (service) -> 443 | dtype2type = 444 | string: 'text' 445 | text: 'textarea' 446 | singlevaluelist: 'select' 447 | multivaluelist: 'select' 448 | fields = {} 449 | for a in service._items[0].attributes when a.variable 450 | fields[a.code] = 451 | type: dtype2type[a.datatype] or a.datatype 452 | required: a.required 453 | label: a.description 454 | class: "form-control" 455 | wrapper: '
' 456 | val: params[a.code] 457 | if a.datatype in ['singlevaluelist', 'multivaluelist'] 458 | fields[a.code].multiple = a.datatype == 'multivaluelist' 459 | options = {} 460 | for v in a.values 461 | options[v.key] = 462 | label: v.name 463 | fields[a.code].options = options 464 | fields.submit = 465 | type: "submit" 466 | label: "Save" 467 | class: "btn btn-primary" 468 | "ng-disabled": "disableSubmit" 469 | return fields 470 | -------------------------------------------------------------------------------- /app/styles/main.css: -------------------------------------------------------------------------------- 1 | /* http://angular-ui.github.io/bootstrap/#/getting_started */ 2 | .nav, .pagination, .carousel, .panel-title a { cursor: pointer; } 3 | 4 | .form-control { 5 | padding: 3px; 6 | } 7 | 8 | .fade { 9 | display: none; 10 | } 11 | 12 | .fade.in { 13 | display: block; 14 | } 15 | 16 | .list-group { 17 | margin-bottom: 0; 18 | } 19 | 20 | .panel-body-nopad { 21 | padding-left: 0; 22 | padding-right: 0; 23 | } 24 | 25 | /* Customize container */ 26 | @media (min-width: 768px) { 27 | .container { 28 | max-width: 1000px; 29 | } 30 | } 31 | .container-narrow > hr { 32 | margin: 30px 0; 33 | } 34 | 35 | /* Plots */ 36 | .plot { 37 | height: 100%; 38 | } 39 | 40 | #map_canvas { 41 | height: 100%; 42 | } 43 | 44 | .panel-title small { 45 | color: #fff; 46 | } 47 | 48 | /* Stacked bar chart */ 49 | 50 | .axis path, 51 | .axis line { 52 | fill: none; 53 | stroke: #000; 54 | shape-rendering: crispEdges; 55 | } 56 | /* 57 | .bar { 58 | fill: steelblue; 59 | } 60 | 61 | .x.axis path { 62 | display: none; 63 | } 64 | */ 65 | 66 | /* scatterplot */ 67 | 68 | .axis path, 69 | .axis line { 70 | fill: none; 71 | stroke: #000; 72 | shape-rendering: crispEdges; 73 | } 74 | 75 | .dot { 76 | stroke: #000; 77 | } 78 | 79 | .tooltip { 80 | position: absolute; 81 | width: 200px; 82 | height: 28px; 83 | pointer-events: none; 84 | } 85 | #statusSummary text { 86 | font-size: 13; 87 | } 88 | 89 | 90 | /* Gridster */ 91 | 92 | .gridster .gridster-item { 93 | -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); 94 | -moz-box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); 95 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); 96 | color: #004756; 97 | background: #ffffff; 98 | padding: 10px; 99 | text-align: center; 100 | } 101 | 102 | .gridster .gridster-item.gridster-item-chart { 103 | padding: 0px; 104 | } 105 | 106 | .gridster-item-chart .panel-heading { 107 | padding: 5px 15px; 108 | cursor: move; 109 | } 110 | 111 | /* FIXME: This should not be needed, work around gridster bug (?) */ 112 | li.gridster-item { 113 | position: absolute; 114 | } 115 | 116 | /* Tiles */ 117 | 118 | .tile{ 119 | background-color: #f4f4f4; 120 | --webkit-box-shadow: 0px 0px 2px 2px #d0d0d0; 121 | font-size: 30px; 122 | text-align: center; 123 | vertical-align: middle; 124 | } 125 | 126 | @media (max-width: 1024px) { 127 | .gridster-item .form-control { 128 | font-size: 12px; 129 | } 130 | } 131 | 132 | .tile-header { 133 | font-size: 18px; 134 | font-color: white; 135 | line-height: 15px; 136 | } 137 | 138 | .tile-number{ 139 | font-weight: bold; 140 | font-size: 24px; 141 | } 142 | @media (min-width: 1024px) { 143 | .tile-number{ 144 | font-size: 32px; 145 | } 146 | } 147 | 148 | .tile-info { 149 | font-size: 20px; 150 | } 151 | 152 | .problemsTile { 153 | text-align: left !important; 154 | } 155 | 156 | .dc-chart g.row text { 157 | fill: black 158 | } 159 | 160 | .dc-chart .pie-slice { 161 | fill: black 162 | } 163 | 164 | .dc-data-count .filter-count { 165 | color: #3182bd; 166 | font-weight: bold; 167 | } 168 | 169 | .dc-data-count .total-count { 170 | color: #3182bd; 171 | font-weight: bold; 172 | } 173 | 174 | #paymentPerWard .axis.x .tick text , 175 | #statusPerWard .axis.x .tick text { 176 | font-size: 8px; 177 | } 178 | 179 | @media (min-width: 1024px) { 180 | #paymentPerWard .axis.x .tick text , 181 | #statusPerWard .axis.x .tick text { 182 | font-size: 9px; 183 | } 184 | } 185 | 186 | 187 | .hor-bar { 188 | fill: #0a871f; 189 | } 190 | 191 | .hor-bar-label { 192 | font-size: 13; 193 | } 194 | 195 | .d3-tip { 196 | line-height: 1; 197 | font-weight: bold; 198 | padding: 12px; 199 | background: rgba(0, 0, 0, 0.8); 200 | color: #fff; 201 | border-radius: 2px; 202 | } 203 | 204 | /* Creates a small triangle extender for the tooltip */ 205 | .d3-tip:after { 206 | box-sizing: border-box; 207 | display: inline; 208 | font-size: 10px; 209 | width: 100%; 210 | line-height: 1; 211 | color: rgba(0, 0, 0, 0.8); 212 | content: "\25BC"; 213 | position: absolute; 214 | text-align: center; 215 | } 216 | 217 | /* Style northward tooltips differently */ 218 | .d3-tip.n:after { 219 | margin: -1px 0 0 0; 220 | top: 100%; 221 | left: 0; 222 | } 223 | 224 | #dc-data-table_wrapper { 225 | padding: 10px; 226 | } 227 | 228 | #dc-data-table th, td { 229 | white-space: nowrap; 230 | } 231 | 232 | div.region-info { 233 | background: rgba(255, 255, 255, 0.9); 234 | border-radius: 5px; 235 | font-weight: bold; 236 | position: absolute; 237 | bottom: 4em; 238 | left: 1.5em; 239 | z-index: 100; 240 | padding: .2em .5em; 241 | line-height: 1.1em; 242 | text-align: center; 243 | } 244 | 245 | .map-control { 246 | position: absolute; 247 | bottom: 1em; 248 | left: 1em; 249 | z-index: 100; 250 | padding: .2em .5em; 251 | line-height: 1.1em; 252 | text-align: center; 253 | } 254 | 255 | #nationalDashMap { 256 | width: 100%; 257 | height: 100%; 258 | } 259 | 260 | #regionalDashMap { 261 | width: 100%; 262 | height: 100%; 263 | } 264 | 265 | .legend { 266 | padding: 6px 8px; 267 | font: 14px/16px Arial, Helvetica, sans-serif; 268 | background: white; 269 | background: rgba(255,255,255,0.8); 270 | box-shadow: 0 0 15px rgba(0,0,0,0.2); 271 | border-radius: 5px; 272 | line-height: 18px; 273 | color: #555; 274 | } 275 | 276 | .legend i { 277 | width: 18px; 278 | height: 18px; 279 | float: left; 280 | margin-right: 8px; 281 | opacity: 0.7; 282 | } 283 | 284 | #wpLocations { 285 | height: 90%; 286 | padding: 0px; 287 | } 288 | 289 | .popup { 290 | height: 100px; 291 | overflow-y: scroll; 292 | } 293 | 294 | .popup-key { 295 | font-weight: bold; 296 | } 297 | 298 | .statusSelector label { 299 | padding-left: 10px; 300 | padding-right: 15px; 301 | } 302 | 303 | .leaflet-control-layers-expanded { 304 | text-align: left; 305 | } 306 | .brand{ 307 | width: 60px; 308 | height: 40px; 309 | /*border-radius: 10%;*/ 310 | background: url('../images/Tanzania.png') no-repeat left center; 311 | margin-left: 10px; 312 | padding-left: 80px; 313 | } 314 | .btn{ 315 | float: right; 316 | position: relative; 317 | margin-top: -20px; 318 | } 319 | -------------------------------------------------------------------------------- /app/styles/prunecluster.css: -------------------------------------------------------------------------------- 1 | .prunecluster { 2 | font-size: 12px; 3 | border-radius: 20px; 4 | transition: all 0.3s linear; 5 | } 6 | .leaflet-marker-icon, .leaflet-marker-shadow, .leaflet-markercluster-icon { 7 | transition: all 0.3s linear; 8 | } 9 | .prunecluster div { 10 | width: 30px; 11 | height: 30px; 12 | text-align: center; 13 | margin-left: 5px; 14 | margin-top: 5px; 15 | border-radius: 50%; 16 | } 17 | .prunecluster div span { 18 | line-height: 30px; 19 | } 20 | 21 | .prunecluster-small { 22 | background-color: rgba(181, 226, 140, 0.6); 23 | } 24 | 25 | .prunecluster-small div { 26 | width: 28px; 27 | height: 28px; 28 | background-color: rgba(110, 204, 57, 0.6); 29 | } 30 | 31 | .prunecluster-small div span { 32 | line-height: 28px; 33 | } 34 | 35 | .prunecluster-medium { 36 | background-color: rgba(241, 211, 87, 0.6); 37 | } 38 | 39 | .prunecluster-medium div { 40 | background-color: rgba(240, 194, 12, 0.6); 41 | } 42 | 43 | .prunecluster-large { 44 | background-color: rgba(253, 156, 115, 0.6); 45 | } 46 | 47 | .prunecluster-large div { 48 | width: 34px; 49 | height: 34px; 50 | background-color: rgba(241, 128, 23, 0.6); 51 | } 52 | 53 | .prunecluster-large div span { 54 | line-height: 34px; 55 | } 56 | -------------------------------------------------------------------------------- /app/views/dashboard.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | 8 | 9 | 10 |
11 |
12 |
    13 |
  • 14 | {{tiles[$index][0] || 'unknown' | translate | titlecase}} 15 |
    16 | {{tiles[$index][1].percent | number:2 }} % 17 |
    18 | ({{tiles[$index][1].count }}) 19 |
  • 20 |
  • 21 | Top Problems 22 |
    23 |
      24 |
    • ({{$index+1}}) - {{p.hardware_problem}} 25 | ({{p.count}})
    • 26 |
    27 |
  • 28 |
  • 30 | 31 |
    32 |
    33 | {{ hoverText }} 34 |
    35 |
    36 |
    37 | 38 | 39 |
    40 |
    41 |
  • 42 |
  • 43 |
    44 |
    45 |

    {{plots[$index].title | translate}}{{params.region}} 46 | > {{params.district}}

    47 |
    48 |
    49 | 50 |
    51 |
    52 | 53 |
    54 |
    55 |
    56 | 64 |
    65 |
    66 |
    67 | 68 |
    69 |
    70 |
    71 | 72 |
    73 | 74 |
    75 |
    76 |
    77 |
    78 | 79 |
    80 |
    81 | 82 |
    83 |
    84 |
  • 85 |
86 |
87 |
88 |
89 | 90 | 91 | 92 |
93 |
94 |
95 |
96 | : 97 | 100 |
101 |

102 |

103 | {{"Selected"|translate}} {{"out of"|translate}} {{"waterpoints"|translate}} | Reset all filters 104 |
105 |

106 |
107 |
108 |
109 | 110 | 111 | 112 |
113 |
114 |
    115 |
  • 116 |
    117 |
    118 |

    {{item.title | translate}}

    119 |
    120 |
    121 |
    122 |
  • 123 |
124 |
125 |
126 |
127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 |
{{col | titlecase}}
135 |
136 |
137 |
138 |
139 | -------------------------------------------------------------------------------- /app/views/edit.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Location 4 |
5 |
6 |
7 |
8 | 10 | 11 |
12 |
13 | -------------------------------------------------------------------------------- /app/views/main.html: -------------------------------------------------------------------------------- 1 |
2 |

Filter map view

3 | 4 |
5 |
6 |
7 |
8 | 9 | 13 |
14 |
15 | 16 | 20 |
21 |
22 | 23 | 27 |
28 |
29 | 33 |
34 |
35 | 36 | 39 |
40 |
41 |
42 |
43 | 44 |
45 | -------------------------------------------------------------------------------- /app/views/requests.html: -------------------------------------------------------------------------------- 1 |
2 |

Filter requests

3 |
4 |
5 |
6 | 7 | 11 |
12 |
13 | 14 | 18 |
19 |
20 | 21 | 22 |
23 |
24 |
25 |
26 | 27 |
29 |
30 |

31 | 33 | {{"Waterpoint" | translate}} {{request.attribute.waterpoint_id}} 34 | {{"submitted on" | translate}} {{request._created}} 35 |

36 |
37 |
38 |
39 |

User provided fields

40 |
41 |
{{k}}
43 |
{{v}}
44 |
45 |
46 |
47 |

Metadata

48 |
49 |
{{k}}
51 |
{{v}}
52 |
53 |
54 |
55 |
56 | -------------------------------------------------------------------------------- /app/views/spinnerdlg.html: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 10 | 11 | 13 |
14 | -------------------------------------------------------------------------------- /app/views/triage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{"Waterpoint" | translate}} {{request.attribute.waterpoint_id}} 4 | {{"submitted on" | translate}} {{request._created}}

5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 24 | 25 | 26 |
AttributeCurrent valueReported valueAccept change
{{k}}{{waterpoint[k]}}{{request.attribute[k]}} 22 | 23 |
27 |
28 |
29 |
30 | 31 |
32 | 33 |
34 |
35 |
36 | 37 |
38 | 39 |
40 |
41 |
42 | 43 |
44 | 45 |
46 |
47 |
48 | 49 |
50 | 51 |
52 |
53 |
54 | 55 |
56 | 58 |
59 |
60 |
61 |
62 | 63 |
64 |
65 |
66 |
67 |
68 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | if [[ -f $HOME/.profile ]]; then 2 | . $HOME/.profile 3 | fi 4 | mkvirtualenv TaarifaAPI 5 | if [[ ! -d TaarifaAPI ]]; then 6 | git clone https://github.com/taarifa/TaarifaAPI 7 | fi 8 | (cd TaarifaAPI; 9 | python setup.py develop) 10 | if [[ ! -d TaarifaWaterpoints ]]; then 11 | git clone https://github.com/taarifa/TaarifaWaterpoints 12 | fi 13 | (cd TaarifaWaterpoints; 14 | pip install -r requirements/dev.txt 15 | npm install 16 | bower install) 17 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TaarifaWaterpoints", 3 | "version": "0.0.0", 4 | "homepage": "https://github.com/taarifa/TaarifaWaterpoints", 5 | "authors": [ 6 | "Taarifa contributors " 7 | ], 8 | "description": "Waterpoint management system for Tanzania", 9 | "main": "scripts/app.coffee", 10 | "keywords": [ 11 | "infrastructure", 12 | "water" 13 | ], 14 | "license": "Apache 2.0", 15 | "ignore": [ 16 | "**/.*", 17 | "node_modules", 18 | "bower_components", 19 | "test", 20 | "tests" 21 | ], 22 | "dependencies": { 23 | "angular-gettext": "~1.1.2", 24 | "angular-gridster": "~0.9.19", 25 | "PruneCluster": "~0.9.1", 26 | "angularjs-geolocation": "~0.1.1", 27 | "leaflet-maskcanvas": "git://github.com/domoritz/leaflet-maskcanvas#819f6b9ecf114b0ab442ab9d1677e37757203c81", 28 | "leaflet-heatmap": "git://github.com/pa7/heatmap.js#develop", 29 | "angular-flash": "~0.1.13" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | sudo add-apt-repository -y ppa:chris-lea/node.js 2 | sudo apt-get update 3 | sudo apt-get install -y git python-pip python-setuptools mongodb nodejs 4 | sudo pip install virtualenv virtualenvwrapper 5 | sudo npm install -g grunt-cli 6 | sudo npm install -g bower 7 | 8 | if ! grep -q WORKON_HOME $HOME/.profile; then 9 | echo "export WORKON_HOME=$HOME/.virtualenvs 10 | export PROJECT_HOME=$HOME 11 | source /usr/local/bin/virtualenvwrapper.sh" >> $HOME/.profile 12 | fi 13 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | from csv import DictReader 2 | from datetime import datetime 3 | from pprint import pprint 4 | 5 | from flask_script import Manager 6 | 7 | from taarifa_api import add_document, delete_documents, get_schema 8 | from taarifa_waterpoints import app 9 | from taarifa_waterpoints.schemas import facility_schema, service_schema 10 | 11 | manager = Manager(app) 12 | 13 | 14 | def check(response, print_status=True): 15 | data, _, _, status = response[0:4] 16 | if 200 <= status <= 299: 17 | if print_status: 18 | print(" Succeeded") 19 | return True 20 | 21 | print("Failed with status", status) 22 | pprint(data) 23 | return False 24 | 25 | 26 | @manager.option("resource", help="Resource to show the schema for") 27 | def show_schema(resource): 28 | """Show the schema for a given resource.""" 29 | pprint(get_schema(resource)) 30 | 31 | 32 | @manager.command 33 | def list_routes(): 34 | """List all routes defined for the application.""" 35 | from urllib.parse import unquote 36 | for rule in sorted(app.url_map.iter_rules(), key=lambda r: r.endpoint): 37 | methods = ','.join(rule.methods) 38 | print(unquote("{:40s} {:40s} {}".format(rule.endpoint, methods, rule))) 39 | 40 | 41 | @manager.command 42 | def create_facility(): 43 | """Create facility for waterpoints.""" 44 | check(add_document('facilities', facility_schema)) 45 | 46 | 47 | @manager.command 48 | def create_service(): 49 | """Create service for waterpoints.""" 50 | check(add_document('services', service_schema)) 51 | 52 | 53 | @manager.command 54 | def delete_facilities(): 55 | """Delete all facilities.""" 56 | check(delete_documents('facilities')) 57 | 58 | 59 | @manager.command 60 | def delete_services(): 61 | """Delete all services.""" 62 | check(delete_documents('services')) 63 | 64 | 65 | @manager.command 66 | def delete_requests(): 67 | """Delete all requests.""" 68 | check(delete_documents('requests')) 69 | 70 | 71 | @manager.option("filename", help="CSV file to upload (required)") 72 | @manager.option("--skip", type=int, default=0, help="Skip a number of records") 73 | @manager.option("--limit", type=int, help="Only upload a number of records") 74 | def upload_waterpoints(filename, skip=0, limit=None): 75 | """Upload waterpoints from a CSV file.""" 76 | # Use sys.stdout.write so waterpoints can be printed nicely and succinctly 77 | import sys 78 | 79 | date_converter = lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ") 80 | bool_converter = lambda s: s == "T" 81 | 82 | status_map = { 83 | "non functional": "not functional", 84 | "functional needs repair": "needs repair" 85 | } 86 | 87 | status_converter = lambda s: status_map.get(s.lower(), s.lower()) 88 | 89 | convert = { 90 | 'latitude': float, 91 | 'longitude': float, 92 | 'gid': int, 93 | 'objectid': int, 94 | 'valid_from': date_converter, 95 | 'valid_to': date_converter, 96 | 'amount_tsh': float, 97 | 'breakdown_year': int, 98 | 'date_recorded': date_converter, 99 | 'gps_height': float, 100 | 'x_wgs84': float, 101 | 'y_wgs84': float, 102 | 'num_privcon': int, 103 | 'pop_served': int, 104 | 'public_meeting': bool_converter, 105 | 'construction_year': int, 106 | 'status_group': status_converter, 107 | 'region_code': int, 108 | 'district_code': int, 109 | 'ward_code': int 110 | } 111 | 112 | def print_flush(msg): 113 | sys.stdout.write(msg) 114 | sys.stdout.flush() 115 | 116 | facility_code = "wpf001" 117 | print_every = 1000 118 | print_flush("Adding waterpoints. Please be patient.") 119 | 120 | with open(filename, 'r') as f: 121 | reader = DictReader(f) 122 | for i in range(skip): 123 | next(reader) 124 | for i, d in enumerate(reader): 125 | actual_index = i + skip + 2 126 | do_print = actual_index % print_every == 0 127 | try: 128 | d = dict((k, convert.get(k, str)(v)) for k, v in d.items() if v) 129 | coords = [d.pop('longitude'), d.pop('latitude')] 130 | d['location'] = {'type': 'Point', 'coordinates': coords} 131 | d['facility_code'] = facility_code 132 | if not check(add_document('waterpoints', d), False): 133 | raise Exception() 134 | if do_print: 135 | print_flush(".") 136 | 137 | except Exception as e: 138 | print("Error adding waterpoint", e) 139 | pprint(d) 140 | exit() 141 | 142 | if limit and i >= limit: 143 | break 144 | # Create a 2dsphere index on the location field for geospatial queries 145 | app.data.driver.db['resources'].create_index([('location', '2dsphere')]) 146 | print("Waterpoints uploaded!") 147 | 148 | 149 | @manager.command 150 | def ensure_indexes(): 151 | """Make sure all important database indexes are created.""" 152 | print("Ensuring resources:location 2dsphere index is created ...") 153 | app.data.driver.db['resources'].create_index([('location', '2dsphere')]) 154 | print("Done!") 155 | 156 | 157 | @manager.option("status", help="Status (functional or non functional)") 158 | @manager.option("wp", help="Waterpoint id") 159 | def create_request(wp, status): 160 | """Create an example request reporting a broken waterpoint""" 161 | r = {"service_code": "wps001", 162 | "attribute": {"waterpoint_id": wp, 163 | "status": status}} 164 | check(add_document("requests", r)) 165 | 166 | 167 | @manager.command 168 | def delete_waterpoints(): 169 | """Delete all existing waterpoints.""" 170 | check(delete_documents('waterpoints')) 171 | 172 | if __name__ == "__main__": 173 | manager.run() 174 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TaarifaWaterpoints", 3 | "version": "0.0.0", 4 | "license": "Apache-2.0", 5 | "repository": "https://github.com/taarifa/TaarifaWaterpoints", 6 | "dependencies": { 7 | "grunt": "~1.5.3", 8 | "grunt-angular-gettext": "~2.5.3", 9 | "grunt-autoprefixer": "~3.0.4", 10 | "grunt-cli": "~1.3.2", 11 | "grunt-concurrent": "~3.0.0", 12 | "grunt-contrib-clean": "~2.0.0", 13 | "grunt-contrib-coffee": "~2.1.0", 14 | "grunt-contrib-concat": "~1.0.1", 15 | "grunt-contrib-connect": "~3.0.0", 16 | "grunt-contrib-copy": "~1.0.0", 17 | "grunt-contrib-cssmin": "~3.0.0", 18 | "grunt-contrib-htmlmin": "~3.1.0", 19 | "grunt-contrib-imagemin": "~4.0.0", 20 | "grunt-contrib-jshint": "~2.1.0", 21 | "grunt-contrib-uglify-es": "~3.3.0", 22 | "grunt-contrib-watch": "~1.1.0", 23 | "grunt-google-cdn": "~0.4.3", 24 | "grunt-newer": "~1.3.0", 25 | "grunt-rev": "~0.1.0", 26 | "grunt-svgmin": "~6.0.1", 27 | "grunt-usemin": "~3.1.1", 28 | "jshint-stylish": "~2.2.1", 29 | "load-grunt-tasks": "~5.1.0", 30 | "ng-annotate": "^1.2.2", 31 | "time-grunt": "~1.4.0", 32 | "grunt-bower-task": "~0.5.0", 33 | "topojson": "^1.6.18" 34 | }, 35 | "devDependencies": { 36 | "karma-ng-scenario": "~1.0.0", 37 | "grunt-karma": "~4.0.0", 38 | "karma": "~6.3.16", 39 | "karma-ng-html2js-preprocessor": "~1.0.0", 40 | "grunt-connect-proxy": "~0.2.0" 41 | }, 42 | "engines": { 43 | "node": ">=0.10.0" 44 | }, 45 | "scripts": { 46 | "test": "grunt test", 47 | "postinstall": "./node_modules/grunt-cli/bin/grunt build", 48 | "heroku-postbuild": "./node_modules/grunt-cli/bin/grunt build" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/deploy.txt 2 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | -e git://github.com/taarifa/TaarifaAPI.git@0.2.0#egg=taarifaapi 2 | dnspython==2.6.1 3 | Flask-Caching==1.9.0 4 | Flask-Script==2.0.5 5 | gunicorn==23.0.0 6 | requests==2.32.4 7 | -------------------------------------------------------------------------------- /requirements/deploy.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | flake8==2.4.1 4 | ipython==8.10.0 5 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.8.6 2 | -------------------------------------------------------------------------------- /scripts/mongoshell.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | from os import environ 4 | from subprocess import check_call 5 | from urlparse import urlparse 6 | 7 | if 'MONGOLAB_URI' in environ: 8 | print 'Using', environ['MONGOLAB_URI'] 9 | url = urlparse(environ['MONGOLAB_URI']) 10 | cmd = 'mongo -u %s -p %s %s:%d/%s' % (url.username, 11 | url.password, 12 | url.hostname, 13 | url.port, 14 | url.path[1:]) 15 | else: 16 | cmd = 'mongo TaarifaAPI' 17 | check_call(cmd, shell=True) 18 | -------------------------------------------------------------------------------- /scripts/openrefine.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "op": "core/text-transform", 4 | "description": "Text transform on cells in column GID using expression value.toNumber()", 5 | "engineConfig": { 6 | "facets": [], 7 | "mode": "row-based" 8 | }, 9 | "columnName": "GID", 10 | "expression": "value.toNumber()", 11 | "onError": "keep-original", 12 | "repeat": false, 13 | "repeatCount": 10 14 | }, 15 | { 16 | "op": "core/text-transform", 17 | "description": "Text transform on cells in column OBJECTID using expression value.toNumber()", 18 | "engineConfig": { 19 | "facets": [], 20 | "mode": "row-based" 21 | }, 22 | "columnName": "OBJECTID", 23 | "expression": "value.toNumber()", 24 | "onError": "keep-original", 25 | "repeat": false, 26 | "repeatCount": 10 27 | }, 28 | { 29 | "op": "core/text-transform", 30 | "description": "Text transform on cells in column VALID_FROM using expression value.toDate()", 31 | "engineConfig": { 32 | "facets": [], 33 | "mode": "row-based" 34 | }, 35 | "columnName": "VALID_FROM", 36 | "expression": "value.toDate()", 37 | "onError": "keep-original", 38 | "repeat": false, 39 | "repeatCount": 10 40 | }, 41 | { 42 | "op": "core/text-transform", 43 | "description": "Text transform on cells in column VALID_TO using expression value.toDate()", 44 | "engineConfig": { 45 | "facets": [], 46 | "mode": "row-based" 47 | }, 48 | "columnName": "VALID_TO", 49 | "expression": "value.toDate()", 50 | "onError": "keep-original", 51 | "repeat": false, 52 | "repeatCount": 10 53 | }, 54 | { 55 | "op": "core/text-transform", 56 | "description": "Text transform on cells in column AMOUNT_TSH using expression value.toNumber()", 57 | "engineConfig": { 58 | "facets": [], 59 | "mode": "row-based" 60 | }, 61 | "columnName": "AMOUNT_TSH", 62 | "expression": "value.toNumber()", 63 | "onError": "keep-original", 64 | "repeat": false, 65 | "repeatCount": 10 66 | }, 67 | { 68 | "op": "core/text-transform", 69 | "description": "Text transform on cells in column BREAKDOWN using expression value.toNumber()", 70 | "engineConfig": { 71 | "facets": [], 72 | "mode": "row-based" 73 | }, 74 | "columnName": "BREAKDOWN", 75 | "expression": "value.toNumber()", 76 | "onError": "keep-original", 77 | "repeat": false, 78 | "repeatCount": 10 79 | }, 80 | { 81 | "op": "core/text-transform", 82 | "description": "Text transform on cells in column DATE_OF_RECORDING using expression value.toDate()", 83 | "engineConfig": { 84 | "facets": [], 85 | "mode": "row-based" 86 | }, 87 | "columnName": "DATE_OF_RECORDING", 88 | "expression": "value.toDate()", 89 | "onError": "keep-original", 90 | "repeat": false, 91 | "repeatCount": 10 92 | }, 93 | { 94 | "op": "core/text-transform", 95 | "description": "Text transform on cells in column GPS_HEIGHT using expression value.toNumber()", 96 | "engineConfig": { 97 | "facets": [], 98 | "mode": "row-based" 99 | }, 100 | "columnName": "GPS_HEIGHT", 101 | "expression": "value.toNumber()", 102 | "onError": "keep-original", 103 | "repeat": false, 104 | "repeatCount": 10 105 | }, 106 | { 107 | "op": "core/text-transform", 108 | "description": "Text transform on cells in column NUMBER PRIVATE CONNECTIONS using expression value.toNumber()", 109 | "engineConfig": { 110 | "facets": [], 111 | "mode": "row-based" 112 | }, 113 | "columnName": "NUMBER PRIVATE CONNECTIONS", 114 | "expression": "value.toNumber()", 115 | "onError": "keep-original", 116 | "repeat": false, 117 | "repeatCount": 10 118 | }, 119 | { 120 | "op": "core/text-transform", 121 | "description": "Text transform on cells in column POPULATION SERVED using expression value.toNumber()", 122 | "engineConfig": { 123 | "facets": [], 124 | "mode": "row-based" 125 | }, 126 | "columnName": "POPULATION SERVED", 127 | "expression": "value.toNumber()", 128 | "onError": "keep-original", 129 | "repeat": false, 130 | "repeatCount": 10 131 | }, 132 | { 133 | "op": "core/text-transform", 134 | "description": "Text transform on cells in column YEAR OF CONSTRUCTION using expression value.toNumber()", 135 | "engineConfig": { 136 | "facets": [], 137 | "mode": "row-based" 138 | }, 139 | "columnName": "YEAR OF CONSTRUCTION", 140 | "expression": "value.toNumber()", 141 | "onError": "keep-original", 142 | "repeat": false, 143 | "repeatCount": 10 144 | }, 145 | { 146 | "op": "core/column-removal", 147 | "description": "Remove column File", 148 | "columnName": "File" 149 | }, 150 | { 151 | "op": "core/column-rename", 152 | "description": "Rename column GID to gid", 153 | "oldColumnName": "GID", 154 | "newColumnName": "gid" 155 | }, 156 | { 157 | "op": "core/column-rename", 158 | "description": "Rename column OBJECTID to objectid", 159 | "oldColumnName": "OBJECTID", 160 | "newColumnName": "objectid" 161 | }, 162 | { 163 | "op": "core/column-rename", 164 | "description": "Rename column VALID_FROM to valid_from", 165 | "oldColumnName": "VALID_FROM", 166 | "newColumnName": "valid_from" 167 | }, 168 | { 169 | "op": "core/column-rename", 170 | "description": "Rename column VALID_TO to valid_to", 171 | "oldColumnName": "VALID_TO", 172 | "newColumnName": "valid_to" 173 | }, 174 | { 175 | "op": "core/column-rename", 176 | "description": "Rename column AMOUNT_TSH to amount_tsh", 177 | "oldColumnName": "AMOUNT_TSH", 178 | "newColumnName": "amount_tsh" 179 | }, 180 | { 181 | "op": "core/column-rename", 182 | "description": "Rename column BREAKDOWN to breakdown_year", 183 | "oldColumnName": "BREAKDOWN", 184 | "newColumnName": "breakdown_year" 185 | }, 186 | { 187 | "op": "core/column-rename", 188 | "description": "Rename column DATE_OF_RECORDING to date_recorded", 189 | "oldColumnName": "DATE_OF_RECORDING", 190 | "newColumnName": "date_recorded" 191 | }, 192 | { 193 | "op": "core/column-rename", 194 | "description": "Rename column FUNDER to funder", 195 | "oldColumnName": "FUNDER", 196 | "newColumnName": "funder" 197 | }, 198 | { 199 | "op": "core/column-rename", 200 | "description": "Rename column GPS_HEIGHT to gps_height", 201 | "oldColumnName": "GPS_HEIGHT", 202 | "newColumnName": "gps_height" 203 | }, 204 | { 205 | "op": "core/column-rename", 206 | "description": "Rename column INSTALLER to installer", 207 | "oldColumnName": "INSTALLER", 208 | "newColumnName": "installer" 209 | }, 210 | { 211 | "op": "core/column-rename", 212 | "description": "Rename column WPTNAME to wptname", 213 | "oldColumnName": "WPTNAME", 214 | "newColumnName": "wptname" 215 | }, 216 | { 217 | "op": "core/column-rename", 218 | "description": "Rename column NUMBER PRIVATE CONNECTIONS to num_privcon", 219 | "oldColumnName": "NUMBER PRIVATE CONNECTIONS", 220 | "newColumnName": "num_privcon" 221 | }, 222 | { 223 | "op": "core/column-rename", 224 | "description": "Rename column BASIN to basin", 225 | "oldColumnName": "BASIN", 226 | "newColumnName": "basin" 227 | }, 228 | { 229 | "op": "core/column-rename", 230 | "description": "Rename column VILLAGE REGISTRATION NUMBER to village_reg_num", 231 | "oldColumnName": "VILLAGE REGISTRATION NUMBER", 232 | "newColumnName": "village_reg_num" 233 | }, 234 | { 235 | "op": "core/column-rename", 236 | "description": "Rename column VILLAGE to village", 237 | "oldColumnName": "VILLAGE", 238 | "newColumnName": "village" 239 | }, 240 | { 241 | "op": "core/column-rename", 242 | "description": "Rename column SUBVILLAGE to subvillage", 243 | "oldColumnName": "SUBVILLAGE", 244 | "newColumnName": "subvillage" 245 | }, 246 | { 247 | "op": "core/column-rename", 248 | "description": "Rename column REGION to region_name", 249 | "oldColumnName": "REGION", 250 | "newColumnName": "regio_namen" 251 | }, 252 | { 253 | "op": "core/column-rename", 254 | "description": "Rename column LATITUDE to latitude", 255 | "oldColumnName": "LATITUDE", 256 | "newColumnName": "latitude" 257 | }, 258 | { 259 | "op": "core/column-rename", 260 | "description": "Rename column LONGITUDE to longitude", 261 | "oldColumnName": "LONGITUDE", 262 | "newColumnName": "longitude" 263 | }, 264 | { 265 | "op": "core/column-rename", 266 | "description": "Rename column LGA to lga", 267 | "oldColumnName": "LGA", 268 | "newColumnName": "lga" 269 | }, 270 | { 271 | "op": "core/column-rename", 272 | "description": "Rename column LGA NAME to lga_name", 273 | "oldColumnName": "LGA NAME", 274 | "newColumnName": "lga_name" 275 | }, 276 | { 277 | "op": "core/column-rename", 278 | "description": "Rename column VILLAGE POPULATION to village_pop", 279 | "oldColumnName": "VILLAGE POPULATION", 280 | "newColumnName": "village_pop" 281 | }, 282 | { 283 | "op": "core/column-rename", 284 | "description": "Rename column GENERAL COMMENT to gen_comment", 285 | "oldColumnName": "GENERAL COMMENT", 286 | "newColumnName": "gen_comment" 287 | }, 288 | { 289 | "op": "core/column-rename", 290 | "description": "Rename column VILLPHOTOI to villphotoid", 291 | "oldColumnName": "VILLPHOTOI", 292 | "newColumnName": "villphotoid" 293 | }, 294 | { 295 | "op": "core/column-rename", 296 | "description": "Rename column WARD to ward_name", 297 | "oldColumnName": "WARD", 298 | "newColumnName": "ward_name" 299 | }, 300 | { 301 | "op": "core/column-rename", 302 | "description": "Rename column POPULATION SERVED to pop_served", 303 | "oldColumnName": "POPULATION SERVED", 304 | "newColumnName": "pop_served" 305 | }, 306 | { 307 | "op": "core/column-rename", 308 | "description": "Rename column PUBLIC MEETING to public_meeting", 309 | "oldColumnName": "PUBLIC MEETING", 310 | "newColumnName": "public_meeting" 311 | }, 312 | { 313 | "op": "core/column-rename", 314 | "description": "Rename column REASON_WPT to reason_wpt", 315 | "oldColumnName": "REASON_WPT", 316 | "newColumnName": "reason_wpt" 317 | }, 318 | { 319 | "op": "core/column-rename", 320 | "description": "Rename column RECORDING to recorded_by", 321 | "oldColumnName": "RECORDING", 322 | "newColumnName": "recorded_by" 323 | }, 324 | { 325 | "op": "core/column-rename", 326 | "description": "Rename column SCHEME MANAGEMENT to scheme_man", 327 | "oldColumnName": "SCHEME MANAGEMENT", 328 | "newColumnName": "scheme_man" 329 | }, 330 | { 331 | "op": "core/column-rename", 332 | "description": "Rename column SCHEMENAME to schemename", 333 | "oldColumnName": "SCHEMENAME", 334 | "newColumnName": "schemename" 335 | }, 336 | { 337 | "op": "core/column-rename", 338 | "description": "Rename column PERMIT to permit", 339 | "oldColumnName": "PERMIT", 340 | "newColumnName": "permit" 341 | }, 342 | { 343 | "op": "core/column-rename", 344 | "description": "Rename column WPTCODE to wptcode", 345 | "oldColumnName": "WPTCODE", 346 | "newColumnName": "wptcode" 347 | }, 348 | { 349 | "op": "core/column-rename", 350 | "description": "Rename column WPTPHOTOID to wptphotoid", 351 | "oldColumnName": "WPTPHOTOID", 352 | "newColumnName": "wptphotoid" 353 | }, 354 | { 355 | "op": "core/column-rename", 356 | "description": "Rename column YEAR OF CONSTRUCTION to construction_year", 357 | "oldColumnName": "YEAR OF CONSTRUCTION", 358 | "newColumnName": "construction_year" 359 | }, 360 | { 361 | "op": "core/column-rename", 362 | "description": "Rename column EXTRACTION TYPE to extraction", 363 | "oldColumnName": "EXTRACTION TYPE", 364 | "newColumnName": "extraction" 365 | }, 366 | { 367 | "op": "core/column-rename", 368 | "description": "Rename column EXTRACTION TYPE GROUP to extraction_group", 369 | "oldColumnName": "EXTRACTION TYPE GROUP", 370 | "newColumnName": "extraction_group" 371 | }, 372 | { 373 | "op": "core/column-rename", 374 | "description": "Rename column EXTRACTION TYPE CLASS to extraction_class", 375 | "oldColumnName": "EXTRACTION TYPE CLASS", 376 | "newColumnName": "extraction_class" 377 | }, 378 | { 379 | "op": "core/column-rename", 380 | "description": "Rename column HARDWARE PROBLEMS to hardware_problem", 381 | "oldColumnName": "HARDWARE PROBLEMS", 382 | "newColumnName": "hardware_problem" 383 | }, 384 | { 385 | "op": "core/column-rename", 386 | "description": "Rename column HARDWARE PROBLEMS GROUP to hardware_problem_group", 387 | "oldColumnName": "HARDWARE PROBLEMS GROUP", 388 | "newColumnName": "hardware_problem_group" 389 | }, 390 | { 391 | "op": "core/column-rename", 392 | "description": "Rename column MANAGEMENT to management", 393 | "oldColumnName": "MANAGEMENT", 394 | "newColumnName": "management" 395 | }, 396 | { 397 | "op": "core/column-rename", 398 | "description": "Rename column MANAGEMENT GROUP to management_group", 399 | "oldColumnName": "MANAGEMENT GROUP", 400 | "newColumnName": "management_group" 401 | }, 402 | { 403 | "op": "core/column-rename", 404 | "description": "Rename column PAYMENT to payment", 405 | "oldColumnName": "PAYMENT", 406 | "newColumnName": "payment" 407 | }, 408 | { 409 | "op": "core/column-rename", 410 | "description": "Rename column PAYMENT GROUP to payment_group", 411 | "oldColumnName": "PAYMENT GROUP", 412 | "newColumnName": "payment_group" 413 | }, 414 | { 415 | "op": "core/column-rename", 416 | "description": "Rename column QUALITY to quality", 417 | "oldColumnName": "QUALITY", 418 | "newColumnName": "quality" 419 | }, 420 | { 421 | "op": "core/column-rename", 422 | "description": "Rename column QUALITY GROUP to quality_group", 423 | "oldColumnName": "QUALITY GROUP", 424 | "newColumnName": "quality_group" 425 | }, 426 | { 427 | "op": "core/column-rename", 428 | "description": "Rename column QUANTITY to quantity", 429 | "oldColumnName": "QUANTITY", 430 | "newColumnName": "quantity" 431 | }, 432 | { 433 | "op": "core/column-rename", 434 | "description": "Rename column QUANTITY GROUP to quantity_group", 435 | "oldColumnName": "QUANTITY GROUP", 436 | "newColumnName": "quantity_group" 437 | }, 438 | { 439 | "op": "core/column-rename", 440 | "description": "Rename column SOURCE to source", 441 | "oldColumnName": "SOURCE", 442 | "newColumnName": "source" 443 | }, 444 | { 445 | "op": "core/column-rename", 446 | "description": "Rename column SOURCE GROUP to source_group", 447 | "oldColumnName": "SOURCE GROUP", 448 | "newColumnName": "source_group" 449 | }, 450 | { 451 | "op": "core/column-rename", 452 | "description": "Rename column SOURCE CLASS to source_class", 453 | "oldColumnName": "SOURCE CLASS", 454 | "newColumnName": "source_class" 455 | }, 456 | { 457 | "op": "core/column-rename", 458 | "description": "Rename column STATUS to status", 459 | "oldColumnName": "STATUS", 460 | "newColumnName": "status" 461 | }, 462 | { 463 | "op": "core/column-rename", 464 | "description": "Rename column STATUS GROUP to status_group", 465 | "oldColumnName": "STATUS GROUP", 466 | "newColumnName": "status_group" 467 | }, 468 | { 469 | "op": "core/column-rename", 470 | "description": "Rename column WATERPOINT TYPE to wp_type", 471 | "oldColumnName": "WATERPOINT TYPE", 472 | "newColumnName": "wp_type" 473 | }, 474 | { 475 | "op": "core/column-rename", 476 | "description": "Rename column WATERPOINT TYPE GROUP to wp_type_group", 477 | "oldColumnName": "WATERPOINT TYPE GROUP", 478 | "newColumnName": "wp_type_group" 479 | }, 480 | { 481 | "op": "core/column-removal", 482 | "description": "Remove column lga", 483 | "columnName": "lga" 484 | } 485 | ] 486 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | dep_links = ['git+https://github.com/taarifa/TaarifaAPI#egg=TaarifaAPI-dev'] 4 | setup(name='TaarifaWaterpoints', 5 | version='dev', 6 | description='Waterpoint management system for Tanzania', 7 | long_description=open('README.rst').read(), 8 | author='The Taarifa Organisation', 9 | author_email='taarifadev@gmail.com', 10 | license='Apache License, Version 2.0', 11 | url='http://taarifa.org', 12 | download_url='https://github.com/taarifa/TaarifaWaterpoints', 13 | classifiers=[ 14 | 'Development Status :: 3 - Alpha', 15 | 'Intended Audience :: Developers', 16 | 'Intended Audience :: Science/Research', 17 | 'License :: OSI Approved :: Apache Software License', 18 | 'Operating System :: OS Independent', 19 | 'Programming Language :: Python :: 2', 20 | 'Programming Language :: Python :: 2.6', 21 | 'Programming Language :: Python :: 2.7', 22 | ], 23 | packages=['taarifa_waterpoints'], 24 | include_package_data=True, 25 | zip_safe=False, 26 | install_requires=['TaarifaAPI==dev', 'Flask-Script==2.0.3'], 27 | dependency_links=dep_links) 28 | -------------------------------------------------------------------------------- /startserver: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Change to script directory 4 | cd -P "$(dirname "${BASH_SOURCE[0]}")" 5 | 6 | # Load virtualenvwrapper (this only happens automatically for login shells) 7 | . `which virtualenvwrapper.sh` 8 | 9 | workon TaarifaAPI 10 | nohup python manage.py runserver -h 0.0.0.0 -r -d 2>&1 > apiserver.log 11 | nohup grunt serve 2>&1 > gruntserver.log 12 | -------------------------------------------------------------------------------- /taarifa_waterpoints/__init__.py: -------------------------------------------------------------------------------- 1 | from .taarifa_waterpoints import app # noqa 2 | -------------------------------------------------------------------------------- /taarifa_waterpoints/schemas.py: -------------------------------------------------------------------------------- 1 | waterpoint_schema = { 2 | 'gid': { 3 | 'type': 'integer', 4 | 'label': 'GID', 5 | # FIXME: not really unique... 6 | # 'unique': True 7 | }, 8 | 'objectid': { 9 | 'type': 'integer', 10 | 'label': 'Object ID', 11 | # FIXME: not really unique... 12 | # 'unique': True 13 | }, 14 | 'valid_from': { 15 | 'type': 'datetime', 16 | 'label': 'Valid From', 17 | }, 18 | 'valid_to': { 19 | 'type': 'datetime', 20 | 'label': 'Valid To', 21 | }, 22 | 'amount_tsh': { 23 | 'type': 'number', 24 | 'label': 'Amount paid (TSH)', 25 | }, 26 | 'breakdown_year': { 27 | 'type': 'integer', 28 | 'label': 'Breakdown Year', 29 | }, 30 | 'date_recorded': { 31 | 'type': 'datetime', 32 | 'label': 'Date recorded', 33 | }, 34 | 'funder': { 35 | 'type': 'string', 36 | 'label': 'Funder', 37 | }, 38 | 'gps_height': { 39 | 'type': 'number', 40 | 'label': 'GPS Height', 41 | }, 42 | 'installer': { 43 | 'type': 'string', 44 | 'label': 'Installer', 45 | }, 46 | 'location': { 47 | 'type': 'point', 48 | }, 49 | 'wptname': { 50 | 'type': 'string', 51 | 'label': 'Waterpoint Name', 52 | }, 53 | 'num_privcon': { 54 | 'type': 'integer', 55 | 'label': 'Number of private connections', 56 | }, 57 | 'basin': { 58 | 'type': 'string', 59 | 'label': 'Basin', 60 | }, 61 | 'subvillage': { 62 | 'type': 'string', 63 | 'label': 'Subvillage', 64 | }, 65 | 'region_name': { 66 | 'type': 'string', 67 | 'label': 'Region', 68 | }, 69 | 'region_code': { 70 | 'type': 'integer', 71 | 'label': 'Region Code', 72 | }, 73 | 'district_name': { 74 | 'type': 'string', 75 | 'label': 'District Name', 76 | }, 77 | 'district_code': { 78 | 'type': 'integer', 79 | 'label': 'District Code', 80 | }, 81 | 'lga_name': { 82 | 'type': 'string', 83 | 'label': 'LGA', 84 | }, 85 | 'ward_name': { 86 | 'type': 'string', 87 | 'label': 'Ward', 88 | }, 89 | 'ward_code': { 90 | 'type': 'integer', 91 | 'label': 'Ward Code', 92 | }, 93 | 'pop_served': { 94 | 'type': 'integer', 95 | 'label': 'Population', 96 | }, 97 | 'public_meeting': { 98 | 'type': 'boolean', 99 | 'label': 'Public meetings held', 100 | }, 101 | 'reason_wpt': { 102 | 'type': 'string', 103 | 'label': 'Reason not functional', 104 | }, 105 | 'recorded_by': { 106 | 'type': 'string', 107 | 'label': 'Recorded by', 108 | }, 109 | 'scheme_man': { 110 | 'type': 'string', 111 | 'label': 'Scheme Management', 112 | }, 113 | 'schemename': { 114 | 'type': 'string', 115 | 'label': 'Scheme Name', 116 | }, 117 | 'permit': { 118 | 'type': 'string', 119 | 'label': 'Permit', 120 | }, 121 | 'wptcode': { 122 | 'type': 'string', 123 | 'label': 'Waterpoint Code', 124 | # FIXME: waterpoint codes should be unique, but are not in the dataset 125 | # 'unique': True, 126 | }, 127 | 'wptphotoid': { 128 | 'type': 'string', 129 | 'label': 'Photo ID', 130 | }, 131 | 'construction_year': { 132 | 'type': 'integer', 133 | 'label': 'Construction Year', 134 | }, 135 | 'extraction': { 136 | 'type': 'string', 137 | 'label': 'Extraction type', 138 | }, 139 | 'extraction_group': { 140 | 'type': 'string', 141 | 'label': 'Extraction type group', 142 | }, 143 | 'extraction_class': { 144 | 'type': 'string', 145 | 'label': 'Extraction type class', 146 | }, 147 | 'hardware_problem': { 148 | 'type': 'string', 149 | 'label': 'Hardware problem', 150 | }, 151 | 'hardware_problem_group': { 152 | 'type': 'string', 153 | 'label': 'Hardware problem group', 154 | }, 155 | 'management': { 156 | 'type': 'string', 157 | 'label': 'Management Authority (COWSO)', 158 | }, 159 | 'management_group': { 160 | 'type': 'string', 161 | 'label': 'Management Group', 162 | }, 163 | 'payment': { 164 | 'type': 'string', 165 | 'label': 'Form of Payment', 166 | }, 167 | 'payment_group': { 168 | 'type': 'string', 169 | 'label': 'Type of Payment', 170 | }, 171 | 'quality': { 172 | 'type': 'string', 173 | 'label': 'Water quality', 174 | }, 175 | 'quality_group': { 176 | 'type': 'string', 177 | 'label': 'Water quality group', 178 | }, 179 | 'quantity': { 180 | 'type': 'string', 181 | 'label': 'Quantity', 182 | }, 183 | 'quantity_group': { 184 | 'type': 'string', 185 | 'label': 'Quantity group', 186 | }, 187 | 'source': { 188 | 'type': 'string', 189 | 'label': 'Source', 190 | }, 191 | 'source_group': { 192 | 'type': 'string', 193 | 'label': 'Source Group', 194 | }, 195 | 'source_class': { 196 | 'type': 'string', 197 | 'label': 'Source Class', 198 | }, 199 | 'status': { 200 | 'type': 'string', 201 | 'label': 'Status detail', 202 | }, 203 | 'status_group': { 204 | 'type': 'string', 205 | 'label': 'Status group', 206 | 'allowed': ['functional', 'not functional', 'needs repair'], 207 | }, 208 | 'wp_type': { 209 | 'type': 'string', 210 | 'label': 'Waterpoint type', 211 | }, 212 | 'wp_type_group': { 213 | 'type': 'string', 214 | 'label': 'Waterpoint type group', 215 | }, 216 | 'division': { 217 | 'type': 'string', 218 | 'label': 'Division', 219 | }, 220 | 'gen_comment': { 221 | 'type': 'string', 222 | 'label': 'General Comments', 223 | }, 224 | 'village': { 225 | 'type': 'string', 226 | 'label': 'Village', 227 | }, 228 | 'village_pop': { 229 | 'type': 'string', 230 | 'label': 'Village Population', 231 | }, 232 | 'village_reg_num': { 233 | 'type': 'string', 234 | 'label': 'Village registration number', 235 | }, 236 | 'villphotoid': { 237 | 'type': 'string', 238 | 'label': 'Village photo id', 239 | }, 240 | } 241 | 242 | # Facility and resources go hand in hand. Following Open311 the facility 243 | # schema uses its fields attribute to define the schema resources must 244 | # have that are part of the facility. 245 | # FIXME: facility/service code duplicated here and in manage.py, should be in 246 | # settings.py 247 | facility_schema = {'facility_code': "wpf001", 248 | 'facility_name': "Waterpoint Infrastructure", 249 | # this defines the schema of a resource within this facility 250 | 'fields': waterpoint_schema, 251 | 'description': "Waterpoint infrastructure in Tanzania", 252 | 'keywords': ["location", "water", "infrastructure"], 253 | 'group': "water", 254 | 'endpoint': "waterpoints"} 255 | 256 | # Services and requests go hand in hand too. Here its the attributes field of a 257 | # service that defines what the schema of a request (report) should look like. 258 | service_schema = { 259 | "service_name": "Communal Water Service", 260 | "attributes": [ 261 | # This defines the schema of a request for this service 262 | # FIXME: how to refer to fields defined in the base schema in 263 | # TaarfaAPI? 264 | {"variable": True, 265 | # FIXME: we need to enforce a foreign key constraint here 266 | "code": "waterpoint_id", 267 | "datatype": "string", 268 | "required": True, 269 | "datatype_description": "Enter a valid Waterpoint id", 270 | "order": 1, 271 | "description": "Unique id of this waterpoint", 272 | "relation": {"resource": "waterpoints", 273 | "field": "wptcode"}}, 274 | {"variable": True, 275 | "code": "status_group", 276 | "datatype": "singlevaluelist", 277 | "required": True, 278 | "datatype_description": "Select an option from the list", 279 | "order": 2, 280 | "description": "Status of this waterpoint", 281 | "values": [{"key": "functional", 282 | "name": "Functional"}, 283 | {"key": "not functional", 284 | "name": "Not functional"}, 285 | {"key": "needs repair", 286 | "name": "Functional, but needs repair"}]}, 287 | {"variable": True, 288 | "code": "status_detail", 289 | "datatype": "string", 290 | "required": False, 291 | "datatype_description": "Describe the status of the waterpoint", 292 | "order": 3, 293 | "description": "Detailed description of the waterpoint status"} 294 | ], 295 | "description": "Location and functionality of a waterpoint", 296 | "keywords": ["location", "infrastructure", "water"], 297 | "group": "water", 298 | "service_code": "wps001" 299 | } 300 | -------------------------------------------------------------------------------- /taarifa_waterpoints/taarifa_waterpoints.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import json 3 | import mimetypes 4 | from eve.render import send_response 5 | from flask import request, send_from_directory 6 | from flask_caching import Cache 7 | 8 | from taarifa_api import api as app, main 9 | cache = Cache(app, config={'CACHE_TYPE': 'simple'}) 10 | 11 | 12 | def pre_get_waterpoints(request, lookup): 13 | """ 14 | Generate spatial query against waterpoint location from lat, lon, 15 | minDistance and maxDistance request arguments. The default value 16 | of maxDistance is 500m. 17 | """ 18 | lat = request.args.get('lat', None, type=float) 19 | lon = request.args.get('lon', None, type=float) 20 | max_distance = request.args.get('maxDistance', 500, type=int) 21 | min_distance = request.args.get('minDistance', None, type=int) 22 | 23 | if lat and lon: 24 | lat = lat if -90 <= lat <= 90 else None 25 | lon = lon if -180 <= lon <= 180 else None 26 | if max_distance: 27 | max_distance = max_distance if max_distance >= 0 else None 28 | if min_distance: 29 | min_distance = min_distance if min_distance >= 0 else None 30 | 31 | if lat and lon: 32 | lookup['location'] = { 33 | '$near': { 34 | "$geometry": { 35 | "type": "Point", 36 | "coordinates": [lat, lon] 37 | }, 38 | } 39 | } 40 | if max_distance: 41 | lookup['location']['$near']['$maxDistance'] = max_distance 42 | if min_distance: 43 | lookup['location']['$near']['$minDistance'] = min_distance 44 | 45 | 46 | def post_waterpoints_get_callback(request, payload): 47 | """Strip all meta data but id from waterpoint payload if 'strip' is set to 48 | a non-zero value in the query string.""" 49 | if request.args.get('strip', 0): 50 | try: 51 | d = json.loads(payload.data) 52 | d['_items'] = [dict((k, v) for k, v in it.items() 53 | if k == '_id' or not k.startswith('_')) 54 | for it in d['_items']] 55 | payload.data = json.dumps(d) 56 | except (KeyError, ValueError): 57 | # If JSON decoding fails or the object has no key _items 58 | pass 59 | 60 | app.name = 'TaarifaWaterpoints' 61 | app.on_post_GET_waterpoints += post_waterpoints_get_callback 62 | app.on_pre_GET_waterpoints += pre_get_waterpoints 63 | 64 | # Override the maximum number of results on a single page 65 | # This is needed by the dashboard 66 | # FIXME: this should eventually be replaced by an incremental load 67 | # which is better for responsiveness 68 | app.config['PAGINATION_LIMIT'] = 70000 69 | if 'waterpoints' in app.config['DOMAIN']: 70 | app.config['DOMAIN']['waterpoints']['transparent_schema_rules'] = True 71 | 72 | 73 | @app.route('/' + app.config['URL_PREFIX'] + '/waterpoints/requests') 74 | @cache.memoize(timeout=24 * 60 * 60) 75 | def waterpoint_requests(): 76 | "Return the unique values for a given field in the waterpoints collection." 77 | # FIXME: Direct call to the PyMongo driver, should be abstracted 78 | reqs = app.data.driver.db['requests'].find( 79 | {'status': 'open'}, 80 | ['attribute.waterpoint_id']) 81 | return send_response('requests', (reqs.distinct('attribute.waterpoint_id'),)) 82 | 83 | 84 | @app.route('/' + app.config['URL_PREFIX'] + '/waterpoints/values/') 85 | @cache.memoize(timeout=24 * 60 * 60) 86 | def waterpoint_values(field): 87 | "Return the unique values for a given field in the waterpoints collection." 88 | # FIXME: Direct call to the PyMongo driver, should be abstracted 89 | resources = app.data.driver.db['resources'] 90 | if request.args: 91 | resources = resources.find(dict(request.args.items())) 92 | return send_response('resources', (sorted(resources.distinct(field)), datetime.now())) 93 | 94 | 95 | @app.route('/' + app.config['URL_PREFIX'] + '/waterpoints/stats') 96 | @cache.memoize(timeout=24 * 60 * 60) 97 | def waterpoint_stats(): 98 | "Return number of waterpoints grouped by district and status." 99 | # FIXME: Direct call to the PyMongo driver, should be abstracted 100 | resources = app.data.driver.db['resources'] 101 | return send_response('resources', (resources.group( 102 | ['district', 'status_group'], dict(request.args.items()), 103 | initial={'count': 0}, 104 | reduce="function(curr, result) {result.count++;}"),)) 105 | 106 | 107 | @app.route('/' + app.config['URL_PREFIX'] + '/waterpoints/status') 108 | @cache.memoize(timeout=24 * 60 * 60) 109 | def waterpoint_status(): 110 | "Return number of waterpoints grouped by status." 111 | # FIXME: Direct call to the PyMongo driver, should be abstracted 112 | resources = app.data.driver.db['resources'] 113 | return send_response('resources', (resources.group( 114 | ['status_group'], dict(request.args.items()), initial={'count': 0}, 115 | reduce="function(curr, result) {result.count++;}"),)) 116 | 117 | 118 | @app.route('/' + app.config['URL_PREFIX'] + '/waterpoints/count_by/') 119 | @cache.memoize(timeout=24 * 60 * 60) 120 | def waterpoint_count_by(field): 121 | "Return number of waterpoints grouped a given field." 122 | # FIXME: Direct call to the PyMongo driver, should be abstracted 123 | resources = app.data.driver.db['resources'] 124 | return send_response('resources', (resources.group( 125 | field.split(','), dict(request.args.items()), initial={'count': 0}, 126 | reduce="function(curr, result) {result.count++;}"),)) 127 | 128 | 129 | @app.route('/' + app.config['URL_PREFIX'] + '/waterpoints/stats_by/') 130 | @cache.memoize(timeout=24 * 60 * 60) 131 | def waterpoint_stats_by(field): 132 | """Return number of waterpoints of a given status grouped by a certain 133 | attribute.""" 134 | # FIXME: Direct call to the PyMongo driver, should be abstracted 135 | resources = app.data.driver.db['resources'] 136 | return send_response('resources', (list(resources.aggregate([ 137 | {"$match": dict(request.args.items())}, 138 | {"$group": {"_id": {field: "$" + field, 139 | "status": "$status_group"}, 140 | "statusCount": {"$sum": 1}, 141 | "populationCount": {"$sum": "$pop_served"}}}, 142 | {"$group": {"_id": "$_id." + field, 143 | "waterpoints": { 144 | "$push": { 145 | "status": "$_id.status", 146 | "count": "$statusCount", 147 | "population": "$populationCount", 148 | }, 149 | }, 150 | "count": {"$sum": "$statusCount"}}}, 151 | {"$project": {"_id": 0, 152 | field: "$_id", 153 | "waterpoints": 1, 154 | "population": 1, 155 | "count": 1}}, 156 | {"$sort": {field: 1}}])),)) 157 | 158 | 159 | @app.route('/scripts/') 160 | def scripts(filename): 161 | return send_from_directory(app.root_path + '/dist/scripts/', filename, cache_timeout=365*24*60*60) 162 | 163 | 164 | @app.route('/styles/') 165 | def styles(filename): 166 | return send_from_directory(app.root_path + '/dist/styles/', filename, cache_timeout=365*24*60*60) 167 | 168 | 169 | @app.route('/images/') 170 | def images(filename): 171 | return send_from_directory(app.root_path + '/dist/images/', filename, cache_timeout=365*24*60*60) 172 | 173 | 174 | @app.route('/data/.topojson') 175 | def topojson(filename): 176 | return send_from_directory(app.root_path + '/dist/data/', filename + '.topojson', 177 | mimetype='application/json') 178 | 179 | 180 | @app.route('/data/') 181 | def data(filename): 182 | mimetype, _ = mimetypes.guess_type(filename) 183 | return send_from_directory(app.root_path + '/dist/data/', filename, mimetype=mimetype) 184 | 185 | 186 | @app.route('/views/') 187 | def views(filename): 188 | return send_from_directory(app.root_path + '/dist/views/', filename) 189 | 190 | 191 | @app.route("/") 192 | def index(): 193 | return send_from_directory(app.root_path + '/dist/', 'index.html') 194 | 195 | 196 | @app.route("/favicon.ico") 197 | def favicon(): 198 | return send_from_directory(app.root_path + '/dist/', 'favicon.ico') 199 | 200 | if __name__ == '__main__': 201 | main() 202 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501 3 | --------------------------------------------------------------------------------