├── log
└── .keep
├── db
└── migrations
│ ├── .keep
│ ├── 20161019_1231_rename_ursus_id_to_incident_id.rb
│ ├── 20161111_1415_involved_person_copy_changes.rb
│ ├── 20161111_1324_remove_contracting_ori_field.rb
│ ├── 20161026_1154_add_secondary_index_to_users.rb
│ └── 20161026_1148_add_secondary_index_to_incidents.rb
├── .rspec
├── app
├── views
│ ├── layouts
│ │ ├── mailer.text.erb
│ │ ├── mailer.html.erb
│ │ ├── doj.html.erb
│ │ ├── partials
│ │ │ ├── _alerts.html.erb
│ │ │ ├── _notices.html.erb
│ │ │ ├── _footer.html.erb
│ │ │ └── _head.html.erb
│ │ ├── devise.html.erb
│ │ ├── application.html.erb
│ │ ├── dashboard.html.erb
│ │ └── error.html.erb
│ ├── errors
│ │ ├── internal_server_error.html.erb
│ │ ├── bad_request.html.erb
│ │ └── not_found.html.erb
│ ├── doj
│ │ ├── analysis.html.erb
│ │ ├── _doj_dashboard_sidenav.html.erb
│ │ ├── overview.html.erb
│ │ ├── window.html.erb
│ │ ├── incidents.html.erb
│ │ └── whosubmitted.html.erb
│ ├── incidents
│ │ ├── _dashboard_sidenav.html.erb
│ │ ├── _dashboard_sidenav_item.html.erb
│ │ ├── index.html.erb
│ │ ├── _past_submissions_pane.html.erb
│ │ └── upload.html.erb
│ ├── devise
│ │ ├── mailer
│ │ │ ├── confirmation_instructions.html.erb
│ │ │ ├── unlock_instructions.html.erb
│ │ │ └── reset_password_instructions.html.erb
│ │ ├── passwords
│ │ │ ├── new.html.erb
│ │ │ └── edit.html.erb
│ │ ├── unlocks
│ │ │ └── new.html.erb
│ │ ├── sessions
│ │ │ └── new.html.erb
│ │ ├── confirmations
│ │ │ └── new.html.erb
│ │ ├── registrations
│ │ │ ├── new.html.erb
│ │ │ ├── _user_account_fields.erb
│ │ │ └── edit.html.erb
│ │ └── shared
│ │ │ └── _links.html.erb
│ ├── feedback
│ │ ├── thank_you.html.erb
│ │ └── new.html.erb
│ ├── application
│ │ ├── _errors.html.erb
│ │ ├── _audit_entry.html.erb
│ │ ├── _review_table.html.erb
│ │ ├── _form_save_buttons.html.erb
│ │ ├── _edit_person_headers.html.erb
│ │ ├── _controls.html.erb
│ │ ├── _display_item.html.erb
│ │ ├── _yes_no_question.html.erb
│ │ ├── _incident_id.html.erb
│ │ ├── _breadcrumbs.html.erb
│ │ ├── _incidents_table.html.erb
│ │ ├── _received_force.html.erb
│ │ └── siteminder_auth_fail.html.erb
│ ├── bridge_mailer
│ │ ├── feedback_email.txt
│ │ └── feedback_email.html.erb
│ ├── pages
│ │ ├── maintenance.html.erb
│ │ └── splash_whitelabel.html.erb
│ └── screeners
│ │ └── forms_not_necessary.html.erb
├── assets
│ ├── images
│ │ ├── favicon.ico
│ │ ├── ursus_logo.png
│ │ ├── ag_seal_gray.png
│ │ ├── favicon_base.png
│ │ ├── fbi-bw-large.png
│ │ ├── bayes_logo_name.png
│ │ ├── demo-watermark.png
│ │ ├── bayes_logo_white.png
│ │ ├── bayes_bridge_1600px.png
│ │ ├── bayes_logo_triangle.png
│ │ ├── bayes_bridge_uof_1600px.png
│ │ ├── bayes_bridge_uof_600px.png
│ │ ├── bayes_bridge_title_1600px.png
│ │ ├── bayes_bridge_title_400px.png
│ │ ├── bayes_bridge_title_600px.png
│ │ ├── bayes_bridge_title_800px.png
│ │ └── ursus_logo_cropped_notext.png
│ ├── javascripts
│ │ ├── splash.js
│ │ ├── doj.js
│ │ ├── incidents.unsaved.js
│ │ ├── upload.js
│ │ ├── analytics.js
│ │ ├── demo.js
│ │ ├── override
│ │ │ └── ie8.js
│ │ ├── util.js
│ │ ├── incidents.address.js
│ │ ├── application.js
│ │ ├── controls.js
│ │ ├── incidents.js
│ │ ├── incidents.charges.js
│ │ └── lib
│ │ │ └── jquery.flexverticalcenter.js
│ └── stylesheets
│ │ ├── general_info.scss
│ │ ├── _alerts.scss
│ │ ├── override
│ │ └── ie.css
│ │ ├── tool_tip.scss
│ │ ├── analytics.scss
│ │ ├── screener.scss
│ │ ├── _controls.scss
│ │ ├── _footer.scss
│ │ ├── upload.scss
│ │ ├── doj_dashboard.scss
│ │ ├── _breadcrumbs.scss
│ │ ├── splash_one_col.scss
│ │ ├── _dashboard_sidenav.scss
│ │ ├── devise.scss
│ │ ├── _style_reset.scss
│ │ └── review.scss
├── helpers
│ ├── application_helper.rb
│ ├── doj_helper.rb
│ ├── path_helper.rb
│ ├── incident_helper.rb
│ └── form_controls_helper.rb
├── controllers
│ ├── involved_civilians_controller.rb
│ ├── involved_officers_controller.rb
│ ├── monitoring_controller.rb
│ ├── splash_controller.rb
│ ├── feedback_controller.rb
│ ├── users
│ │ └── sessions_controller.rb
│ ├── screeners_controller.rb
│ ├── steps_base_controller.rb
│ └── doj_controller.rb
├── models
│ ├── feedback.rb
│ ├── event.rb
│ ├── concerns
│ │ ├── validates_uniqueness.rb
│ │ ├── allows_partial_save.rb
│ │ ├── incident_authorization.rb
│ │ ├── can_lookup_fields.rb
│ │ └── has_only_one_instance.rb
│ ├── constants
│ │ ├── user.rb
│ │ ├── constants.rb
│ │ ├── involved_officer.rb
│ │ ├── general_info.rb
│ │ └── involved_person.rb
│ ├── audit_entry.rb
│ ├── visit.rb
│ ├── agency_status.rb
│ ├── incident_id.rb
│ ├── involved_officer.rb
│ └── screener.rb
├── validators
│ ├── incident_time_validator.rb
│ ├── ori_validator.rb
│ ├── civilian_mental_status_validator.rb
│ ├── subset_validator.rb
│ ├── uniqueness_validator.rb
│ └── incident_date_validator.rb
├── queries
│ ├── query.rb
│ ├── employee_draft_counts_query.rb
│ ├── past_submissions_query.rb
│ ├── get_all_incidents_query.rb
│ ├── dashboard_incidents_query.rb
│ └── compute_analytics_query.rb
├── mailers
│ └── bridge_mailer.rb
└── services
│ ├── bulk_import_service.rb
│ └── incident_stats_service.rb
├── notebooks
├── requirements-notebooks.py
└── Dockerfile.notebooks
├── public
├── favicon.ico
└── robots.txt
├── config
├── initializers
│ ├── gems
│ │ ├── gaffe.rb
│ │ └── dynamo_db.rb
│ ├── rails
│ │ ├── cookies_serializer.rb
│ │ ├── session_store.rb
│ │ ├── filter_parameter_logging.rb
│ │ ├── wrap_parameters.rb
│ │ └── assets.rb
│ └── custom
│ │ ├── lib.rb
│ │ ├── mail.rb
│ │ ├── ori.rb
│ │ ├── branding.rb
│ │ ├── aws.rb
│ │ └── views.rb
├── boot.rb
├── environment.rb
├── aws.yml
├── secrets.yml
├── application.rb
├── routes.rb
└── environments
│ ├── development.rb
│ └── test.rb
├── .dockerignore
├── bin
├── bundle
├── rake
├── rails
├── spring
└── setup
├── config.ru
├── spec
├── factories
│ ├── screener.rb
│ ├── user.rb
│ ├── involved_officer.rb
│ ├── general_info.rb
│ ├── involved_civilian.rb
│ └── incident.rb
├── mailers
│ ├── previews
│ │ └── bridge_mailer_preview.rb
│ └── bridge_mailer_spec.rb
├── requests
│ ├── monitoring_spec.rb
│ ├── maintenance_spec.rb
│ ├── splash_page_spec.rb
│ ├── feedback_spec.rb
│ ├── visit_tracking_spec.rb
│ ├── login_with_devise_spec.rb
│ ├── incident_id_spec.rb
│ └── devise_signup_spec.rb
├── controllers
│ └── application_controller_spec.rb
└── models
│ └── agency_status_spec.rb
├── lib
├── core_extensions
│ ├── time.rb
│ ├── array.rb
│ ├── string.rb
│ └── integer.rb
├── tasks
│ ├── maintenance.rake
│ ├── migrations.rake
│ └── siteminder_cookie_handling.rake
├── bridge_exceptions.rb
└── siteminder.rb
├── data
├── ori.csv.example
├── README.md
├── scripts
│ ├── cleaning_helpers.py
│ ├── generate_js_from_csv.py
│ ├── generate_ori_js.py
│ ├── generate_ori_rb.py
│ ├── clean_qualifiers.py
│ ├── generate_ori_contracts_rb.py
│ └── cleanup_csv.py
└── raw
│ └── vcco_county.csv
├── precompile_and_serve.sh
├── Rakefile
├── Dockerfile.data
├── Dockerfile.mailcatcher
├── .gitattributes
├── Dockerfile.test
├── Dockerfile.test-devise
├── CONTRIBUTING.md
├── .rubocop.metrics.yml
├── LICENSE
├── should_run_ci.sh
├── Dockerfile
├── .gitignore
├── .rubocop.yml
├── Gemfile
└── circle.yml
/log/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/db/migrations/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 | --require spec_helper
3 |
--------------------------------------------------------------------------------
/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/notebooks/requirements-notebooks.py:
--------------------------------------------------------------------------------
1 | xlrd
2 | boto3
3 |
--------------------------------------------------------------------------------
/app/views/errors/internal_server_error.html.erb:
--------------------------------------------------------------------------------
1 | Something went wrong.
2 |
--------------------------------------------------------------------------------
/app/views/errors/bad_request.html.erb:
--------------------------------------------------------------------------------
1 | You are not authorized to view that page.
2 |
--------------------------------------------------------------------------------
/app/views/errors/not_found.html.erb:
--------------------------------------------------------------------------------
1 | You tried to go to a page that doesn't exist.
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/config/initializers/gems/gaffe.rb:
--------------------------------------------------------------------------------
1 | # Gaffe-related configuration goes here.
2 |
3 | Gaffe.enable!
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | log
2 | tmp
3 |
4 | .bundle
5 | .git
6 | *.sublime*
7 | *.tar
8 | dynamodb
9 |
10 |
--------------------------------------------------------------------------------
/app/assets/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/favicon.ico
--------------------------------------------------------------------------------
/app/views/doj/analysis.html.erb:
--------------------------------------------------------------------------------
1 |
Analysis coming soon
2 |
--------------------------------------------------------------------------------
/app/assets/images/ursus_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/ursus_logo.png
--------------------------------------------------------------------------------
/app/assets/images/ag_seal_gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/ag_seal_gray.png
--------------------------------------------------------------------------------
/app/assets/images/favicon_base.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/favicon_base.png
--------------------------------------------------------------------------------
/app/assets/images/fbi-bw-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/fbi-bw-large.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_logo_name.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_logo_name.png
--------------------------------------------------------------------------------
/app/assets/images/demo-watermark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/demo-watermark.png
--------------------------------------------------------------------------------
/notebooks/Dockerfile.notebooks:
--------------------------------------------------------------------------------
1 | FROM jupyter/scipy-notebook:latest
2 | COPY * ./
3 | RUN pip3 install -r requirements-notebooks.py
4 |
--------------------------------------------------------------------------------
/app/assets/images/bayes_logo_white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_logo_white.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_bridge_1600px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_bridge_1600px.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_logo_triangle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_logo_triangle.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_bridge_uof_1600px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_bridge_uof_1600px.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_bridge_uof_600px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_bridge_uof_600px.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_bridge_title_1600px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_bridge_title_1600px.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_bridge_title_400px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_bridge_title_400px.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_bridge_title_600px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_bridge_title_600px.png
--------------------------------------------------------------------------------
/app/assets/images/bayes_bridge_title_800px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/bayes_bridge_title_800px.png
--------------------------------------------------------------------------------
/app/assets/images/ursus_logo_cropped_notext.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bayesimpact/bridge-uof/HEAD/app/assets/images/ursus_logo_cropped_notext.png
--------------------------------------------------------------------------------
/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
3 | load Gem.bin_path('bundler', 'bundle')
4 |
--------------------------------------------------------------------------------
/config/boot.rb:
--------------------------------------------------------------------------------
1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
2 |
3 | require 'bundler/setup' # Set up gems listed in the Gemfile.
4 |
--------------------------------------------------------------------------------
/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require ::File.expand_path('../config/environment', __FILE__)
4 | run Rails.application
5 |
--------------------------------------------------------------------------------
/app/assets/javascripts/splash.js:
--------------------------------------------------------------------------------
1 | $(document).ready(function() {
2 | $('#splash .inner').flexVerticalCenter();
3 | $('#splash-one-col .inner').flexVerticalCenter();
4 | });
5 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/general_info.scss:
--------------------------------------------------------------------------------
1 | .ui-datepicker-current-day a {
2 | background-color: #FDD;
3 | color: red;
4 | border-radius: 2px;
5 | font-weight: bold;
6 | }
7 |
--------------------------------------------------------------------------------
/config/initializers/rails/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | Rails.application.config.action_dispatch.cookies_serializer = :json
4 |
--------------------------------------------------------------------------------
/spec/factories/screener.rb:
--------------------------------------------------------------------------------
1 | FactoryGirl.define do
2 | factory :screener do
3 | id { SecureRandom.uuid }
4 | multiple_agencies false
5 | shots_fired true
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require File.expand_path('../application', __FILE__)
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/config/initializers/rails/session_store.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | Rails.application.config.session_store :cookie_store, key: '_ab71_session'
4 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/_alerts.scss:
--------------------------------------------------------------------------------
1 | .alert-dismissable {
2 | position: relative;
3 | }
4 |
5 | .alert-dismissable > .close {
6 | position: absolute;
7 | top: 0;
8 | right: 6px;
9 | }
10 |
--------------------------------------------------------------------------------
/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | <%= yield %>
7 |
8 |
9 |
--------------------------------------------------------------------------------
/config/initializers/custom/lib.rb:
--------------------------------------------------------------------------------
1 | # Load all of our lib code.
2 | Dir[File.join(Rails.root, "lib", "*.rb")].each { |l| require l }
3 | Dir[File.join(Rails.root, "lib", "core_extensions", "*.rb")].each { |l| require l }
4 |
--------------------------------------------------------------------------------
/lib/core_extensions/time.rb:
--------------------------------------------------------------------------------
1 | # Monkey-patches to the Time class go here.
2 | class Time
3 | def self.this_year
4 | current.year
5 | end
6 |
7 | def self.last_year
8 | this_year - 1
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | # Generic helper methods that don't belong in other Helper modules go here.
2 | module ApplicationHelper
3 | def title(page_title)
4 | content_for(:title) { page_title }
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/data/ori.csv.example:
--------------------------------------------------------------------------------
1 | AGENCY_NAME,COUNTY,ORI,CONTRACTS_TO_ORI
2 | Bayes Impact Police Department,SAN FRANCISCO,CA0012345,
3 | Rubin Sheriff's Office,ORANGE,CA0027777,
4 | Thomas City Police Department,ORANGE,CA0028888,CA0027777
5 |
--------------------------------------------------------------------------------
/app/controllers/involved_civilians_controller.rb:
--------------------------------------------------------------------------------
1 | # Controller for Involved Civilians form.
2 | class InvolvedCiviliansController < InvolvedPersonsController
3 | private
4 |
5 | def person_type
6 | :civilian
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/app/controllers/involved_officers_controller.rb:
--------------------------------------------------------------------------------
1 | # Controller for Involved Officers form.
2 | class InvolvedOfficersController < InvolvedPersonsController
3 | private
4 |
5 | def person_type
6 | :officer
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/precompile_and_serve.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit on first error.
4 | set -e
5 |
6 | # Precompile assets.
7 | if [ "${RAILS_ENV}" == "production" ]; then
8 | rake assets:precompile
9 | fi
10 |
11 | # Serve.
12 | rails server $@
13 |
--------------------------------------------------------------------------------
/app/views/layouts/doj.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for :sidenav do %>
2 | <%= render partial: 'doj_dashboard_sidenav' %>
3 | <% end %>
4 |
5 | <% content_for :panel do %>
6 | <%= yield %>
7 | <% end %>
8 | <%= render template: "layouts/dashboard" %>
9 |
--------------------------------------------------------------------------------
/config/initializers/rails/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Configure sensitive parameters which will be filtered from the log file.
4 | Rails.application.config.filter_parameters += [:password]
5 |
--------------------------------------------------------------------------------
/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | begin
3 | load File.expand_path('../spring', __FILE__)
4 | rescue LoadError => e
5 | raise unless e.message.include?('spring')
6 | end
7 | require_relative '../config/boot'
8 | require 'rake'
9 | Rake.application.run
10 |
--------------------------------------------------------------------------------
/app/views/incidents/_dashboard_sidenav.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <% @status_texts.each do |status, text| %>
3 | <%= render partial: "dashboard_sidenav_item", locals: {text: text, status: status ? status.to_sym : nil} %>
4 | <% end %>
5 |
6 |
--------------------------------------------------------------------------------
/app/views/devise/mailer/confirmation_instructions.html.erb:
--------------------------------------------------------------------------------
1 | Welcome <%= @email %>!
2 |
3 | You can confirm your account email through the link below:
4 |
5 | <%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>
6 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require File.expand_path('../config/application', __FILE__)
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/app/models/feedback.rb:
--------------------------------------------------------------------------------
1 | # A piece of user feedback.
2 | class Feedback
3 | include Dynamoid::Document
4 | include CanLookupFields
5 |
6 | belongs_to :user
7 |
8 | field :source, :string
9 | field :content, :string
10 |
11 | validates :content, presence: true
12 | end
13 |
--------------------------------------------------------------------------------
/app/views/feedback/thank_you.html.erb:
--------------------------------------------------------------------------------
1 | <%= title "Thank you" %>
2 |
3 |
4 |
5 |
6 |
7 | <%= link_to 'Back to Dashboard', '/', class: 'btn-bayes' %>
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/views/layouts/partials/_alerts.html.erb:
--------------------------------------------------------------------------------
1 | <% if alert %>
2 | <%= alert %>
3 |
4 | ×
5 |
6 |
7 | <% end %>
--------------------------------------------------------------------------------
/app/views/layouts/partials/_notices.html.erb:
--------------------------------------------------------------------------------
1 | <% if notice %>
2 | <%= notice %>
3 |
4 | ×
5 |
6 |
7 | <% end %>
--------------------------------------------------------------------------------
/app/views/incidents/_dashboard_sidenav_item.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <% if @incident_counts_by_type[status] %> (<%= @incident_counts_by_type[status]%>) <% end %><%= link_to text, dashboard_path(status: status) %>
3 |
4 |
--------------------------------------------------------------------------------
/config/aws.yml:
--------------------------------------------------------------------------------
1 | # AWS configuration.
2 | development:
3 | access_key_id: AKIA1111111111111UA
4 | secret_access_key: secret
5 | endpoint: http://localhost:8000
6 |
7 | test:
8 | access_key_id: AKIA1111111111111UA
9 | secret_access_key: secret
10 | endpoint: http://localhost:8001
11 |
--------------------------------------------------------------------------------
/Dockerfile.data:
--------------------------------------------------------------------------------
1 | FROM tailordev/pandas:0.17.1
2 |
3 | RUN mkdir -p /code/data
4 | WORKDIR /code
5 | COPY data/raw data/raw
6 | COPY data/scripts data/scripts
7 | COPY app app
8 | COPY Makefile .
9 |
10 | RUN /usr/bin/make clean
11 | RUN /usr/local/bin/pip install xlrd
12 |
13 | CMD ["make", "all"]
14 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | begin
3 | load File.expand_path('../spring', __FILE__)
4 | rescue LoadError => e
5 | raise unless e.message.include?('spring')
6 | end
7 | APP_PATH = File.expand_path('../../config/application', __FILE__)
8 | require_relative '../config/boot'
9 | require 'rails/commands'
10 |
--------------------------------------------------------------------------------
/app/assets/javascripts/doj.js:
--------------------------------------------------------------------------------
1 | $(function(){
2 | // Add autocomplete for agency ori search bar
3 | $('#doj_incidents_ori_box').autocomplete({
4 | source: window.AGENCY_ORI,
5 | select: function (event, ui) {
6 | window.location.href = '/doj/incidents/' + ui.item.label;
7 | }
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/lib/tasks/maintenance.rake:
--------------------------------------------------------------------------------
1 | namespace :maintenance do
2 | desc "Start maintenance mode"
3 | task start: :environment do
4 | GlobalState.start_maintenance_mode!
5 | end
6 |
7 | desc "Stop maintenance mode"
8 | task stop: :environment do
9 | GlobalState.stop_maintenance_mode!
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/views/layouts/devise.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= render "layouts/partials/head" %>
4 |
5 | <%= render "layouts/partials/nav" %>
6 |
7 |
8 |
9 | <%= render "layouts/partials/alerts" %>
10 | <%= yield %>
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/views/devise/mailer/unlock_instructions.html.erb:
--------------------------------------------------------------------------------
1 | Hello <%= @resource.email %>!
2 |
3 | Your account has been locked due to an excessive number of unsuccessful sign in attempts.
4 |
5 | Click the link below to unlock your account:
6 |
7 | <%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>
8 |
--------------------------------------------------------------------------------
/app/controllers/monitoring_controller.rb:
--------------------------------------------------------------------------------
1 | # Controller for monitoring endpoints.
2 | # Note this controller inherits from ActionController::Base,
3 | # NOT ApplicationController, so it doesn't perform use authentication,
4 | # tracking, etc.
5 | class MonitoringController < ActionController::Base
6 | def ping
7 | render plain: "pong"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app/models/event.rb:
--------------------------------------------------------------------------------
1 | # An single controller action, tracked by Ahoy.
2 | # Each individual user visit will likely consist of many events.
3 | class Event
4 | include Dynamoid::Document
5 |
6 | # associations
7 | belongs_to :visit
8 | belongs_to :user
9 |
10 | # fields
11 | field :name
12 | field :properties
13 | field :time, :datetime
14 | end
15 |
--------------------------------------------------------------------------------
/app/validators/incident_time_validator.rb:
--------------------------------------------------------------------------------
1 | # Validates that a time is in the appropriate format.
2 | class IncidentTimeValidator < ActiveModel::EachValidator
3 | def validate_each(record, attribute, value)
4 | unless value =~ /^(([01][0-9])|(2[0-3]))[0-5][0-9]$/i
5 | record.errors[attribute] << "must be 4 digits, between 0000 and 2359"
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/app/validators/ori_validator.rb:
--------------------------------------------------------------------------------
1 | # Validates that the incident ORI is allowed.
2 | class OriValidator < ActiveModel::EachValidator
3 | def validate_each(record, attribute, value)
4 | user = record.incident.target.try(:user)
5 |
6 | if user && user.allowed_oris.exclude?(value)
7 | record.errors[attribute] << "invalid ORI: #{value}"
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= render "layouts/partials/head" %>
4 |
5 |
6 | <%= render "layouts/partials/nav" %>
7 |
8 | <%= content_for?(:body) ? yield(:body) : yield %>
9 |
10 | <%= render "layouts/partials/footer" %>
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/override/ie.css:
--------------------------------------------------------------------------------
1 | /* IE(8,9)-specific CSS hacks. */
2 |
3 | input:not([type="checkbox"]) {
4 | /* Prevents text being cut off in text input fields on IE9. */
5 | line-height: 1;
6 | }
7 |
8 | table.table-bordered {
9 | /* Without this, borders don't always render on bootstrap tables due to IE weirdness. */
10 | border-collapse: separate !important;
11 | }
12 |
--------------------------------------------------------------------------------
/app/views/application/_errors.html.erb:
--------------------------------------------------------------------------------
1 | <% record.errors.clear if record['partial'] %>
2 | <% if (record.is_a? Array and record.map { |e| e.errors.any? }.any?) or (!record.is_a? Array and record.errors.any?) %>
3 |
4 |
There were problems with your form. Scroll down to see highlighted errors.
5 |
6 | <% end %>
--------------------------------------------------------------------------------
/Dockerfile.mailcatcher:
--------------------------------------------------------------------------------
1 | FROM ruby:2.2.3
2 |
3 | MAINTAINER everett.wetchler@bayesimpact.org
4 |
5 | RUN apt-get update -qq && apt-get install -y build-essential libpq-dev
6 | RUN gem install mailcatcher
7 |
8 | EXPOSE 1025
9 | EXPOSE 1080
10 | #CMD ["/usr/local/bundle/bin/mailcatcher", "-f", "--ip", "0.0.0.0", "--http-ip", "0.0.0.0"]
11 | CMD ["/usr/local/bundle/bin/mailcatcher", "-f"]
12 |
--------------------------------------------------------------------------------
/app/helpers/doj_helper.rb:
--------------------------------------------------------------------------------
1 | # Helper methods pertaining to the DOJ dashboard.
2 | module DojHelper
3 | def submission_window_button_text
4 | if GlobalState.submission_open?
5 | "Close the window now"
6 | elsif GlobalState.submission_not_yet_open?
7 | "Open the window now"
8 | else
9 | "Re-open the window for #{Time.current.year - 1}"
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/app/models/concerns/validates_uniqueness.rb:
--------------------------------------------------------------------------------
1 | # Adds validates_uniqueness_of() to models (see validators/uniqueness_validator.rb).
2 | module ValidatesUniqueness
3 | extend ActiveSupport::Concern
4 |
5 | # [Class methods.]
6 | module ClassMethods
7 | def validates_uniqueness_of(*atts)
8 | validates_with(UniquenessValidator, _merge_attributes(atts))
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/spec/factories/user.rb:
--------------------------------------------------------------------------------
1 | FactoryGirl.define do
2 | factory :dummy_user, class: User do
3 | first_name 'Dummy'
4 | last_name 'User'
5 | email 'test@example.com'
6 | password 'password' if Rails.configuration.x.login.use_devise?
7 | ori 'ORI01234'
8 | role Rails.configuration.x.roles.admin
9 | department 'Foo Police Department'
10 | user_id 'some_user_id_123'
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/spec/mailers/previews/bridge_mailer_preview.rb:
--------------------------------------------------------------------------------
1 | # Preview all emails at http://localhost:3000/rails/mailers/ursus_mailer
2 | class BridgeMailerPreview < ActionMailer::Preview
3 | def feedback_email
4 | feedback = Feedback.new(source: "Some part of the page (test)",
5 | content: "This was confusing (test)")
6 | BridgeMailer.feedback_email(feedback, User.all.first)
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | app/assets/javascripts/lib/* linguist-vendored
2 | app/assets/stylesheets/lib/* linguist-vendored
3 |
4 | app/assets/javascripts/crimes.js linguist-vendored
5 | app/assets/javascripts/crime_qualifiers.js linguist-vendored
6 | app/assets/javascripts/police_agency_oris.js linguist-vendored
7 | app/models/constants/department_by_ori.rb linguist-vendored
8 | app/models/constants/contracting_oris.rb linguist-vendored
9 |
--------------------------------------------------------------------------------
/app/controllers/splash_controller.rb:
--------------------------------------------------------------------------------
1 | # Controller for splash pages.
2 | class SplashController < ApplicationController
3 | skip_before_action :consider_splash
4 |
5 | def splash_show
6 | render "pages/splash_#{Rails.configuration.x.branding.name}", layout: nil
7 | end
8 |
9 | def splash_dismiss
10 | @current_user.update_attributes(splash_complete: true)
11 | redirect_to dashboard_path
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/app/views/application/_audit_entry.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= audit_entry_description(entry) %>
4 | <% if show_changed_fields_for_audit_entry?(entry) %>
5 |
6 | <% entry.changed_fields.each do |f| %>
7 | <%= f.key %> from <%= f.old_value || "n/a" %> to <%= f.new_value || "n/a" %>
8 | <% end %>
9 |
10 | <% end %>
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/views/bridge_mailer/feedback_email.txt:
--------------------------------------------------------------------------------
1 | URSUS feedback from <%= @user.full_name %>
2 | Source:
3 |
4 | <%= @feedback.source %>
5 |
6 | Content:
7 |
8 | <%= @feedback.content %>
9 |
10 | User info:
11 |
12 | Name: <%= @user.full_name %>
13 | ORI: <%= @user.ori %>
14 | Department: <%= @user.department %>
15 | Role: <%= @user.role %>
16 | Email: <%= @user.email %>
17 |
18 | Sent at <%= @feedback.created_at %>
19 |
--------------------------------------------------------------------------------
/lib/core_extensions/array.rb:
--------------------------------------------------------------------------------
1 | # Monkey-patches to the Array class go here.
2 | class Array
3 | # (Used in FactoryGirl factories.)
4 | # Usually samples a single element from an array, but sometimes samples two.
5 | def sample_one_or_two_elements
6 | sample(rand(5).zero? ? 2 : 1)
7 | end
8 |
9 | # (Used in tests.)
10 | # Check if an array is sorted.
11 | def sorted?
12 | self == sort
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/app/validators/civilian_mental_status_validator.rb:
--------------------------------------------------------------------------------
1 | # Validates that a mental status list doesn't include both 'None' and something else.
2 | class CivilianMentalStatusValidator < ActiveModel::EachValidator
3 | def validate_each(record, attribute, value)
4 | if value.present? && value.include?('None') && value.length >= 2
5 | record.errors[attribute] << "cannot specify both 'None' and another value"
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/tasks/migrations.rake:
--------------------------------------------------------------------------------
1 | require 'dynamodb/migration'
2 |
3 | namespace :db do
4 | desc "Run all DynamoDB migrations"
5 | task migrate: :environment do
6 | aws_client = Dynamoid.adapter.adapter.client
7 | DynamoDB::Migration.run_all_migrations(
8 | client: aws_client,
9 | path: Rails.root.join('db/migrations'),
10 | migration_table_name: "#{Dynamoid.config.namespace}_migrations"
11 | )
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/config/initializers/rails/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # This file contains settings for ActionController::ParamsWrapper which
4 | # is enabled by default.
5 |
6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7 | ActiveSupport.on_load(:action_controller) do
8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters)
9 | end
10 |
--------------------------------------------------------------------------------
/data/README.md:
--------------------------------------------------------------------------------
1 | ## `data/` Contents
2 |
3 | - `data/raw` contains raw files received from our California DoJ partners, roughly 2016-03-14.
4 | - `data/scripts` contains Python scripts that are run by the Makefile to process the raw data.
5 | - `ori.csv` **must be provided** for the application to run. If you don't have a list of police agencies in your state, you can copy over the provided example file:
6 | ```
7 | cp data/ori.csv.example data/ori.csv
8 | ```
9 |
--------------------------------------------------------------------------------
/spec/requests/monitoring_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe '[Monitoring endpoints, no login]', type: :request do
4 | it '/ping.html renders "pong"' do
5 | visit '/ping.html'
6 | expect(page.status_code).to eq(200)
7 | expect(page.body).to eq("pong")
8 | end
9 |
10 | it '/ping renders "pong"' do
11 | visit 'ping'
12 | expect(page.status_code).to eq(200)
13 | expect(page.body).to eq("pong")
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/config/initializers/rails/assets.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Version of your assets, change this if you want to expire all your assets.
4 | Rails.application.config.assets.version = '1.0'
5 |
6 | # Precompile additional assets.
7 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
8 | Rails.application.config.assets.precompile += %w(fastforward.js override/ie8.js override/ie.css)
9 |
--------------------------------------------------------------------------------
/app/views/devise/mailer/reset_password_instructions.html.erb:
--------------------------------------------------------------------------------
1 | Hello <%= @resource.email %>!
2 |
3 | Someone has requested a link to change your password. You can do this through the link below.
4 |
5 | <%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>
6 |
7 | If you didn't request this, please ignore this email.
8 | Your password won't change until you access the link above and create a new one.
9 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/tool_tip.scss:
--------------------------------------------------------------------------------
1 | .help-tip {
2 | margin-left: 6px;
3 | text-align: center;
4 | background-color: #BCDBEA;
5 | border-radius: 50%;
6 | width: 16px;
7 | height: 16px;
8 | font-size: 16px;
9 | line-height: 16px;
10 | display: inline-block;
11 | cursor: pointer;
12 |
13 | &:hover {
14 | background-color: #666;
15 | }
16 |
17 | &:before{
18 | content: '?';
19 | font-weight: bold;
20 | color: #fff;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/assets/javascripts/incidents.unsaved.js:
--------------------------------------------------------------------------------
1 | var unsavedChanges = false;
2 |
3 | $(function () {
4 | $('#incidentStep').on('change keyup keydown', 'input, textarea, select', function (e) {
5 | unsavedChanges = true;
6 | });
7 |
8 | $(document).submit(function() {
9 | unsavedChanges = false;
10 | });
11 |
12 | $(window).on('beforeunload', function () {
13 | if (unsavedChanges) {
14 | return 'You have unsaved changes.';
15 | }
16 | });
17 | });
--------------------------------------------------------------------------------
/app/models/constants/user.rb:
--------------------------------------------------------------------------------
1 | module Constants
2 | # Constants related to the User model.
3 | module User
4 | SITEMINDER_KEY_MAPPING = {
5 | 'givenname' => :first_name,
6 | 'sn' => :last_name,
7 | 'mail' => :email,
8 | 'dojORI' => :ori,
9 | 'dojagencyName' => :department,
10 | 'SM_USERGROUPS' => :role,
11 | 'SM_USERLOGINNAME' => :user_id
12 | }.freeze
13 | USER_FIELDS = SITEMINDER_KEY_MAPPING.values.freeze
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/queries/query.rb:
--------------------------------------------------------------------------------
1 | # Base class for queries. Subclasses must implement #perform_query.
2 | class Query
3 | def run(params)
4 | # Request-level memoization.
5 | key = params.merge(query: self.class.name).to_s
6 | result = RequestStore.store[key] ||= perform_query(params)
7 | result.clone # (Just in case another query tries to mutate the result.)
8 | end
9 |
10 | private
11 |
12 | def perform_query
13 | raise UnimplementedError.new
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/views/devise/passwords/new.html.erb:
--------------------------------------------------------------------------------
1 | Forgotten password
2 |
3 | <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
4 | <%= devise_error_messages! %>
5 |
6 |
7 | <%= f.email_field :email, autofocus: true, placeholder: "Email" %>
8 |
9 |
10 |
11 | <%= f.submit "Send instructions" %>
12 |
13 | <% end %>
14 |
15 | <%= render "devise/shared/links" %>
16 |
--------------------------------------------------------------------------------
/Dockerfile.test:
--------------------------------------------------------------------------------
1 | FROM bayesimpact/ruby-2.2.3-phantomjs
2 |
3 | RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
4 | RUN mkdir /bridge-uof
5 | WORKDIR /bridge-uof
6 | COPY Gemfile /bridge-uof/Gemfile
7 | COPY Gemfile.lock /bridge-uof/Gemfile.lock
8 | RUN bundle install --with=test
9 |
10 | COPY . /bridge-uof
11 | RUN sed -i -e "s/localhost:/testdb:/" /bridge-uof/config/aws.yml
12 | ENTRYPOINT ["bundle", "exec", "rspec", "--profile", "15", "--format", "documentation"]
13 |
--------------------------------------------------------------------------------
/Dockerfile.test-devise:
--------------------------------------------------------------------------------
1 | FROM bayesimpact/ruby-2.2.3-phantomjs
2 |
3 | RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
4 | RUN mkdir /bridge-uof
5 | WORKDIR /bridge-uof
6 | COPY Gemfile /bridge-uof/Gemfile
7 | COPY Gemfile.lock /bridge-uof/Gemfile.lock
8 | RUN bundle install --with=test
9 |
10 | COPY . /bridge-uof
11 | RUN sed -i -e "s/localhost:8001/testdb-devise:8002/" /bridge-uof/config/aws.yml
12 | ENTRYPOINT ["bundle", "exec", "rspec", "--format", "documentation"]
13 |
--------------------------------------------------------------------------------
/app/assets/javascripts/upload.js:
--------------------------------------------------------------------------------
1 | $(function(){
2 | $('#showHideSchema').toggleLinkFor($('#schema'));
3 |
4 | $('#xmlSchema').hide();
5 | $('.toggle-schema').click(function () {
6 | $('#jsonSchema').toggle();
7 | $('#xmlSchema').toggle();
8 | });
9 |
10 | $('#upload-control').submit(function (event) {
11 | var fileButton = $(".btn-file");
12 | $('.btn-file').css({'opacity': 0.5, 'background-color': 'gray'});
13 | $("#fileButtonText").html("Uploading....");
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/app/views/doj/_doj_dashboard_sidenav.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <% [
3 | ["Overview", "overview"],
4 | ["Submission window", "window"],
5 | ["Who has submitted?", "whosubmitted"],
6 | ["View incidents", "incidents"],
7 | ["Analysis", "analysis"]
8 | ].each do |item| %>
9 |
10 | <%= link_to item[0], url_for(action: item[1], controller: 'doj') %>
11 |
12 | <% end %>
13 |
14 |
--------------------------------------------------------------------------------
/app/views/doj/overview.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
Welcome, <%= @current_user.full_name %>
3 |
4 |
You are a DOJ superuser. Use the menu on the left to
5 | perform your duties. You can
6 | open and close the submission window,
7 | see which agencies have submitted,
8 | view and edit incidents submitted by police agencies,
9 | and analyze the incident submissions in aggregate.
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/views/application/_review_table.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <% for key in display_fields %>
3 |
4 | <%= render partial: "display_item", locals: {
5 | key: labels[key] || key.to_s.custom_humanize,
6 | value: (data.send(key) rescue nil),
7 | is_fbi_field: fbi_fields.include?(key),
8 | partial: local_assigns[:partials].try { |p| p[key.to_sym] }
9 | } %>
10 |
11 | <% end %>
12 |
13 |
--------------------------------------------------------------------------------
/app/models/audit_entry.rb:
--------------------------------------------------------------------------------
1 | # An entry in the audit log.
2 | class AuditEntry
3 | include Dynamoid::Document
4 |
5 | has_one :user
6 | has_many :changed_fields
7 | field :custom_text, :string
8 | field :page, :string
9 | field :is_new, :boolean
10 | end
11 |
12 | # A field that has been changed within an AuditEntry.
13 | class ChangedField
14 | include Dynamoid::Document
15 |
16 | belongs_to :audit_entry
17 | field :key, :string
18 | field :old_value, :string
19 | field :new_value, :string
20 | end
21 |
--------------------------------------------------------------------------------
/app/views/devise/unlocks/new.html.erb:
--------------------------------------------------------------------------------
1 | Resend unlock instructions
2 |
3 | <%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %>
4 | <%= devise_error_messages! %>
5 |
6 |
7 | <%= f.label :email %>
8 | <%= f.email_field :email, autofocus: true %>
9 |
10 |
11 |
12 | <%= f.submit "Resend unlock instructions" %>
13 |
14 | <% end %>
15 |
16 | <%= render "devise/shared/links" %>
17 |
--------------------------------------------------------------------------------
/config/initializers/custom/mail.rb:
--------------------------------------------------------------------------------
1 | # Load and validate email configuration.
2 |
3 | mail_config = Rails.application.config.x.mail
4 |
5 | unless ENV['MAIL_FROM'].present?
6 | raise 'Must set MAIL_FROM to an email address for the app\'s outgoing emails.'
7 | end
8 | mail_config.from_address = ENV['MAIL_FROM']
9 |
10 | unless ENV['FEEDBACK_MAIL_TO'].present?
11 | raise 'Must set FEEDBACK_MAIL_TO to an email address for the app to send user feedback.'
12 | end
13 | mail_config.feedback_to_address = ENV['FEEDBACK_MAIL_TO']
14 |
--------------------------------------------------------------------------------
/app/views/devise/sessions/new.html.erb:
--------------------------------------------------------------------------------
1 | Login
2 |
3 | <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
4 |
5 | <%= f.email_field :email, autofocus: true, placeholder: "Email" %>
6 |
7 |
8 |
9 | <%= f.password_field :password, autocomplete: "off", placeholder: "Password" %>
10 |
11 |
12 |
13 | <%= f.submit "Login" %>
14 |
15 | <% end %>
16 |
17 | <%= render "devise/shared/links" %>
18 |
--------------------------------------------------------------------------------
/bin/spring:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | # This file loads spring without using Bundler, in order to be fast.
4 | # It gets overwritten when you run the `spring binstub` command.
5 |
6 | unless defined?(Spring)
7 | require 'rubygems'
8 | require 'bundler'
9 |
10 | if (match = Bundler.default_lockfile.read.match(/^GEM$.*?^ (?: )*spring \((.*?)\)$.*?^$/m))
11 | Gem.paths = { 'GEM_PATH' => [Bundler.bundle_path.to_s, *Gem.path].uniq }
12 | gem 'spring', match[1]
13 | require 'spring/binstub'
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/views/bridge_mailer/feedback_email.html.erb:
--------------------------------------------------------------------------------
1 | URSUS feedback from <%= @user.full_name %>
2 | Source:
3 |
4 | <%= @feedback.source %>
5 |
6 | Content:
7 |
8 | <%= @feedback.content %>
9 |
10 | User info:
11 |
12 | Name: <%= @user.full_name %>
13 | ORI: <%= @user.ori %>
14 | Department: <%= @user.department %>
15 | Role: <%= @user.role %>
16 | Email: <%= @user.email %>
17 |
18 | Sent at <%= @feedback.created_at %>
19 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Contributing
2 |
3 | We welcome contributions to Bridge.
4 |
5 | By submitting a pull request, you represent that you have the right to license your contribution to Bayes Impact and the community, and agree by submitting the patch that your contributions are licensed under the [Apache License 2.0](https://opensource.org/licenses/Apache-2.0).
6 |
7 | Before submitting the pull request, please make sure you have tested your changes
8 | and that they follow our code quality guidelines (a good way to check is to run the
9 | `rubocop` linter).
10 |
--------------------------------------------------------------------------------
/app/controllers/feedback_controller.rb:
--------------------------------------------------------------------------------
1 | # Controller for feedback form.
2 | class FeedbackController < ApplicationController
3 | def new
4 | @feedback = Feedback.new
5 | end
6 |
7 | def create
8 | @feedback = Feedback.new(get_formatted_params(params, Feedback))
9 | if @feedback.save
10 | @current_user.feedbacks << @feedback
11 | BridgeMailer.feedback_email(@feedback, @current_user).deliver_now
12 | redirect_to :thank_you
13 | else
14 | render :new
15 | end
16 | end
17 |
18 | def thank_you
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/.rubocop.metrics.yml:
--------------------------------------------------------------------------------
1 | # To assess code quality metrics with rubocop (not computed normally), run:
2 | # rubocop -c .rubocop.metrics.yml --only Metrics app config lib vendor
3 |
4 | Metrics/AbcSize:
5 | Max: 9999 # ABC, CyclomaticComplexity, and PerceivedComplexity are very similar metrics, so disable all but the last.
6 |
7 | Metrics/CyclomaticComplexity:
8 | Max: 9999 # ABC, CyclomaticComplexity, and PerceivedComplexity are very similar metrics, so disable all but the last.
9 |
10 | Metrics/LineLength:
11 | Max: 120
12 |
13 | Metrics/MethodLength:
14 | Max: 15
15 |
--------------------------------------------------------------------------------
/app/assets/javascripts/analytics.js:
--------------------------------------------------------------------------------
1 | $(function(){
2 | // Selecting a new year automatically redirects to that page.
3 | $('#analytics-year').change(function () {
4 | window.location.href = $(this).val();
5 | });
6 | });
7 |
8 | function renderPivotTable(year) {
9 | var jsonPath = year ? "/incidents.json?year=" + year : "/incidents.json";
10 | $.getJSON(jsonPath, function(data) {
11 | $("#pivot-table").pivotUI(data, {
12 | rows: ["Officer Force Used"],
13 | cols: ["Civilian Armed?"],
14 | rendererName: "Table"
15 | });
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/app/assets/javascripts/demo.js:
--------------------------------------------------------------------------------
1 | $(function(){
2 | // #hideDemoWarning button immediately hides the demo warning and sets a cookie.
3 | $('#hideDemoWarning').click(function () {
4 | $('#demoWarning').hide();
5 | positionControls(); // (see controls.js)
6 |
7 | document.cookie = 'hide_demo_msg=true';
8 | });
9 | $('#generateFakeIncidentsButton').click(function (event) {
10 | $("#dashboardColumn div.alert").css("opacity", "0.5");
11 | $(this).val("Generating....").prop("disabled", true);
12 | $(this).parents('form').submit();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/app/queries/employee_draft_counts_query.rb:
--------------------------------------------------------------------------------
1 | # Returns the number of drafts still being filled out by each employee in the user's ORI.
2 | class EmployeeDraftCountsQuery < Query
3 | private
4 |
5 | def perform_query(params)
6 | raise ActionController::BadRequest.new unless params[:user].admin?
7 |
8 | all_incidents = GetAllIncidentsQuery.new.run(user: params[:user])
9 |
10 | all_incidents.each_with_object(Hash.new(0)) do |i, counts|
11 | counts[i.user.full_name] += 1 if i.draft? && (i.user != params[:user])
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/spec/requests/maintenance_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe '[Maintenance mode]', type: :request do
4 | it 'Maintenance mode can be switched on and off' do
5 | login(dont_handle_splash: true)
6 |
7 | visit root_path
8 | expect(page).to have_content('Welcome')
9 |
10 | GlobalState.start_maintenance_mode!
11 |
12 | visit root_path
13 | expect(page).to have_content('Down for Maintenance')
14 |
15 | GlobalState.stop_maintenance_mode!
16 |
17 | visit root_path
18 | expect(page).to have_content('Welcome')
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/app/mailers/bridge_mailer.rb:
--------------------------------------------------------------------------------
1 | # Mailer class that sends out feedback emails.
2 | class BridgeMailer < ActionMailer::Base
3 | default from: Rails.configuration.x.mail.from_address
4 | layout 'mailer'
5 |
6 | def feedback_email(feedback, user)
7 | @feedback = feedback
8 | @user = user
9 |
10 | mail(to: Rails.configuration.x.mail.feedback_to_address,
11 | cc: (user.email unless Rails.configuration.x.login.use_demo?),
12 | subject: "#{Rails.configuration.x.branding.ursus? ? 'URSUS feedback' : 'Feedback'} from #{@user.full_name}")
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/core_extensions/string.rb:
--------------------------------------------------------------------------------
1 | # Monkey-patches to the String class go here.
2 | class String
3 | # http://stackoverflow.com/questions/1235863/test-if-a-string-is-basically-an-integer-in-quotes-using-ruby
4 | def integer?
5 | to_i.to_s == self
6 | end
7 |
8 | # Convert the string to an integer only if it is actually an integer, return nil otherwise.
9 | def to_i_safe
10 | integer? ? to_i : nil
11 | end
12 |
13 | # Like #humanize but handles *_id strings better.
14 | def custom_humanize
15 | end_with?('_id') ? (humanize + " ID") : humanize
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/validators/subset_validator.rb:
--------------------------------------------------------------------------------
1 | # Validates that a record is a subset of a given list.
2 | class SubsetValidator < ActiveModel::EachValidator
3 | include ActiveModel::Validations::Clusivity
4 |
5 | def validate_each(record, attribute, values)
6 | return if values.nil?
7 |
8 | values.uniq.each do |value|
9 | unless include?(record, value)
10 | record.errors.add(attribute, :inclusion, options.except(:in, :within).merge!(value: value))
11 | end
12 | end
13 |
14 | record.errors.add(attribute, :duplicate) unless values.uniq.length == values.length
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Bridge is (C) 2016 Bayes Impact
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 |
--------------------------------------------------------------------------------
/app/helpers/path_helper.rb:
--------------------------------------------------------------------------------
1 | # Helpers related to paths.
2 | # Also included in ApplicationController.
3 | module PathHelper
4 | def edit_person_path(type, incident, num)
5 | send("edit_incident_involved_#{type}_path", incident, num)
6 | end
7 |
8 | def new_person_path(type, incident)
9 | send("new_incident_involved_#{type}_path", incident)
10 | end
11 |
12 | def analytics_path(year)
13 | dashboard_path(status: 'analytics', year: year)
14 | end
15 |
16 | def incident_base_url(incident_url)
17 | parts = incident_url.split("/")
18 | "/#{parts[1]}/#{parts[2]}"
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/app/views/devise/confirmations/new.html.erb:
--------------------------------------------------------------------------------
1 | Resend confirmation instructions
2 |
3 | <%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %>
4 | <%= devise_error_messages! %>
5 |
6 |
7 | <%= f.email_field :email, autofocus: true, value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email), placeholder: "Email" %>
8 |
9 |
10 |
11 | <%= f.submit "Resend confirmation instructions" %>
12 |
13 | <% end %>
14 |
15 | <%= render "devise/shared/links" %>
16 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/analytics.scss:
--------------------------------------------------------------------------------
1 | .visualization {
2 | position: relative;
3 | }
4 |
5 | // We add a persistent watermark to charts in the demo app, so all screenshots
6 | // will clearly show that the data is not live.
7 | .demo-watermark {
8 | position: absolute;
9 | top: 0;
10 | left: 0;
11 | height: 100%;
12 | width: 100%;
13 | opacity: 0.5;
14 | background-image: url(asset-path('demo-watermark.png'));
15 | background-repeat: repeat;
16 | z-index: 99;
17 | pointer-events: none;
18 | }
19 |
20 | #pivotInstructions {
21 | font-size: 16px;
22 | }
23 |
24 | #notes {
25 | margin-top: 1em;
26 | }
27 |
--------------------------------------------------------------------------------
/app/views/layouts/dashboard.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for :body do %>
2 | <%= render partial: "controls" %>
3 |
4 |
5 |
6 | <%= yield :sidenav %>
7 |
8 |
9 | <% if flash[:notice] %>
10 |
<%= flash[:notice] %>
11 | <% end %>
12 | <%= yield :panel %>
13 |
14 |
15 |
16 |
17 | <% end %>
18 | <%= render template: "layouts/application" %>
--------------------------------------------------------------------------------
/app/views/application/_form_save_buttons.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <% if defined? text then %>
4 | <%= text %>
5 | <% else %>
6 | Save and Continue
7 | <% end %>
8 | <% if (not defined? use_arrow) or use_arrow then %>
9 |
10 | <% end %>
11 |
12 | <% unless local_assigns[:no_partial_save] %>
13 | or
14 | <%= link_to "I'm not done yet, save my progress so far", {}, id: "save_and_return" %>
15 | <% end %>
--------------------------------------------------------------------------------
/should_run_ci.sh:
--------------------------------------------------------------------------------
1 | BRANCH=$(git rev-parse --abbrev-ref HEAD)
2 |
3 | # Always run CI in master.
4 | if [ "$BRANCH" != "master" ]; then
5 | # Do not run CI if the branch doesn't exist anymore.
6 | if [ -z "$(git ls-remote --heads origin "$BRANCH")" ]; then
7 | echo "Branch doesn't exist anymore - skipping tests ..."
8 | touch skip-tests
9 | exit
10 | fi
11 |
12 | # Do not run CI if the branch has already been updated.
13 | if [ "$(git ls-remote --heads origin "$BRANCH" | cut -f1)" != "$(git rev-parse HEAD)" ]; then
14 | echo "Branch is no longer up-to-date - skipping tests ..."
15 | touch skip-tests
16 | exit
17 | fi
18 | fi
19 |
--------------------------------------------------------------------------------
/lib/core_extensions/integer.rb:
--------------------------------------------------------------------------------
1 | # Monkey-patches to the Integer class go here.
2 | class Integer
3 | # Allows ActionView::Helpers::TextHelper#pluralize to be used outside of views.
4 | def pluralize(word)
5 | ActionController::Base.helpers.pluralize(self, word)
6 | end
7 |
8 | # Run a block a given number of times, sleeping and retrying when
9 | # an exception of the given type is caught.
10 | def tries(catching: StandardError)
11 | retries ||= 0
12 | yield
13 | rescue catching
14 | if (retries += 1) <= self
15 | sleep 1 << retries # (Sleep with exponential backoff.)
16 | retry
17 | end
18 | raise
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/spec/factories/involved_officer.rb:
--------------------------------------------------------------------------------
1 | FactoryGirl.define do
2 | factory :involved_officer do
3 | id { SecureRandom.uuid }
4 | officer_used_force false
5 | officer_used_force_reason ['To effect arrest or take into custody']
6 | injured false
7 | received_force true
8 | received_force_type ['Blunt / impact weapon']
9 | received_force_location ['Head']
10 | on_duty true
11 | dress 'Tactical'
12 | age '21-25'
13 | race { InvolvedPerson::RACES.sample_one_or_two_elements }
14 | asian_race { race.include?(InvolvedPerson::ASIAN_RACE_STR) ? InvolvedPerson::ASIAN_RACES.sample_one_or_two_elements : nil }
15 | gender 'Male'
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/models/concerns/allows_partial_save.rb:
--------------------------------------------------------------------------------
1 | # Gives Documents the ability to be saved without validation.
2 | #
3 | # Note that partial=true renders a document invalid, so if a
4 | # document has previously been partially saved, you must give
5 | # it partial=false before you can save it without validation.
6 | module AllowsPartialSave
7 | extend ActiveSupport::Concern
8 |
9 | included do
10 | field :partial, :boolean
11 | validates :partial, acceptance: { accept: false }
12 | end
13 |
14 | def partial_save(attributes)
15 | attributes.each { |attribute, value| self[attribute] = value }
16 | self["partial"] = true
17 | save(validate: false)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/app/queries/past_submissions_query.rb:
--------------------------------------------------------------------------------
1 | # Returns previously-submitted incidents, organized by year.
2 | class PastSubmissionsQuery < Query
3 | private
4 |
5 | def perform_query(params)
6 | # raise ActionController::BadRequest.new unless params[:user].admin?
7 |
8 | submitted_incidents = GetAllIncidentsQuery.new.run(user: params[:user]).select(&:submitted?)
9 |
10 | agency_status = AgencyStatus.find_by_ori(params[:user].ori)
11 | submitted_years = agency_status ? agency_status.complete_submission_years : []
12 |
13 | Hash[submitted_years.map do |year|
14 | [year, submitted_incidents.select { |i| i.year == year }]
15 | end]
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/app/views/pages/maintenance.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= render "layouts/partials/head" %>
4 |
5 |
6 | <% if Rails.configuration.x.branding.ursus? %>
7 |
8 | <% else %>
9 |
10 | <% end %>
11 |
Down for Maintenance
12 |
<%= Rails.configuration.x.branding.ursus? ? 'URSUS' : 'Bridge UOF' %> is temporarily down for routine maintenance.
13 |
Please come back in an hour.
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/controllers/users/sessions_controller.rb:
--------------------------------------------------------------------------------
1 | module Users
2 | class SessionsController < Devise::SessionsController
3 | # before_filter :configure_sign_in_params, only: [:create]
4 |
5 | # GET /resource/sign_in
6 | # def new
7 | # super
8 | # end
9 |
10 | # POST /resource/sign_in
11 | # def create
12 | # super
13 | # end
14 |
15 | # DELETE /resource/sign_out
16 | # def destroy
17 | # super
18 | # end
19 |
20 | # protected
21 |
22 | # If you have extra params to permit, append them to the sanitizer.
23 | # def configure_sign_in_params
24 | # devise_parameter_sanitizer.for(:sign_in) << :attribute
25 | # end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build this Container by running from this dir:
2 | # docker build -t bayesimpact/bridge-uof .
3 | FROM ruby:2.2.3
4 |
5 | MAINTAINER everett.wetchler@bayesimpact.org
6 |
7 | RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
8 | RUN mkdir /bridge-uof
9 | WORKDIR /bridge-uof
10 | COPY Gemfile /bridge-uof/Gemfile
11 | COPY Gemfile.lock /bridge-uof/Gemfile.lock
12 | RUN bundle install
13 |
14 | COPY . /bridge-uof
15 | RUN sed -i -e "s/localhost:/db:/" /bridge-uof/config/aws.yml
16 | EXPOSE 80
17 | CMD ["./precompile_and_serve.sh", "-b", "0.0.0.0", "-p", "80"]
18 |
19 | # Label the image with the git commit.
20 | ARG GIT_SHA1=non-git
21 | LABEL org.bayesimpact.git=$GIT_SHA1
22 |
--------------------------------------------------------------------------------
/spec/controllers/application_controller_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe ApplicationController, type: :controller do
4 | it "replace_url_host" do
5 | c = ApplicationController.new
6 | expect(c.replace_url_host('http://foo.bar.baz/qux', 'something')).to eq('something/qux')
7 | expect(c.replace_url_host('http://foo.bar.baz/qux', 'https://something')).to eq('https://something/qux')
8 | expect(c.replace_url_host('https://foo.bar.baz/qux', 'https://something')).to eq('https://something/qux')
9 | expect(c.replace_url_host('http://foo.bar.baz/', 'something')).to eq('something/')
10 | expect(c.replace_url_host('http://foo.bar.baz', 'something')).to eq('something')
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/screener.scss:
--------------------------------------------------------------------------------
1 | .agency-table-group {
2 | display: inline-block;
3 | background-color: #EEE;
4 | margin: 10px;
5 | padding: 14px;
6 | border: 1px solid #CCC;
7 | font-size: 90%;
8 | text-align: center;
9 | min-width: 250px;
10 | table {
11 | border-collapse: collapse;
12 | margin: 0px auto 5px auto;
13 | color: #555;
14 | border-radius: 3px;
15 | th, td {
16 | padding: 2px 5px;
17 | }
18 | th {
19 | text-align: right;
20 | font-weight: bold;
21 | }
22 | }
23 | }
24 |
25 | .important-help-text {
26 | color: $color-secondary-darkest-18f;
27 | font-style: italic;
28 | }
29 |
30 | .you-are-admin {
31 | text-align: center;
32 | }
33 |
--------------------------------------------------------------------------------
/config/initializers/custom/ori.rb:
--------------------------------------------------------------------------------
1 | # App should fail to start if ORI data isn't loaded.
2 |
3 | # Skip this check in test env, because right now CircleCI doesn't
4 | # generate the ORI files and we don't want to add an extra step
5 | # to our build.
6 | unless Rails.env.test?
7 | Rails.configuration.after_initialize do
8 | begin
9 | Constants::CONTRACTING_ORIS
10 | rescue LoadError
11 | raise "Constants::CONTRACTING_ORIS not specified! Have you run `docker-compose run data`?"
12 | end
13 |
14 | begin
15 | Constants::DEPARTMENT_BY_ORI
16 | rescue LoadError
17 | raise "Constants::DEPARTMENT_BY_ORI not specified! Have you run `docker-compose run data`?"
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/config/initializers/custom/branding.rb:
--------------------------------------------------------------------------------
1 | # Branding-specific configuration code goes here.
2 |
3 | branding = Rails.configuration.x.branding
4 |
5 | branding.name = ENV["BRANDING"]
6 | branding.incident_id_prefix = ENV["INCIDENT_ID_PREFIX"]
7 |
8 | def branding.ursus?
9 | name == Constants::BRANDING_URSUS
10 | end
11 |
12 | def branding.whitelabel?
13 | name == Constants::BRANDING_WHITELABEL
14 | end
15 |
16 | # Validate presence of env vars.
17 |
18 | if branding.incident_id_prefix.blank?
19 | raise 'Must set INCIDENT_ID_PREFIX env var.'
20 | end
21 |
22 | if Constants::AVAILABLE_BRANDINGS.exclude? branding.name
23 | raise "Must set BRANDING env variable to one of #{Constants::AVAILABLE_BRANDINGS} (currently it is '#{ENV['BRANDING']}')"
24 | end
25 |
--------------------------------------------------------------------------------
/app/views/devise/registrations/new.html.erb:
--------------------------------------------------------------------------------
1 | Sign up
2 |
3 | <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
4 | <%= devise_error_messages! %>
5 |
6 | <%= render partial: "user_account_fields", locals: {f: f} %>
7 |
8 |
9 | <% "Password" %>
10 | <%= f.password_field :password, autocomplete: "off",
11 | placeholder: @pwd_placeholder %>
12 |
13 |
14 |
15 | <%= f.password_field :password_confirmation, autocomplete: "off", placeholder: "Confirm password" %>
16 |
17 |
18 |
19 | <%= f.submit "Sign up" %>
20 |
21 | <% end %>
22 |
23 | <%= link_to "Have an account? Log in", new_user_session_path %>
24 |
--------------------------------------------------------------------------------
/app/models/constants/constants.rb:
--------------------------------------------------------------------------------
1 | # Generic constants that pertain to more than one model go here.
2 | module Constants
3 | NONE = "None".freeze
4 | UNCONFIRMED_FLED = 'Unconfirmed (Fled)'.freeze
5 | MULTIPLE = "Multiple".freeze
6 | VARIOUS = 'Various*'.freeze
7 |
8 | ERROR_BLANK_FIELD = "Can't be blank".freeze
9 |
10 | SBI_DEFINITION = "a bodily injury that involves a substantial risk of death, unconsciousness," \
11 | " protracted and obvious disfigurement, or protracted loss or impairment of" \
12 | " the function of a bodily member or organ".freeze
13 |
14 | BRANDING_WHITELABEL = 'whitelabel'.freeze
15 | BRANDING_URSUS = 'ursus'.freeze
16 | AVAILABLE_BRANDINGS = [BRANDING_WHITELABEL, BRANDING_URSUS].freeze
17 | end
18 |
--------------------------------------------------------------------------------
/app/views/devise/registrations/_user_account_fields.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= f.text_field :first_name, autofocus: true, placeholder: "First Name" %>
3 |
4 |
5 |
6 | <%= f.text_field :last_name, placeholder: "Last Name" %>
7 |
8 |
9 |
10 | <%= f.email_field :email, placeholder: "Email" %>
11 |
12 |
13 |
14 | <%= f.text_field :ori, placeholder: "ORI" %>
15 |
16 |
17 |
18 | <%= f.text_field :department, placeholder: "Department" %>
19 |
20 |
21 |
22 | <%= f.text_field :role, placeholder: "Role" %>
23 |
24 |
25 |
26 | <%= f.text_field :user_id, placeholder: "User ID (from ECARS)" %>
27 |
28 |
--------------------------------------------------------------------------------
/config/initializers/custom/aws.rb:
--------------------------------------------------------------------------------
1 | # AWS-related configuration goes here.
2 |
3 | Aws.config[:region] = ENV['AWS_REGION'] || 'us-west-1'
4 |
5 | if ENV['USE_DEVELOPMENT_AWS_KEYS'] == 'true' && Rails.env != 'test'
6 | # Allow overriding of key settings, in case we need to run local dynamodb.
7 | aws_env_key = 'development'
8 | else
9 | aws_env_key = Rails.env
10 | end
11 |
12 | creds = YAML.load(File.read(Rails.root.join('config', 'aws.yml')))
13 | if creds.key?(aws_env_key)
14 | creds = creds[aws_env_key]
15 | Aws.config[:credentials] = Aws::Credentials.new(creds['access_key_id'], creds['secret_access_key'])
16 | Aws.config[:endpoint] = creds['endpoint']
17 | else
18 | Aws.config[:credentials] = Aws::Credentials.new(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY'])
19 | end
20 |
--------------------------------------------------------------------------------
/config/initializers/gems/dynamo_db.rb:
--------------------------------------------------------------------------------
1 | # DynamoDB configuration goes here.
2 |
3 | Dynamoid.configure do |config|
4 | config.namespace = ENV['DYNAMO_TABLE_PREFIX'] || "ursus_#{Rails.env}"
5 | config.write_capacity = 1
6 | config.read_capacity = 1
7 | end
8 |
9 | MODELS = [
10 | User, Incident, Screener, GeneralInfo, InvolvedCivilian, InvolvedOfficer,
11 | Feedback, AuditEntry, ChangedField, AgencyStatus, GlobalState, Visit, Event
12 | ].freeze
13 |
14 | Rails.configuration.after_initialize do
15 | # Initialize tables, if necessary. Do this after initialization so that
16 | # we are sure that Devise has been loaded.
17 | MODELS.each do |model|
18 | 3.tries(catching: Aws::DynamoDB::Errors::LimitExceededException) do
19 | model.create_table
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/app/queries/get_all_incidents_query.rb:
--------------------------------------------------------------------------------
1 | # Returns all incidents potentially visible to a user.
2 | # This is a "sub-query" returning a raw incident set that is
3 | # then used by user-facing queries such as DashboardIncidentsQuery.
4 | class GetAllIncidentsQuery < Query
5 | private
6 |
7 | def perform_query(params)
8 | if params[:user].admin?
9 | # Admins oversee incidents of all users in their ORI and contracting ORIs.
10 | params[:user].allowed_oris.flat_map do |ori|
11 | User.find_all_by_secondary_index(ori: ori).flat_map do |user|
12 | user.incidents.records
13 | end
14 | end
15 | else
16 | # Other users only oversee their own incidents.
17 | params[:user].incidents.records
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/spec/factories/general_info.rb:
--------------------------------------------------------------------------------
1 | FactoryGirl.define do
2 | factory :general_info do
3 | id { SecureRandom.uuid }
4 | # Generate a random date in the past year.
5 | incident_date_str { rand(Date.civil(Time.current.year - 1, 1, 1)..Date.civil(Time.current.year - 1, 12, 31)).strftime('%m/%d/%Y') }
6 | incident_time_str '1400'
7 | sequence(:address) { |n| "123#{n} Main Street" }
8 | city 'San Francisco'
9 | state 'CA'
10 | zip_code '94123'
11 | county 'San Francisco County'
12 | multiple_locations false
13 | on_k12_campus false
14 | arrest_made false
15 | crime_report_filed false
16 | contact_reason GeneralInfo::CONTACT_REASONS[0]
17 | num_involved_civilians 1
18 | num_involved_officers 1
19 | ori { build(:dummy_user).ori }
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/app/views/application/_edit_person_headers.html.erb:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/_controls.scss:
--------------------------------------------------------------------------------
1 | #controls {
2 | position: absolute;
3 | top: $nav-height;
4 | bottom: 0;
5 | right: 0;
6 | width: 84px;
7 |
8 | margin-left: 10px;
9 | font-size: 46px;
10 | background-color: #f3f3f3;
11 |
12 | .fa {
13 | width: 100%;
14 | padding: 24px 5px;
15 | color: $grayish-color;
16 | cursor: pointer;
17 | text-decoration: none !important;
18 | border-bottom: 1px solid #ccc;
19 |
20 | &:hover {
21 | color: #777;
22 | }
23 |
24 | .button-label {
25 | display: block;
26 | padding-top: 4px;
27 | font-size: 12px;
28 | font-family: 'Source Sans Pro', sans-serif;
29 | font-weight: bold;
30 | }
31 | }
32 | }
33 |
34 | @media screen and (max-width: 768px) {
35 | #controls {
36 | display: none;
37 | }
38 | }
--------------------------------------------------------------------------------
/spec/requests/splash_page_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe '[Splash page]', type: :request do
4 | it 'Renders the splash page for first-time visitors' do
5 | login(dont_handle_splash: true)
6 | visit root_path
7 | expect(current_path).to end_with('/welcome')
8 |
9 | # Until the user clicks 'ENTER' on the splash page, they'll keep getting
10 | # redirected there
11 | visit root_path
12 | expect(current_path).to end_with('/welcome')
13 |
14 | click_button('ENTER')
15 | expect(current_path).not_to end_with('/welcome') # Sent away from splash
16 | visit root_path
17 | expect(current_path).not_to end_with('/welcome') # Doesn't get splash again
18 | visit welcome_path
19 | expect(current_path).to end_with('/welcome') # Can still explicitly visit
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/app/queries/dashboard_incidents_query.rb:
--------------------------------------------------------------------------------
1 | # Returns incidents to display on the dashboard.
2 | class DashboardIncidentsQuery < Query
3 | private
4 |
5 | def perform_query(params)
6 | agency_last_submission_year = AgencyStatus.get_agency_last_submission_year(params[:user].ori)
7 |
8 | GetAllIncidentsQuery.new.run(user: params[:user])
9 | .reject(&:deleted?) # Don't include deleted incidents.
10 | .select { |i| i.year.nil? || i.year > agency_last_submission_year } # Ignore submitted incidents and old drafts.
11 | .select { |i| i.authorized_to_view? params[:user] } # Only show incidents user is authorized to see.
12 | .sort_by { |i| i.created_at.to_i }.reverse! # Display reverse chronologically.
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/app/controllers/screeners_controller.rb:
--------------------------------------------------------------------------------
1 | # Controller for Screener form.
2 | class ScreenersController < ApplicationController
3 | before_action :set_screener
4 |
5 | def create
6 | formatted_params = get_formatted_params(params, Screener)
7 | if @screener.update_attributes(formatted_params)
8 | if @screener.forms_necessary?
9 | @incident = Incident.create(user: @current_user, screener: @screener)
10 | @incident.audit_entries << AuditEntry.new(user: @current_user, custom_text: 'filled out the screener')
11 | redirect_to edit_incident_general_info_path(@incident)
12 | else
13 | render 'forms_not_necessary'
14 | end
15 | else
16 | render :new
17 | end
18 | end
19 |
20 | private
21 |
22 | def set_screener
23 | @screener = Screener.new
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/assets/javascripts/override/ie8.js:
--------------------------------------------------------------------------------
1 | /* IE<=8-specific JS hacks. */
2 |
3 | $(function () {
4 | // Hide #breadcrumbs-ursus-id because for some reason it makes the page not load.
5 | $('#breadcrumbs-ursus-id').hide();
6 |
7 | // Analytics don't work for a few reasons. Just don't display them.
8 | $('.visualization').text(
9 | "Your web browser, Internet Explorer 8, does not support this chart. " +
10 | "If you'd like to see this visualization, try any newer version of Internet Explorer (9, 10, etc) " +
11 | "or another major browser (Chrome, Firefox, etc)."
12 | );
13 |
14 | // Call positionControls() (event handler on window resize) a little later to make
15 | // it correctly calculate the top margin of #controls. [see controls.js].
16 | setTimeout(function () {
17 | $(window).resize();
18 | }, 1000);
19 | });
20 |
--------------------------------------------------------------------------------
/app/validators/uniqueness_validator.rb:
--------------------------------------------------------------------------------
1 | # Validates that an attribute of the given object is unique -
2 | # that is, no other instance of this model has that attribute value.
3 | # Avoids a SCAN operation if possible by attempting a secondary index query.
4 | class UniquenessValidator < ActiveModel::EachValidator
5 | def validate_each(document, attribute, value)
6 | records = begin
7 | if document.class.indexed_hash_keys.include? attribute.to_s
8 | document.class.find_all_by_secondary_index(attribute => value)
9 | else
10 | document.class.where(attribute => value).all
11 | end
12 | end
13 |
14 | if records.size > 1 || (records.size == 1 && records[0].hash_key != document.hash_key)
15 | document.errors.add(attribute, :taken, options.except(:case_sensitive, :scope).merge(value: value))
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/views/devise/passwords/edit.html.erb:
--------------------------------------------------------------------------------
1 | Change your password
2 |
3 | <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
4 | <%= devise_error_messages! %>
5 | <%= f.hidden_field :reset_password_token %>
6 |
7 |
8 | <% if @minimum_password_length %>
9 | (<%= @minimum_password_length %> characters minimum)
10 | <% end %>
11 | <%= f.password_field :password, autofocus: true, autocomplete: "off", placeholder: "New password" %>
12 |
13 |
14 |
15 | <%= f.password_field :password_confirmation, autocomplete: "off", placeholder: "Confirm new password" %>
16 |
17 |
18 |
19 | <%= f.submit "Change my password" %>
20 |
21 | <% end %>
22 |
23 | <%= render "devise/shared/links" %>
24 |
--------------------------------------------------------------------------------
/app/views/layouts/partials/_footer.html.erb:
--------------------------------------------------------------------------------
1 |
3 | <% if Rails.configuration.x.branding.ursus? %>
4 |
5 |
6 | <%= image_tag "ag_seal_gray.png" %>
7 | <%= image_tag "bayes_logo_white.png" %>
8 |
9 |
22 |
23 | <% end %>
24 |
--------------------------------------------------------------------------------
/app/views/application/_controls.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
HELP
3 |
ZOOM IN
4 |
ZOOM 100%
5 |
ZOOM OUT
6 | <% if local_assigns[:show_print_button] %>
7 |
PRINT
8 | <% end %>
9 |
10 |
--------------------------------------------------------------------------------
/app/views/application/_display_item.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= key %>
3 | <% if is_fbi_field %>
4 |
7 |
8 | <% end %>
9 |
10 |
11 | <% if partial %>
12 | <%= render partial: partial, locals: { value: value } %>
13 | <% elsif value.instance_of?(Array) %>
14 | <%= value.join(', ') %>
15 | <% elsif value == nil || value == '' %>
16 | n/a
17 | <% elsif value.instance_of?(DateTime) %>
18 | <%= value.strftime("%a %b %e %H:%M %Y") %>
19 | <% else %>
20 | <%= (value == !!value) ? (value ? "Yes" : "No") : value.to_s %>
21 | <% end %>
22 |
23 |
--------------------------------------------------------------------------------
/app/helpers/incident_helper.rb:
--------------------------------------------------------------------------------
1 | # Helper methods pertaining to displaying components of incidents.
2 | module IncidentHelper
3 | def display_ori(ori)
4 | "#{Constants::DEPARTMENT_BY_ORI[ori]} (#{ori})"
5 | end
6 |
7 | def render_incident_id(incident)
8 | render partial: "incident_id", locals: { value: incident.incident_id }
9 | end
10 |
11 | def audit_entry_description(entry)
12 | name = entry.user.full_name
13 | date = entry.created_at.strftime("%a %b %-d %Y at %H%M")
14 |
15 | if entry.custom_text
16 | "#{name} #{entry.custom_text} on #{date}."
17 | elsif entry.is_new
18 | "#{name} filled out the #{entry.page} page on #{date}."
19 | else
20 | "#{name} edited the #{entry.page} page on #{date} and updated the following fields:"
21 | end
22 | end
23 |
24 | def show_changed_fields_for_audit_entry?(entry)
25 | !entry.custom_text && !entry.is_new
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require 'pathname'
3 |
4 | # path to your application root.
5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
6 |
7 | Dir.chdir APP_ROOT do
8 | # This script is a starting point to setup your application.
9 | # Add necessary setup steps to this file:
10 |
11 | puts "== Installing dependencies =="
12 | system "gem install bundler --conservative"
13 | system "bundle check || bundle install"
14 |
15 | # puts "\n== Copying sample files =="
16 | # unless File.exist?("config/database.yml")
17 | # system "cp config/database.yml.sample config/database.yml"
18 | # end
19 |
20 | puts "\n== Preparing database =="
21 | system "bin/rake db:setup"
22 |
23 | puts "\n== Removing old logs and tempfiles =="
24 | system "rm -f log/*"
25 | system "rm -rf tmp/cache"
26 |
27 | puts "\n== Restarting application server =="
28 | system "touch tmp/restart.txt"
29 | end
30 |
--------------------------------------------------------------------------------
/app/views/incidents/index.html.erb:
--------------------------------------------------------------------------------
1 | <%= title @status_text %>
2 |
3 | <% content_for :sidenav do %>
4 | <%= render partial: 'dashboard_sidenav' %>
5 | <% end %>
6 | <% content_for :panel do %>
7 | <%= dashboard_page_explanation(@current_user, @status, @incidents.length) %>
8 |
9 | <% if @status == 'state_submission' %>
10 | <%= render partial: 'state_submit_pane' %>
11 | <% elsif @status == 'past_submissions' %>
12 | <%= render partial: 'past_submissions_pane' %>
13 | <% elsif @status == 'analytics' %>
14 | <%= render partial: 'analytics_pane' %>
15 | <% elsif @status.present? && Incident::STATUS_TYPES.include?(@status.to_sym) %>
16 | <% if @incidents.present? %>
17 | <%= render partial: 'incidents_table', locals: {incidents: @incidents} %>
18 | <% end %>
19 | <% else %>
20 | <%= render partial: 'overview_pane' %>
21 | <% end %>
22 | <% end %>
23 | <%= render template: "layouts/dashboard" %>
24 |
--------------------------------------------------------------------------------
/lib/bridge_exceptions.rb:
--------------------------------------------------------------------------------
1 | # Custom exceptions go here.
2 | module BridgeExceptions
3 | class BulkUploadError < StandardError; end
4 | class DeserializationError < StandardError; end
5 | class IncidentIdCollisionError < StandardError; end
6 | class SiteminderAuthenticationError < StandardError; end
7 | class SiteminderCookieNotFoundError < StandardError; end
8 | class UnableToSubmitError < StandardError; end
9 | class UnathorizedToView < ActionController::BadRequest; end
10 | class UnimplementedError < StandardError; end
11 |
12 | # Custom exceptions with default messages go below.
13 |
14 | # Thrown when an invalid login mechanism is detected.
15 | class InvalidLoginMechanismError < StandardError
16 | def initialize(msg = "Need to set LOGIN_MECHANISM env variable to DEVISE, SITEMINDER, or DEMO " \
17 | "(currently it is '#{ENV['LOGIN_MECHANISM']}')")
18 | super
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/data/scripts/cleaning_helpers.py:
--------------------------------------------------------------------------------
1 | """Helper functions for data cleaning scripts."""
2 |
3 |
4 | def extract_code_number(code):
5 | """For natural sorting, extract (for example) '123' from '123 ABC'.
6 |
7 | Lexicographical sorting of code strings does not result in a natural ordering.
8 | Return a tuple of all numerical sections. E.g. "123.4 ABC" would return (123, 4)
9 | so that it naturally sorts before "123.10" and after "50.4".
10 | """
11 | current = ''
12 | parts = []
13 | for i, ch in enumerate(code):
14 | if ch.isdigit():
15 | current += ch
16 | elif ch == '.':
17 | if not current:
18 | # Break if we get something weird like '123...4'
19 | break
20 | parts.append(int(current))
21 | current = ''
22 | else:
23 | break
24 | if current:
25 | parts.append(int(current))
26 | return tuple(parts)
27 |
--------------------------------------------------------------------------------
/app/views/application/_yes_no_question.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= f.label field, label, class: label_class %>
4 | <% unless help_content.nil? %>
5 |
7 | <% end %>
8 |
9 |
10 |
11 |
12 | <% if check_yes.nil? %>
13 | <%= f.radio_button field, true %>
14 | <% else %>
15 | <%= f.radio_button field, true, checked: check_yes %>
16 | <% end %>
17 | <%= yes_label %>
18 |
19 |
20 | <% if check_no.nil? %>
21 | <%= f.radio_button field, false %>
22 | <% else %>
23 | <%= f.radio_button field, false, checked: check_no %>
24 | <% end %>
25 | <%= no_label %>
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/app/views/application/_incident_id.html.erb:
--------------------------------------------------------------------------------
1 | <% if value.present? %>
2 | <%= value.prefix %> -<%= value.county %> -<%= value.agency %> -<%= value.year %> -<%= value.code %>
3 | <% end %>
4 |
--------------------------------------------------------------------------------
/spec/requests/feedback_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe '[Feedback and feedback email tests]', type: :request do
4 | before :each do
5 | login
6 | end
7 |
8 | it 'Loads the feedback page when the help button is clicked' do
9 | click_link 'HELP'
10 | expect(current_path).to eq(feedback_path)
11 | end
12 |
13 | it 'Saves feedback and sends it in an email', driver: :poltergeist do
14 | expect(Feedback.count).to eq(0)
15 | visit feedback_path
16 | find('a', text: 'Click here for a feedback form').click
17 | fill_in 'Which part gave you difficulty', with: "Foo"
18 | fill_in 'Please explain', with: "Bar"
19 | expect { find('button[type=submit]').click }
20 | .to change { ActionMailer::Base.deliveries.count }.by(1)
21 | expect(current_path).to eq(thank_you_path)
22 |
23 | expect(Feedback.count).to eq(1)
24 | f = Feedback.first
25 | expect(f.source).to eq("Foo")
26 | expect(f.content).to eq("Bar")
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/spec/requests/visit_tracking_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe '[Visit and event tracking]', type: :request do
4 | let(:user) { User.first }
5 |
6 | before :each do
7 | login
8 |
9 | # We don't care about the events that were triggered by login.
10 | Event.all.each(&:destroy)
11 | end
12 |
13 | it 'tracks visits and events' do
14 | # Visiting one page should create a visit and one event.
15 |
16 | visit dashboard_path
17 |
18 | expect(Visit.count).to eq(1)
19 | expect(Visit.first.user).to eq(user)
20 |
21 | expect(Event.count).to eq(1)
22 | expect(Event.first.name).to eq('incidents#index')
23 | expect(Event.first.visit.id).to eq(Visit.first.id)
24 | expect(Event.first.user).to eq(user)
25 |
26 | # Visiting another page should create new events, but the visit should stay the same.
27 |
28 | visit new_incident_path
29 |
30 | expect(Visit.count).to eq(1)
31 | expect(Event.count).to be > 1
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/_footer.scss:
--------------------------------------------------------------------------------
1 | footer {
2 | position: absolute;
3 | bottom: 0;
4 | width: 100%;
5 | height: $footer-height;
6 | background-color: $grayish-color;
7 | padding: 18px;
8 |
9 | img {
10 | display: inline-block;
11 | height: 48px;
12 | width: 48px;
13 | }
14 |
15 | div {
16 | float: left;
17 | }
18 |
19 | #agency-name {
20 | padding: 0px;
21 | padding-top: 0px;
22 | margin: auto 0px auto 10px;
23 | font-size: 16px;
24 | font-weight: bold;
25 | color: white;
26 | height: 100%;
27 | }
28 |
29 | p {
30 | margin: 0;
31 | }
32 |
33 | a {
34 | color: white;
35 | &:hover {
36 | color: white;
37 | text-decoration: underline;
38 | }
39 | }
40 | }
41 |
42 | @media screen and (max-width: 768px) {
43 | footer {
44 | position: static;
45 | height: auto;
46 | padding: 24px;
47 | padding-bottom: 0;
48 | text-align: center;
49 |
50 | img, div {
51 | float: none;
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/views/layouts/error.html.erb:
--------------------------------------------------------------------------------
1 | <%= title "Oops!" %>
2 |
3 | <% content_for :body do %>
4 | <%= render partial: "controls" %>
5 |
6 |
7 |
Oops!
8 |
9 |
<%= yield %>
10 |
If you have a moment, please <%= link_to "let us know", feedback_path %> what you were doing before you got this message, so we can track down the issue more easily.
11 |
<%= link_to "Return to your dashboard", dashboard_path %>
12 |
13 |
14 | <% if Rails.env.development? || ENV["SHOW_EXCEPTIONS_ANYWAY"] == "true" %>
15 |
16 |
Original exception (only displayed in development and test environment):
17 |
<%= @exception.inspect %>
18 |
<%= @exception.backtrace.join("\n") %>
19 | <% end %>
20 |
21 |
22 | <% end %>
23 | <%= render template: "layouts/application" %>
24 |
--------------------------------------------------------------------------------
/app/views/incidents/_past_submissions_pane.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <% if @past_incidents_by_year.empty? %>
3 |
4 | <% if @current_user.admin? %>
5 | Your agency has not yet made
6 | <% if Rails.configuration.x.branding.ursus? %>an URSUS submission<% else %>a submission<% end %>
7 | to the state.
8 | <% else %>
9 | None of your incidents have been submitted to the state yet.
10 | <% end %>
11 |
12 | <% else %>
13 |
Past State Submissions
14 | <% @past_incidents_by_year.keys.sort.reverse.each do |year| %>
15 |
16 | <% if @past_incidents_by_year[year].length > 0 %>
17 | <%= render partial: 'incidents_table', locals: {incidents: @past_incidents_by_year[year]} %>
18 | <% end %>
19 | <% end %>
20 | <% end %>
21 |
22 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/upload.scss:
--------------------------------------------------------------------------------
1 | #bulk-upload {
2 | .block {
3 | overflow: scroll;
4 | width: 80%;
5 | background-color: #f5f5f5;
6 | border: 1px solid #ccc;
7 | margin-bottom: 1em;
8 |
9 | pre {
10 | border: none;
11 | }
12 |
13 | h3 {
14 | padding-left: 10px;
15 | }
16 | }
17 |
18 | #schema {
19 | display: none;
20 | }
21 |
22 | #upload-control {
23 | margin: 20px 0;
24 |
25 | .btn-file {
26 | position: relative;
27 | overflow: hidden;
28 |
29 | input[type=file] {
30 | position: absolute;
31 | top: 0;
32 | right: 0;
33 | min-width: 100%;
34 | min-height: 100%;
35 | font-size: 100px;
36 | text-align: right;
37 | filter: alpha(opacity=0);
38 | opacity: 0;
39 | outline: none;
40 | background: white;
41 | cursor: inherit;
42 | display: block;
43 | }
44 | }
45 | }
46 |
47 | #showHideSchema, .toggle-schema {
48 | cursor: pointer;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/config/secrets.yml:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Your secret key is used for verifying the integrity of signed cookies.
4 | # If you change this key, all old signed cookies will become invalid!
5 |
6 | # Make sure the secret is at least 30 characters and all random,
7 | # no regular words or you'll be exposed to dictionary attacks.
8 | # You can use `rake secret` to generate a secure secret key.
9 |
10 | # Make sure the secrets in this file are kept private
11 | # if you're sharing your code publicly.
12 |
13 | development:
14 | secret_key_base: b714509650504eaa786e181ad638b81863e3ddd85d338615fc97d5971c1f29b787b860e357add56cd3e9f2270c6af5987b2f5124d3efd3dfc68dd7c52c85958a
15 |
16 | test:
17 | secret_key_base: 3b5db199004c4490259a6d4156c5173402f8ae45f3357ff4f98abb4779f4517e57906c9f30bae8164782dd0456f2a8691c7c3ad7e77c7c93ff66cf74869c0d6d
18 |
19 | # Do not keep production secrets in the repository,
20 | # instead read values from the environment.
21 | production:
22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
23 |
--------------------------------------------------------------------------------
/spec/factories/involved_civilian.rb:
--------------------------------------------------------------------------------
1 | FactoryGirl.define do
2 | factory :involved_civilian do
3 | id { SecureRandom.uuid }
4 | assaulted_officer false
5 | perceived_armed false
6 | confirmed_armed { [true, false].sample }
7 | confirmed_armed_weapon { confirmed_armed ? InvolvedCivilian::CONFIRMED_WEAPONS.sample_one_or_two_elements : nil }
8 | firearm_type { confirmed_armed_weapon ? InvolvedCivilian::FIREARM_TYPES.sample_one_or_two_elements : nil }
9 | resisted false
10 | received_force true
11 | received_force_type { InvolvedCivilian::RECEIVED_FORCE_TYPES.sample_one_or_two_elements }
12 | received_force_location ['Head']
13 | injured false
14 | custody_status 'In custody (W&I section 5150)'
15 | mental_status ['None']
16 | highest_charge 'Some charge'
17 | age '21-25'
18 | race { InvolvedPerson::RACES.sample_one_or_two_elements }
19 | asian_race { race.include?(InvolvedPerson::ASIAN_RACE_STR) ? InvolvedPerson::ASIAN_RACES.sample_one_or_two_elements : nil }
20 | gender 'Male'
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/config/initializers/custom/views.rb:
--------------------------------------------------------------------------------
1 | # ActionView override configuration goes here.
2 |
3 | # Override ActionView's field_error_proc to wrap form fields with errors
4 | # in a .has-errors div (this is a boostrap class to highlight fields with errors).
5 |
6 | ActionView::Base.field_error_proc = proc do |html_tag, instance|
7 | # We need to use .html_safe at the end due to field_error_proc handling,
8 | # and it's not a big deal anyway because there's no user content to worry about.
9 | # rubocop:disable Rails/OutputSafety
10 | if html_tag =~ /^"
15 | doc.to_html
16 | elsif html_tag !~ /type="radio/
17 | "#{html_tag}
"
18 | else
19 | html_tag
20 | end.html_safe
21 | # rubocop:enable Rails/OutputSafety
22 | end
23 |
--------------------------------------------------------------------------------
/data/scripts/generate_js_from_csv.py:
--------------------------------------------------------------------------------
1 | """Generates a javascript file that contains criminal code data."""
2 |
3 | import sys
4 |
5 |
6 | def process(infile, outfile, varname):
7 | """Read data from infile and generate js at outfile."""
8 | outlines = [
9 | "// This file was automatically generated by a rule in the Makefile",
10 | "window.%s = [" % varname.upper(),
11 | ]
12 | for line in open(infile).read().splitlines():
13 | outlines.append(' "%s",' % line.replace('"', '\\"'))
14 | outlines[-1] = outlines[-1][:-1] # Remove trailing comma because it breaks IE.
15 | outlines.append("]")
16 | with open(outfile, 'w') as f:
17 | f.write('\n'.join(outlines) + '\n')
18 |
19 |
20 | def main(infile, outfile, varname):
21 | """Usually should not need to change this method, but do so as needed."""
22 | print('Will create ' + outfile)
23 | print('Processing...')
24 | process(infile, outfile, varname)
25 | print('...success!')
26 |
27 |
28 | if __name__ == '__main__':
29 | main(*sys.argv[1:])
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ##############################
2 | # Bayes General
3 | ##############################
4 |
5 | # Compiled python files
6 | *.py[cod]
7 |
8 | # iPython notebook backups
9 | .ipynb_checkpoints
10 |
11 | # Virtualenv
12 | env/
13 |
14 | # OS X garbage
15 | .DS_Store
16 |
17 | # Sublime garbage
18 | *.sublime*
19 |
20 | ## emacs backups
21 | *~
22 |
23 | ## vim backups
24 | .*.swp
25 |
26 | ## R backups
27 | *.Rhistory
28 |
29 | ##############################
30 | # Rails
31 | ##############################
32 |
33 | # Ignore bundler config.
34 | /.bundle
35 |
36 | # Ignore all logfiles and tempfiles.
37 | /log/*
38 | !/log/.keep
39 | /tmp
40 |
41 | ##############################
42 | # Bridge-Specific
43 | ##############################
44 |
45 | *_bayes.md
46 | todo.md
47 | dynamodb
48 |
49 | # Sensitive environment variables
50 | *.env
51 |
52 | # Processed data files generated by Makefile rules
53 | data/*.csv
54 | app/models/constants/department_by_ori.rb
55 | app/models/constants/contracting_oris.rb
56 | app/assets/javascripts/police_agency_oris.js
57 |
--------------------------------------------------------------------------------
/app/models/visit.rb:
--------------------------------------------------------------------------------
1 | # A visit to Ursus, tracked by Ahoy.
2 | # Note that the field names are defined by Ahoy - if they are renamed,
3 | # they will not be set correctly.
4 | class Visit
5 | include Dynamoid::Document
6 |
7 | # associations
8 | belongs_to :user
9 |
10 | # required
11 | field :visitor_id
12 |
13 | # the rest are recommended but optional
14 | # simply remove the columns you don't want
15 |
16 | # standard
17 | field :ip
18 | field :user_agent
19 | field :referrer
20 | field :landing_page
21 |
22 | # traffic source
23 | field :referring_domain
24 | field :search_keyword
25 |
26 | # technology
27 | field :browser
28 | field :os
29 | field :device_type
30 | field :screen_height, :integer
31 | field :screen_width, :integer
32 |
33 | # location
34 | field :country
35 | field :region
36 | field :city
37 |
38 | # utm parameters
39 | # field :utm_source
40 | # field :utm_medium
41 | # field :utm_term
42 | # field :utm_content
43 | # field :utm_campaign
44 |
45 | field :started_at, :datetime
46 | end
47 |
--------------------------------------------------------------------------------
/db/migrations/20161019_1231_rename_ursus_id_to_incident_id.rb:
--------------------------------------------------------------------------------
1 | # Migration to rename the 'ursus_id' field to 'incident_id' for Incidents.
2 | class RenameUrsusIdToIncidentId < DynamoDB::Migration::Unit
3 | def update
4 | logger = Logger.new(STDOUT)
5 |
6 | logger.info "Running migration #{self.class.name}:"
7 |
8 | incident_ids = Incident.all.map(&:id)
9 | logger.info "Updating #{incident_ids.length} Incident records ..."
10 | incident_ids.each do |id|
11 | begin
12 | client.update_item(
13 | table_name: "#{Dynamoid.config.namespace}_incidents",
14 | key: { 'id' => id },
15 | update_expression: "SET incident_id_str = ursus_id_str REMOVE ursus_id_str",
16 | condition_expression: "attribute_exists (ursus_id_str)"
17 | )
18 | rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
19 | logger.debug " Skipping #{id} because it has no ursus_id_str"
20 | end
21 | end
22 |
23 | logger.info 'Done!'
24 | logger.info '================================'
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/app/views/doj/window.html.erb:
--------------------------------------------------------------------------------
1 | Submission for <%= Time.last_year %> is
2 |
3 | <%= GlobalState.submission_open? ? 'OPEN' : 'CLOSED' %>
4 |
5 |
6 |
7 | <%= form_tag({controller: "doj", action: "window_toggle"}, {id: "window-submit-form"}) do %>
8 |
9 |
10 | <% close_confirmation = "This will prevent agencies from submitting or changing any more data for #{Time.last_year}. You can undo this, but it's meant to be closed once and for all. Are you sure you want to close the window now?" %>
11 | <% open_confirmation = "This will allow agencies to submit data for #{Time.last_year}. Are you sure you're ready to open the window?" %>
12 | <%= submit_tag submission_window_button_text, class: "btn btn-bayes", data: {confirm: GlobalState.submission_open? ? close_confirmation : open_confirmation } %>
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/config/application.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path('../boot', __FILE__)
2 |
3 | require "rails"
4 | # Pick the frameworks you want:
5 | require "active_model/railtie"
6 | require "active_job/railtie"
7 | # require "active_record/railtie"
8 | require "action_controller/railtie"
9 | require "action_mailer/railtie"
10 | require "action_view/railtie"
11 | require "sprockets/railtie"
12 | require "rails/test_unit/railtie"
13 |
14 | # Require the gems listed in Gemfile, including any gems
15 | # you've limited to :test, :development, or :production.
16 | Bundler.require(*Rails.groups)
17 |
18 | module Ab71
19 | # Entry point for the Ursus application.
20 | class Application < Rails::Application
21 | config.autoload_paths += [
22 | Rails.root.join('lib'),
23 | Rails.root.join('app', 'models'),
24 | Rails.root.join('app', 'models', 'constants'),
25 | Rails.root.join('app', 'queries'),
26 | Rails.root.join('app', 'services'),
27 | Rails.root.join('app', 'validators')
28 | ]
29 |
30 | config.time_zone = 'Pacific Time (US & Canada)'
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/data/scripts/generate_ori_js.py:
--------------------------------------------------------------------------------
1 | """Generates a javascript file that contains police ORI data."""
2 |
3 | import csv
4 | import sys
5 |
6 |
7 | def process(infile, outfile):
8 | """Read data from infile and generate js at outfile."""
9 | outlines = [
10 | "// This file was automatically generated by a rule in the Makefile",
11 | "window.AGENCY_ORI = [",
12 | ]
13 | with open(infile) as f:
14 | reader = csv.reader(f)
15 | header = True
16 | for line in reader:
17 | if header:
18 | header = False
19 | else:
20 | outlines.append(' "%s",' % ", ".join(line[:3]))
21 | outlines.append("]")
22 | with open(outfile, 'w') as f:
23 | f.write('\n'.join(outlines) + '\n')
24 |
25 |
26 | def main(infile, outfile):
27 | """Usually should not need to change this method, but do so as needed."""
28 | print('Creating {} ...'.format(outfile))
29 | process(infile, outfile)
30 | print('...success!')
31 |
32 |
33 | if __name__ == '__main__':
34 | main(*sys.argv[1:])
35 |
--------------------------------------------------------------------------------
/app/views/screeners/forms_not_necessary.html.erb:
--------------------------------------------------------------------------------
1 | <%= title "No forms necessary" %>
2 |
3 | <%= render partial: "controls" %>
4 |
5 |
6 |
7 |
8 |
9 |
22 |
23 | <%= link_to "Back to Dashboard", root_path, class: "btn-bayes" %>
24 | or <%= link_to "Change my answers", "javascript:history.go(-1)" %>
25 |
26 |
--------------------------------------------------------------------------------
/app/assets/javascripts/util.js:
--------------------------------------------------------------------------------
1 | function pad(str, n) {
2 | // e.g. pad("blah", 6) = "blah "
3 | return (str + " ").slice(0, n);
4 | }
5 |
6 | function disableQuestions(selector) {
7 | $(selector).addClass("disabled");
8 | $(selector + " input").prop("disabled", true);
9 | $(selector + " select").prop("disabled", true);
10 | // Trigger change event to notify any sub-questions.
11 | $(selector + " input").trigger('change');
12 | $(selector + " select").trigger('change');
13 | }
14 |
15 | function enableQuestions(selector) {
16 | $(selector).removeClass("disabled");
17 | $(selector + " input").prop('disabled', false);
18 | $(selector + " select").prop('disabled', false);
19 | // Trigger change event to notify any sub-questions.
20 | $(selector + " input").trigger('change');
21 | $(selector + " select").trigger('change');
22 | }
23 |
24 | $.fn.extend({
25 | toggleLinkFor: function (toggleable) {
26 | $(this).click(function () {
27 | toggleable.toggle();
28 | $(this).text(toggleable.is(':visible') ? '[hide]' : '[show]');
29 | });
30 | }
31 | });
--------------------------------------------------------------------------------
/app/views/doj/incidents.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= form_tag({controller: "doj", action: "incidents"}, method: "get") do %>
3 |
5 | <%= submit_tag "GO", class: 'btn-bayes' %>
6 | <% end %>
7 |
8 |
9 | <% if @ori %>
10 | <% if @bad_ori %>
11 |
<%= @ori %> is not a valid ORI
12 | <% else %>
13 |
<%= @dept %> (<%= @ori %>)
14 | <% if @agency_last_submission_year < Time.last_year %>
15 |
This agency has not yet submitted for <%= Time.last_year %>
16 | <% else %>
17 |
This agency submitted <%= pluralize(@incidents.length, 'incident') %> for <%= Time.last_year %>
18 | <%= render partial: 'incidents_table', locals: {incidents: @incidents} %>
19 | <% end %>
20 | <% end %>
21 | <% end %>
22 |
23 |
--------------------------------------------------------------------------------
/data/scripts/generate_ori_rb.py:
--------------------------------------------------------------------------------
1 | """Generates a Ruby file that contains police ORI data."""
2 |
3 | import sys
4 |
5 | import pandas as pd
6 |
7 |
8 | def process(infile, outfile):
9 | """Read data from infile and generate Ruby at outfile."""
10 | outlines = [
11 | "# This file was automatically generated by a rule in the Makefile",
12 | "module Constants",
13 | " DEPARTMENT_BY_ORI = {",
14 | ]
15 |
16 | df = pd.read_csv(infile)
17 | for name, ori in zip(df['AGENCY_NAME'], df['ORI']):
18 | outlines.append(' "%s" => "%s",' % (ori, name))
19 |
20 | outlines[-1] = outlines[-1][:-1]
21 | outlines.extend([
22 | " }.freeze",
23 | "end"
24 | ])
25 | with open(outfile, 'w') as f:
26 | f.write('\n'.join(outlines) + '\n')
27 |
28 |
29 | def main(infile, outfile):
30 | """Usually should not need to change this method, but do so as needed."""
31 | print('Creating {} ...'.format(outfile))
32 | process(infile, outfile)
33 | print('...success!')
34 |
35 |
36 | if __name__ == '__main__':
37 | main(*sys.argv[1:])
38 |
--------------------------------------------------------------------------------
/app/views/devise/registrations/edit.html.erb:
--------------------------------------------------------------------------------
1 | Edit <%= resource_name.to_s.humanize %>
2 |
3 | <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
4 | <%= devise_error_messages! %>
5 |
6 | <%= render partial: "user_account_fields", locals: {f: f} %>
7 |
8 | <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
9 | Currently waiting confirmation for: <%= resource.unconfirmed_email %>
10 | <% end %>
11 |
12 |
13 | <%= f.password_field :password, autocomplete: "off", placeholder: "New password (optional)" %>
14 |
15 |
16 |
17 | <%= f.password_field :password_confirmation, autocomplete: "off", placeholder: "Confirm new password" %>
18 |
19 |
20 |
21 | <%= f.password_field :current_password, autocomplete: "off", placeholder: "Current password (REQUIRED)" %>
22 |
23 |
24 |
25 | <%= f.submit "Update" %>
26 |
27 | <% end %>
28 |
29 | <%= link_to "Back to my dashboard", :back %>
30 |
--------------------------------------------------------------------------------
/spec/factories/incident.rb:
--------------------------------------------------------------------------------
1 | FactoryGirl.define do
2 | factory :incident do
3 | transient do
4 | user_id { build(:dummy_user).user_id }
5 | num_civilians 1
6 | num_officers 1
7 | submit true
8 | stop_step nil
9 | ori nil
10 | end
11 |
12 | user { User.find_by_user_id(user_id) || create(:dummy_user) }
13 | screener
14 | general_info { create(:general_info, num_involved_civilians: num_civilians, num_involved_officers: num_officers, ori: (ori || user.ori)) }
15 |
16 | # e is the FactoryGirl evaluator (has access to transient attributes).
17 | after(:create) do |incident, e|
18 | unless e.stop_step == :civilians
19 | e.num_civilians.times do
20 | incident.involved_civilians << create(:involved_civilian)
21 | end
22 |
23 | unless e.stop_step == :officers
24 | e.num_officers.times do
25 | incident.involved_officers << create(:involved_officer)
26 | end
27 |
28 | incident.in_review! if e.submit
29 | end
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/app/views/doj/whosubmitted.html.erb:
--------------------------------------------------------------------------------
1 | Agency Submissions for <%= Time.last_year %>
2 |
3 |
4 |
5 |
6 | ORI
7 | DEPARTMENT
8 | SUBMITTED?
9 |
10 |
11 |
12 |
13 |
14 | <% @agencies.each do |a| %>
15 |
16 | <%= a[:ori] %>
17 | <%= a[:department] %>
18 |
19 |
20 | <%= a[:submitted] ? 'YES' : 'NO' %>
21 |
22 |
23 |
24 | <% if a[:submitted] %>
25 | <%= link_to " View Incidents".html_safe,
26 | {controller: 'doj', action: 'incidents', ori: a[:ori]},
27 | class: "btn btn-primary" %>
28 | <% end %>
29 |
30 |
31 | <% end %>
32 |
33 |
34 |
--------------------------------------------------------------------------------
/app/assets/javascripts/incidents.address.js:
--------------------------------------------------------------------------------
1 | $(function(){
2 | $("#general_info_address").geocomplete({
3 | // Restrict to US.
4 | country: 'US',
5 | // Give a preference to California.
6 | // Coordinates rounded from https://en.wikipedia.org/wiki/California.
7 | bounds: {
8 | south: 32.53,
9 | north: 42,
10 | west: -124.43,
11 | east: -114.13
12 | }
13 | }).bind("geocode:result", function(event, result) {
14 | $("#general_info_address").val(result.name);
15 | $.map(result.address_components, function (c) {
16 | if (c.types.indexOf("locality") > -1) {
17 | $("#general_info_city").val(c.long_name);
18 | }
19 | if (c.types.indexOf("administrative_area_level_2") > -1) {
20 | $("#general_info_county").val(c.long_name);
21 | }
22 | if (c.types.indexOf("administrative_area_level_1") > -1) {
23 | $("#general_info_state").val(c.short_name);
24 | }
25 | if (c.types.indexOf("postal_code") > -1) {
26 | $("#general_info_zip_code").val(c.long_name);
27 | }
28 | });
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/app/views/application/_breadcrumbs.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <% active_step_number = Incident::STEPS.index(active_step) %>
3 | <% can_edit = @incident && @incident.authorized_to_edit?(@current_user) %>
4 | <% Incident::STEPS.each_with_index do |s, i|%>
5 | <% if i == active_step_number
6 | step_link_class = "active"
7 | elsif can_edit && i <= @incident.next_step_number && i > 0
8 | # Screener link (i=0) should never be enabled.
9 | step_link_class = "enabled"
10 | else
11 | step_link_class = "disabled"
12 | end %>
13 | <% if request.path.include? 'screener' %>
14 | <%= link_to s.to_s.capitalize, '', class: step_link_class %>
15 | <% else %>
16 | <% step_url = incident_base_url(request.path) + '/' + Incident::STEP_URLS[i].to_s %>
17 | <%= link_to s.to_s.capitalize, step_url, class: step_link_class %>
18 | <% end %>
19 | <% end %>
20 | <% if @incident && @incident.incident_id.present? %>
21 | <%= render partial: 'incident_id', locals: {value: @incident.incident_id} %>
22 | <% end %>
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/controllers/steps_base_controller.rb:
--------------------------------------------------------------------------------
1 | # Base class for controllers representing steps in the incident form.
2 | class StepsBaseController < ApplicationController
3 | before_action :set_incident, :make_sure_step_is_accessible
4 |
5 | private
6 |
7 | # If the current step isn't accessible yet, redirect to the last accessible step.
8 | def make_sure_step_is_accessible
9 | return redirect_to incident_path(@incident) unless @incident.step_accessible? current_step
10 | end
11 |
12 | # If a user edits an approved incident, it should be moved back to the review stage to require a fresh approval.
13 | def move_incident_to_in_review_if_necessary!
14 | @incident.in_review! if @incident.approved?
15 | end
16 |
17 | # Returns the current step (one of Incident::STEP_URLS).
18 | def current_step
19 | case params[:controller]
20 | when 'screeners'
21 | :screener
22 | when 'general_infos'
23 | :general_info
24 | when 'involved_civilians'
25 | :involved_civilians
26 | when 'involved_officers'
27 | :involved_officers
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/spec/requests/login_with_devise_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe '[Logging in with Devise]', type: :request do
4 | let(:dummy_user) { create :dummy_user }
5 |
6 | it 'redirects root page to signin' do
7 | visit root_path
8 | expect(page).to have_current_path(new_user_session_path)
9 | end
10 |
11 | it 'redirects new incident page to signin' do
12 | visit new_incident_path
13 | expect(page).to have_current_path(new_user_session_path)
14 | end
15 |
16 | it 'allows login with valid credentials' do
17 | visit new_user_session_path
18 | fill_in 'Email', with: dummy_user.email
19 | fill_in 'Password', with: dummy_user.password
20 | find('input[type=submit]').click
21 | expect(page).to have_current_path(welcome_path)
22 | end
23 |
24 | it 'forbids login with wrong credentials' do
25 | visit new_user_session_path
26 | fill_in 'Email', with: dummy_user.email
27 | fill_in 'Password', with: dummy_user.password + '_'
28 | find('input[type=submit]').click
29 | expect(page).to have_current_path(new_user_session_path)
30 | expect(page).to have_content('Invalid email or password.')
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | require: rubocop-rspec
2 |
3 | Metrics:
4 | Enabled: false # Metrics should never be explicitly used for CI, but can be useful for finding areas to refactor.
5 |
6 | Rails:
7 | Enabled: true # This is a Rails application, so let's run the Rails cops.
8 |
9 | RSpec/ExampleLength:
10 | Enabled: false # Metrics should never be explicitly used for CI.
11 |
12 | RSpec/HookArgument:
13 | EnforcedStyle: each # `before(:each) do` is clearer than `before do`.
14 |
15 | RSpec/MultipleExpectations:
16 | Enabled: false # Metrics should never be explicitly used for CI.
17 |
18 | Style/ExtraSpacing:
19 | Enabled: false # We like to put extra spaces before one-liner comments.
20 |
21 | Style/GuardClause:
22 | Enabled: false # Sometimes the guard clause suggestions would make lines too long, since we disable line-length metrics.
23 |
24 | Style/IndentationConsistency:
25 | EnforcedStyle: rails # We indent private/protected methods as per Rails style.
26 |
27 | Style/RaiseArgs:
28 | EnforcedStyle: compact # We prefer the compact exception raising style.
29 |
30 | Style/StringLiterals:
31 | Enabled: false # We don't really care about ''/"" consistency; changing all our strings would be messy.
32 |
--------------------------------------------------------------------------------
/app/models/agency_status.rb:
--------------------------------------------------------------------------------
1 | # A submission status of an ORI.
2 | # Simply stores an ORI and the last year that agency made a state submission.
3 | class AgencyStatus
4 | include Dynamoid::Document
5 |
6 | table key: :ori
7 |
8 | field :ori, :string
9 | field :complete_submission_years_set, :set, default: Set.new
10 |
11 | validates :ori, presence: true, uniqueness: true
12 |
13 | def self.get_agency_last_submission_year(ori)
14 | agency_status = find_by_ori(ori)
15 | if agency_status.nil?
16 | -1
17 | else
18 | agency_status.last_submission_year
19 | end
20 | end
21 |
22 | def self.find_or_create_by_ori(ori)
23 | find_by_ori(ori) || create(ori: ori)
24 | end
25 |
26 | def self.find_by_ori(ori)
27 | find(ori) # Since ORI is the primary key
28 | end
29 |
30 | def last_submission_year
31 | if complete_submission_years.empty?
32 | -1
33 | else
34 | complete_submission_years.max
35 | end
36 | end
37 |
38 | def mark_year_submitted!(year)
39 | complete_submission_years_set.add(year.to_s) # Store as string
40 | save!
41 | end
42 |
43 | def complete_submission_years
44 | complete_submission_years_set.map(&:to_i).sort # Convert string -> int
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/app/views/devise/shared/_links.html.erb:
--------------------------------------------------------------------------------
1 | <%- if controller_name != 'sessions' %>
2 | <%= link_to "Log in", new_session_path(resource_name) %>
3 | <% end -%>
4 |
5 | <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
6 | <%= link_to "Forgot your password?", new_password_path(resource_name) %>
7 | <% end -%>
8 |
9 | <%- if devise_mapping.registerable? && controller_name != 'registrations' %>
10 | <%= link_to "Not registered? Click here to request access", new_registration_path(resource_name) %>
11 | <% end -%>
12 |
13 |
14 | <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
15 | <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
16 | <% end -%>
17 |
18 | <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
19 | <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
20 | <% end -%>
21 |
22 | <%- if devise_mapping.omniauthable? %>
23 | <%- resource_class.omniauth_providers.each do |provider| %>
24 | <%= link_to "Sign in with #{provider.to_s.titleize}", omniauth_authorize_path(resource_name, provider) %>
25 | <% end -%>
26 | <% end -%>
27 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/doj_dashboard.scss:
--------------------------------------------------------------------------------
1 | #window-submit-button {
2 | font-size: 18px;
3 | margin-top: 30px;
4 | }
5 |
6 | .window-open, .submit-yes {
7 | background-color: $bootstrap-success-color;
8 | color: lighten($bootstrap-success-color, 60%);
9 | border-radius: 3px;
10 | display: inline-block;
11 | padding: 7px;
12 | }
13 |
14 | .window-closed, .submit-no {
15 | background-color: $color-secondary-dark-18f;
16 | color: lighten($color-secondary-dark-18f, 60%);
17 | border-radius: 3px;
18 | display: inline-block;
19 | padding: 7px;
20 | }
21 |
22 | .submit-yes, .submit-no {
23 | padding: 3px 7px;
24 | }
25 |
26 | #agency-submissions-table {
27 | margin-top: 30px;
28 | .ori-col {
29 | width: 15%;
30 | }
31 | .dept-col {
32 | width: 50%;
33 | }
34 | .submitted-col {
35 | width: 10%;
36 | }
37 | .button-col {
38 | width: 25%;
39 | }
40 | }
41 |
42 | #doj_incidents_ori_box {
43 | display: inline-block;
44 | width: 70%;
45 | height: 42px;
46 | vertical-align: middle;
47 | font-size: 18px;
48 | }
49 |
50 | #doj_incidents_search_form {
51 | margin-bottom: 30px;
52 | }
53 |
54 | #doj_incidents_content {
55 | h1 {
56 | margin-bottom: 30px;
57 | }
58 | .alert {
59 | display: inline-block;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/data/scripts/clean_qualifiers.py:
--------------------------------------------------------------------------------
1 | """Script to clean the criminal code qualifiers data."""
2 |
3 | import sys
4 |
5 | from cleaning_helpers import extract_code_number
6 |
7 |
8 | def process(infile, outfile):
9 | """Read the raw data from [infile] and write cleaned data to [outfile]."""
10 | rows = open(infile).read().splitlines()
11 | print('Dropping the first row: %s' % rows[0])
12 | rows = rows[1:]
13 | split_rows = []
14 | l1 = len('32 ')
15 | l2 = l1 + len('PCACCESSORY ')
16 | l3 = l2 + len('ACCESSORY ')
17 | for r in rows:
18 | parts = [r[:l1], r[l1:l2], r[l2:l3], r[l3:]]
19 | split_rows.append([p.strip() for p in parts])
20 | print(split_rows[:3])
21 | split_rows.sort(key=lambda r: extract_code_number(r[0]))
22 | with open(outfile, 'w') as f:
23 | for row in split_rows:
24 | f.write(' '.join(row) + '\n')
25 |
26 |
27 | def main(infile, outfile):
28 | """Usually should not need to change this method, but do so as needed."""
29 | print('Will save processed file to {}'.format(outfile))
30 | print('Processing...')
31 | process(infile, outfile)
32 | print('...success!')
33 |
34 |
35 | if __name__ == '__main__':
36 | main(*sys.argv[1:])
37 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/_breadcrumbs.scss:
--------------------------------------------------------------------------------
1 | $breadcrumbs-height: 50px;
2 | $ursus-id-width: 230px;
3 |
4 | #breadcrumbs {
5 | background-color: #212121;
6 | padding: 0 14px;
7 | font-size: 0; // Remove pesky spacing between inline elements
8 |
9 | a, #breadcrumbs-ursus-id {
10 | display: inline-block;
11 | height: $breadcrumbs-height;
12 | line-height: $breadcrumbs-height;
13 | padding: 0 14px;
14 | margin: 0;
15 | font-weight: normal;
16 | font-size: 16px;
17 | text-decoration: none;
18 | text-align: right;
19 | color: #DDD;
20 | }
21 | a:hover, a.active {
22 | border-bottom: 2px solid white;
23 | }
24 | a.disabled {
25 | pointer-events: none;
26 | color: #525252;
27 | }
28 | a.active {
29 | font-weight: bold;
30 | color: white;
31 | }
32 | a:not(:first-child) {
33 | padding-left: 0;
34 | }
35 | a:not(:first-child):before {
36 | font-family: FontAwesome;
37 | content: "\f054";
38 | padding-right: 14px;
39 | margin-left: 0;
40 | }
41 | a:last-of-type {
42 | margin-right: $ursus-id-width;
43 | }
44 |
45 | #breadcrumbs-ursus-id {
46 | position: absolute;
47 | right: 0;
48 | width: $ursus-id-width;
49 |
50 | &:before {
51 | content: "";
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/db/migrations/20161111_1415_involved_person_copy_changes.rb:
--------------------------------------------------------------------------------
1 | # Renames several fields for InvolvedCivilians and InvolvedOfficers.
2 | class InvolvedPersonCopyChanges < DynamoDB::Migration::Unit
3 | def update
4 | logger = Logger.new(STDOUT)
5 |
6 | logger.info "Running migration #{self.class.name}:"
7 |
8 | civilians = InvolvedCivilian.all
9 | logger.info "Updating #{civilians.length} InvolvedCivilian records ..."
10 | civilians.each do |civilian|
11 | if civilian.resistance_type == 'Resistance'
12 | civilian.resistance_type = 'Active resistance'
13 | end
14 |
15 | if civilian.custody_status == 'In custody'
16 | civilian.custody_status = 'In custody (other)'
17 | end
18 |
19 | civilian.save(validation: false)
20 | end
21 |
22 | officers = InvolvedOfficer.all
23 | logger.info "Updating #{officers.length} InvolvedOfficer records ..."
24 | officers.each do |officer|
25 | officer.officer_used_force_reason = officer.officer_used_force_reason.map do |reason|
26 | reason == 'To effect arrest' ? 'To effect arrest or take into custody' : reason
27 | end
28 |
29 | officer.save(validation: false)
30 | end
31 |
32 | logger.info 'Done!'
33 | logger.info '================================'
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/app/models/concerns/incident_authorization.rb:
--------------------------------------------------------------------------------
1 | # A concern belonging to the Incident model. Methods pertaining to authorization go here.
2 | module IncidentAuthorization
3 | extend ActiveSupport::Concern
4 |
5 | # A normal user can view his or her own incidents only. An admin user
6 | # can view non-draft incidents of all users from their ORI.
7 | def authorized_to_view?(viewing_user)
8 | if viewing_user.doj?
9 | # DOJ can view anything
10 | true
11 | elsif viewing_user.admin?
12 | # Admins can view their own incidents, and any non-draft by anyone
13 | # in their ORI or contract ORIs
14 | (user == viewing_user) || (!draft? && viewing_user.allowed_oris.include?(ori))
15 | else
16 | # Regular user, can only see their own incidents
17 | user == viewing_user
18 | end
19 | end
20 |
21 | def authorized_to_edit?(viewing_user)
22 | if viewing_user.doj?
23 | # DOJ can edit anything.
24 | true
25 | elsif submitted?
26 | # After state submission, incidents can no longer be edited.
27 | false
28 | elsif viewing_user.admin?
29 | # Admins can edit unsubmitted incidents that they can view.
30 | authorized_to_view? viewing_user
31 | else
32 | # Regular users can only edit their drafts.
33 | draft?
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/spec/requests/incident_id_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe '[Incident ID]', type: :request do
4 | let(:user) { build :dummy_user, ori: 'CA0123456' }
5 |
6 | before :all do
7 | Rails.configuration.x.branding.incident_id_prefix = 'PREFIX'
8 | end
9 |
10 | before :each do
11 | login user: user
12 | end
13 |
14 | it 'generates valid ID after general_info step' do
15 | create_partial_incident(:general)
16 | expect(Incident.first.incident_id).to be_blank
17 | answer_all_general_info submit: true
18 |
19 | i = Incident.first
20 | id = i.incident_id
21 | year = i.general_info.compute_datetime.year
22 | expect(id.to_s).not_to be nil
23 | expect(id.to_s[0..-4]).to eq("PREFIX-12-3456-#{year}-")
24 |
25 | expect(i.incident_id.prefix).to eq("PREFIX")
26 | expect(i.incident_id.county).to eq("12")
27 | expect(i.incident_id.agency).to eq("3456")
28 | expect(i.incident_id.year).to eq(year.to_s)
29 | expect(i.incident_id.code.length).to eq(Incident::INCIDENT_ID_CODE_LENGTH)
30 | i.incident_id.code.each_char { |c| expect(Incident::INCIDENT_ID_CODE_CHARS).to include c }
31 | end
32 |
33 | it 'generates unique IDs' do
34 | 5.times { create_partial_incident(:civilians) }
35 | ids = Incident.all.map { |i| i.incident_id.to_s }
36 | expect(ids.uniq.length).to eq(5)
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/app/assets/javascripts/application.js:
--------------------------------------------------------------------------------
1 | // This is a manifest file that'll be compiled into application.js, which will include all the files
2 | // listed below.
3 | //
4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
6 | //
7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8 | // compiled file.
9 | //
10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
11 | // about supported directives.
12 | //
13 | // *******************************************
14 | // * MANIFEST
15 | // *******************************************
16 | //= require es5-shim/es5-shim
17 | //= require jquery
18 | //= require bootstrap-sprockets
19 | //= require jquery_ujs
20 | //= require jquery-ui
21 | //= require jquery-ui/datepicker
22 | //= require jquery-ui-timepicker-addon
23 | //= require_directory .
24 | //= require_directory ./lib
25 | //= stub fastforward
26 | //= require geocomplete
27 | //= require Chart.bundle
28 | //= require chartkick
29 | //= require ahoy
30 |
31 | $(function() {
32 | // General initialization code that applies to all pages goes here.
33 | $('[data-toggle="popover"]').popover({container: "body", html: true});
34 | });
35 |
--------------------------------------------------------------------------------
/data/raw/vcco_county.csv:
--------------------------------------------------------------------------------
1 | "","ALAMEDA","01"
2 | "","ALPINE","02"
3 | "","AMADOR","03"
4 | "","BUTTE","04"
5 | "","CALAVERAS","05"
6 | "","COLUSA","06"
7 | "","CONTRA COSTA","07"
8 | "","DEL NORTE","08"
9 | "","EL DORADO","09"
10 | "","FRESNO","10"
11 | "","GLENN","11"
12 | "","HUMBOLDT","12"
13 | "","IMPERIAL","13"
14 | "","INYO","14"
15 | "","KERN","15"
16 | "","KINGS","16"
17 | "","LAKE","17"
18 | "","LASSEN","18"
19 | "","LOS ANGELES","19"
20 | "","MADERA","20"
21 | "","MARIN","21"
22 | "","MARIPOSA","22"
23 | "","MENDOCINO","23"
24 | "","MERCED","24"
25 | "","MODOC","25"
26 | "","MONO","26"
27 | "","MONTEREY","27"
28 | "","NAPA","28"
29 | "","NEVADA","29"
30 | "","ORANGE","30"
31 | "","OUT OF STATE","98"
32 | "","PLACER","31"
33 | "","PLUMAS","32"
34 | "","RIVERSIDE","33"
35 | "","SACRAMENTO","34"
36 | "","SAN BENITO","35"
37 | "","SAN BERNARDINO","36"
38 | "","SAN DIEGO","37"
39 | "","SAN FRANCISCO","38"
40 | "","SAN JOAQUIN","39"
41 | "","SAN LUIS OBISPO","40"
42 | "","SAN MATEO","41"
43 | "","SANTA BARBARA","42"
44 | "","SANTA CLARA","43"
45 | "","SANTA CRUZ","44"
46 | "","SHASTA","45"
47 | "","SIERRA","46"
48 | "","SISKIYOU","47"
49 | "","SOLANO","48"
50 | "","SONOMA","49"
51 | "","STANISLAUS","50"
52 | "","SUTTER","51"
53 | "","TEHAMA","52"
54 | "","TRINITY","53"
55 | "","TULARE","54"
56 | "","TUOLUMNE","55"
57 | "","UNKNOWN","99"
58 | "","VENTURA","56"
59 | "","YOLO","57"
60 | "","YUBA","58"
--------------------------------------------------------------------------------
/app/views/layouts/partials/_head.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= content_for?(:title) ? content_for(:title) + " | " : "" %>
8 | <%= Rails.configuration.x.branding.ursus? ? 'URSUS | California' : 'Bridge |' %>
9 | Use of Force Incident Reporting
10 |
11 |
12 | <%= stylesheet_link_tag 'application', media: 'all' %>
13 | <%= javascript_include_tag 'application' %>
14 | <% if Rails.env.development? || ENV['ENABLE_FAST_FORWARD'] == 'true' %>
15 | <%= javascript_include_tag 'fastforward' %>
16 | <% end %>
17 |
18 | <%= csrf_meta_tags %>
19 |
20 | <%= favicon_link_tag 'favicon.ico' %>
21 |
22 |
23 |
26 |
29 |
30 |
31 |
32 |
36 |
37 |
--------------------------------------------------------------------------------
/app/views/application/_incidents_table.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | INCIDENT ID
5 | DATE AND TIME
6 |
7 |
8 |
9 |
10 |
11 |
12 | <% incidents.each do |incident| %>
13 |
14 | <%= incident.incident_id.present? ? render_incident_id(incident) : "(incomplete)" %>
15 | <%= incident.try { |i| i.general_info.target }.try(:display_datetime) || "(incomplete)" %>
16 |
17 |
18 | <% case incident.status
19 | when 'draft' %>
20 | Edit
21 | <% else %>
22 | View
23 | <% end %>
24 |
25 |
26 | <% if incident.draft? %>
27 | <%= button_to "Delete",
28 | { controller: "incidents", action: "destroy", id: incident.id, status: @status },
29 | method: :delete, data: { confirm: "Are you sure you want to discard this incident?" },
30 | class: "btn btn-danger", form_class: "single-button-form" %>
31 |
32 | <% end %>
33 |
34 |
35 | <% end %>
36 |
37 |
38 |
--------------------------------------------------------------------------------
/data/scripts/generate_ori_contracts_rb.py:
--------------------------------------------------------------------------------
1 | """Generates a Ruby file that contains police ORI data."""
2 |
3 | import sys
4 | from collections import defaultdict
5 |
6 | import pandas as pd
7 |
8 |
9 | def process(infile, outfile):
10 | """Read data from infile and generate js at outfile."""
11 | outlines = [
12 | "# This file was automatically generated by a rule in the Makefile",
13 | "module Constants",
14 | " CONTRACTING_ORIS = {",
15 | ]
16 | df = pd.read_csv(infile)
17 | agency_to_contracts = defaultdict(list)
18 | for sub, parent in zip(df['ORI'], df['CONTRACTS_TO_ORI']):
19 | if pd.isnull(parent) or parent == '':
20 | continue
21 | agency_to_contracts[parent].append(sub)
22 |
23 | for parent in sorted(agency_to_contracts):
24 | subs = sorted(agency_to_contracts[parent])
25 | outlines.append(' "%s" => %%w(%s),' % (parent, " ".join(subs)))
26 |
27 | outlines[-1] = outlines[-1][:-1]
28 | outlines.extend([
29 | " }.freeze",
30 | "end"])
31 | with open(outfile, 'w') as f:
32 | f.write('\n'.join(outlines) + '\n')
33 |
34 |
35 | def main(infile, outfile):
36 | """Usually should not need to change this method, but do so as needed."""
37 | print('Creating {} ...'.format(outfile))
38 | process(infile, outfile)
39 | print('...success!')
40 |
41 |
42 | if __name__ == '__main__':
43 | main(*sys.argv[1:])
44 |
--------------------------------------------------------------------------------
/app/models/incident_id.rb:
--------------------------------------------------------------------------------
1 | # Value object representing the Ursus ID of an incident.
2 | class IncidentId
3 | delegate :to_s, :blank?, :present?, to: :id_string
4 |
5 | def initialize(incident)
6 | @incident = incident
7 | end
8 |
9 | def generate!
10 | return unless @incident.ori && @incident.year
11 |
12 | prefix = "#{Rails.configuration.x.branding.incident_id_prefix}" \
13 | "-#{@incident.ori[3..4]}-#{@incident.ori[5..-1]}-#{@incident.year}-"
14 | chars = Incident::INCIDENT_ID_CODE_CHARS
15 |
16 | 5.tries(catching: BridgeExceptions::IncidentIdCollisionError) do
17 | try_id = prefix + (0...Incident::INCIDENT_ID_CODE_LENGTH).map { chars[rand(chars.length)] }.join
18 |
19 | unless @incident.update_attribute(:incident_id_str, try_id)
20 | exception_msg = "Failed to generate a unique ID for this incident. Most recently tried #{try_id}"
21 | raise BridgeExceptions::IncidentIdCollisionError.new(exception_msg)
22 | end
23 | end
24 | end
25 |
26 | def prefix
27 | id_string.split('-')[0]
28 | end
29 |
30 | def county
31 | id_string.split('-')[1]
32 | end
33 |
34 | def agency
35 | id_string.split('-')[2]
36 | end
37 |
38 | def year
39 | id_string.split('-')[3]
40 | end
41 |
42 | def code
43 | id_string.split('-')[4]
44 | end
45 |
46 | private
47 |
48 | def id_string
49 | generate! unless @incident.incident_id_str
50 | @incident.incident_id_str
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/splash_one_col.scss:
--------------------------------------------------------------------------------
1 | $demo-splash-inner-width: 650px;
2 |
3 | #splash-one-col {
4 | position: absolute;
5 | width: 100%;
6 | height: 100%;
7 | margin: 0;
8 | background-color: $dark-background-color;
9 |
10 | .inner {
11 | width: $demo-splash-inner-width;
12 | margin: 0 auto;
13 | padding: 0 72px;
14 | font-size: 16px;
15 | color: white;
16 | text-align: left;
17 |
18 | a {
19 | font-weight: bold;
20 | color: #02BFE7;
21 | }
22 |
23 | h1 {
24 | font-family: 'Merriweather', serif;
25 | font-weight: 800;
26 | font-size: 24px;
27 | margin-bottom: 48px;
28 | }
29 |
30 | p {
31 | margin-bottom: 36px;
32 |
33 | description {
34 | margin-bottom: 60px;
35 | }
36 | }
37 |
38 | button {
39 | padding: 8px 60px
40 | }
41 | }
42 |
43 | &.maintenance {
44 | background-color: #f3f3f3;
45 |
46 | .inner {
47 | color: $grayish-color;
48 | text-align: center;
49 |
50 | * {
51 | margin-bottom: 18px;
52 | }
53 |
54 | img {
55 | width: 300px;
56 | }
57 | }
58 | }
59 | }
60 |
61 | @media screen and (max-width: $demo-splash-inner-width) {
62 | #splash-one-col {
63 |
64 | .inner {
65 | margin-top: 0px !important; // necessary to prevent jQuery.flexVerticalCenter from messing up on mobile
66 | min-height: auto;
67 | max-height: 216px;
68 | width: auto;
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/data/scripts/cleanup_csv.py:
--------------------------------------------------------------------------------
1 | """Cleans CSVs sent to us by DOJ."""
2 |
3 | import csv
4 | import sys
5 |
6 |
7 | def process(infile, outfile, columns):
8 | """Convert and clean according to a few rules."""
9 | to_drop = [i for i, item in enumerate(columns) if item == 'DROP']
10 | if to_drop:
11 | print('Dropping columns: {}'.format(to_drop))
12 | to_drop.reverse() # So we can iterate through and drop
13 | for idx in to_drop:
14 | columns.pop(idx)
15 | to_write = [columns]
16 |
17 | with open(infile) as f:
18 | reader = csv.reader(f)
19 | for row in reader:
20 | for idx in to_drop:
21 | row.pop(idx)
22 | row = [elt.strip() for elt in row]
23 | to_write.append(row)
24 |
25 | with open(outfile, 'w') as f:
26 | writer = csv.writer(f)
27 | for row in to_write:
28 | writer.writerow(row)
29 |
30 |
31 | def main(infile, outfile, columns):
32 | """Usually should not need to change this method, but do so as needed."""
33 | columns = columns.split(',')
34 | print('Will save processed file to {}'.format(outfile))
35 | print('Processing...')
36 | process(infile, outfile, columns)
37 | print('...success!')
38 |
39 |
40 | if __name__ == '__main__':
41 | if len(sys.argv) != 4:
42 | print(('Usage: %s [input_filename] [output_filename] '
43 | '[column1,DROP,column2,...,columnN]' % sys.argv[0]))
44 | sys.exit(1)
45 | main(*sys.argv[1:])
46 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/_dashboard_sidenav.scss:
--------------------------------------------------------------------------------
1 | $sidenav-item-height: 50px;
2 | $sidenav-text-color: #000;
3 | #dashboardSideNav {
4 | border-right: 1px solid $border-separator-color;
5 |
6 | li {
7 | padding: 0;
8 | padding-left: 14px;
9 | color: $sidenav-text-color;
10 | height: $sidenav-item-height;
11 | line-height: $sidenav-item-height;
12 | font-weight: normal;
13 | font-size: 18px;
14 | a, a:hover {
15 | text-decoration: none;
16 | color: inherit;
17 | cursor: inherit;
18 | display: inline-block;
19 | }
20 | overflow: hidden;
21 | white-space: nowrap;
22 | text-overflow: ellipsis;
23 | list-style-type: none;
24 |
25 | &:before {
26 | content: "\2022";
27 | line-height: 0;
28 | margin-right: 0.4em;
29 | }
30 | }
31 | li.active {
32 | cursor: default;
33 | }
34 | li:not(.active):hover {
35 | cursor: pointer;
36 | opacity: 0.7;
37 | }
38 | li.active, li:not(.active):hover {
39 | color: $minor-button-color;
40 | }
41 | }
42 |
43 | @media screen and (max-width: 768px) {
44 | #dashboardSideNav {
45 | width: 100%;
46 | border: none;
47 |
48 | li {
49 | list-style: none;
50 | height: 30;
51 | border-bottom: 1px solid $border-separator-color;
52 |
53 | &:before {
54 | content: "";
55 | margin: 0;
56 | }
57 |
58 | &:hover {
59 | color: white !important;
60 | background-color: $minor-button-color;
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | ruby "2.2.3"
4 |
5 | gem 'ahoy_matey'
6 | gem 'aws-sdk', '~> 2'
7 | gem 'bootstrap-sass', '~> 3.3'
8 | gem 'chartkick'
9 | gem 'coffee-rails', '~> 4.1.0'
10 | gem 'devise', '~> 3.5'
11 | gem 'dotenv-rails'
12 | gem 'dynamodb-migration', git: 'https://github.com/bayesimpact/dynamodb-migration.git'
13 | gem 'dynamoid', git: 'https://github.com/bayesimpact/Dynamoid.git'
14 | gem 'dynamoid-devise'
15 | gem 'es5-shim-rails'
16 | gem 'factory_girl_rails', '~> 4.0'
17 | gem 'font-awesome-rails', '~> 4.5'
18 | gem 'gaffe'
19 | gem 'geocomplete_rails'
20 | gem 'jquery-rails'
21 | gem 'jquery-timepicker-addon-rails'
22 | gem 'jquery-ui-rails', '~> 5.0'
23 | gem 'orm_adapter-dynamoid', git: 'https://github.com/bayesimpact/orm_adapter-dynamoid.git'
24 | gem 'puma'
25 | gem 'rails', '4.2.3'
26 | gem 'request_store'
27 | gem 'sass-rails', '~> 5.0'
28 | gem 'uglifier', '>= 1.3.0'
29 |
30 | group :test do
31 | gem 'capybara', '~> 2.7'
32 | gem 'capybara-slow_finder_errors'
33 | gem 'phantomjs', require: 'phantomjs/poltergeist'
34 | gem 'poltergeist'
35 | gem 'rspec-rails', '~> 3.4'
36 | gem 'rspec-retry'
37 | gem 'timecop'
38 | end
39 |
40 | group :development do
41 | gem 'launchy'
42 | gem 'quiet_assets' # Don't clutter the logs with GET calls to assets.
43 | end
44 |
45 | group :development, :test do
46 | gem 'byebug' # Call 'byebug' anywhere in the code to stop execution and get a debugger console.
47 | gem 'spring' # Spring speeds up development by keeping your application running in the background.
48 | end
49 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/devise.scss:
--------------------------------------------------------------------------------
1 | // Styles for pages like login, registration, etc (based on the Devise plugin)
2 | #dialogOverlay {
3 | position: fixed;
4 | top: 0;
5 | left: 0;
6 | width: 100%;
7 | height: 100%;
8 | background-color: #323a45;
9 | opacity: 0.8;
10 | z-index: 2000;
11 | }
12 |
13 | @import url(https://fonts.googleapis.com/css?family=Merriweather:300);
14 |
15 | #dialog {
16 | position: relative;
17 | background-color: #f8f8f8;
18 | opacity: 1.0;
19 | margin: 112px auto;
20 | padding: 56px;
21 | border-radius: 3px;
22 | width: 420px;
23 | z-index: 2001;
24 | text-align: center;
25 | h2 {
26 | font-family: 'Merriweather', serif;
27 | color: $header-text-color;
28 | margin-top: 0;
29 | margin-bottom: 14px;
30 | }
31 | .field {
32 | margin-top: 0;
33 | margin-bottom: 14px;
34 | }
35 | label {
36 | margin: 0;
37 | }
38 | input:not([type="checkbox"]) {
39 | width: 100%;
40 | border-radius: 3px;
41 | margin: 0;
42 | font-size: 16px;
43 | font-weight: normal;
44 | padding: 14px;
45 | border: 1px solid $border-separator-color;
46 | }
47 | input[type="submit"]{
48 | padding: 0;
49 | line-height: 42px;
50 | background-color: $minor-button-color;
51 | color: white;
52 | font-size: 18px;
53 | font-weight: bold;
54 | }
55 | a {
56 | display: block;
57 | color: $header-text-color;
58 | font-weight: normal;
59 | font-size: 14px;
60 | text-decoration: underline;
61 | margin: 0;
62 | margin-top: 22px;
63 | }
64 | }
--------------------------------------------------------------------------------
/app/services/bulk_import_service.rb:
--------------------------------------------------------------------------------
1 | # BulkImportService.import handles bulk import of incidents.
2 | class BulkImportService
3 | def self.import(file, user)
4 | incidents = _parse_file(file)
5 |
6 | output = ["Uploading #{incidents.size} #{incidents.size == 1 ? 'incident' : 'incidents'} ..."]
7 | incidents.each_with_index do |incident_hash, idx|
8 | output << _import_incident(incident_hash, user, idx)
9 | end
10 | output
11 | rescue BridgeExceptions::BulkUploadError => e
12 | e.message.split("\n")
13 | end
14 |
15 | # "Private methods"
16 |
17 | # Parse a JSON or XML file into an array of hashes representing incidents.
18 | def self._parse_file(file)
19 | filename = file.original_filename
20 | contents = file.read
21 |
22 | begin
23 | # Parse as JSON by default, but parse as XML if the extension is '.xml'.
24 | if filename.end_with?('.xml')
25 | Hash.from_xml(contents)['incidents']
26 | else
27 | JSON.parse(contents)
28 | end
29 | rescue JSON::ParserError, REXML::ParseException => e
30 | msg = "Invalid file!\nAn error occurred while parsing #{filename}:\n #{e.to_s.split("\n").first}"
31 | raise BridgeExceptions::BulkUploadError.new(msg)
32 | end
33 | end
34 |
35 | def self._import_incident(incident_hash, user, idx)
36 | incident = Incident.from_hash(incident_hash, user)
37 | "##{idx + 1}. Created incident #{incident.incident_id}."
38 | rescue BridgeExceptions::DeserializationError => e
39 | "##{idx + 1}. Error occurred!\n #{e.message}"
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/app/views/incidents/upload.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for :sidenav do %>
2 | <%= render partial: 'dashboard_sidenav' %>
3 | <% end %>
4 |
5 | <% content_for :panel do %>
6 |
7 |
Bulk Upload
8 |
9 | <% if @output %>
10 |
11 |
Results
12 |
<%= @output.join("\n")%>
13 |
14 | <% end %>
15 |
16 |
Use this form to upload multiple incidents at once. The uploaded file
17 | must be a JSON or XML file matching our schema [show] .
18 |
19 |
20 |
21 | Contact us if you have questions about bulk upload.
22 |
23 |
24 | <%= form_tag({action: :upload}, id: 'upload-control', multipart: true) do %>
25 |
26 | Choose file to upload
27 |
28 |
29 | <% end %>
30 |
31 |
32 |
33 |
34 |
<%= @json_schema %>
35 |
36 |
37 |
38 |
<%= @xml_schema %>
39 |
40 |
41 |
42 | <% end %>
43 |
44 | <%= render template: "layouts/dashboard" %>
45 |
--------------------------------------------------------------------------------
/app/models/constants/involved_officer.rb:
--------------------------------------------------------------------------------
1 | module Constants
2 | # Constants related to the InvolvedOfficer model.
3 | module InvolvedOfficer
4 | AGES = [
5 | '18-20', '21-25', '26-30', '31-35', '36-40', '41-45', '46-50', '51-55',
6 | '56-60', '61-65', '66-70', '71-75', '76-80', '81-85', '86-90', '91-95', '96-100+'
7 | ].freeze
8 |
9 | DRESS_TYPES = ['Patrol Uniform', 'Tactical', 'Utility', 'Plainclothes'].freeze
10 |
11 | RECEIVED_FORCE_TYPES = [
12 | 'Civilian physical contact', 'Civilian vehicle contact', 'Blunt / impact weapon',
13 | 'Chemical spray (e.g. OC/CS)', 'Electronic control device', 'Impact projectile',
14 | 'Knife, blade, or stabbing instrument', 'Threat of firearm', 'Discharge of firearm (miss)',
15 | 'Discharge of firearm (hit)', 'Other dangerous weapon', 'Animal'
16 | ].freeze
17 |
18 | OFFICER_USED_FORCE_REASON_TYPES = ['To effect arrest or take into custody',
19 | 'To prevent escape',
20 | 'To overcome resistance'].freeze
21 |
22 | DISPLAY_FIELDS = ([
23 | :officer_used_force, :officer_used_force_reason, :on_duty, :dress
24 | ] + Constants::InvolvedPerson::DISPLAY_FIELDS).freeze
25 |
26 | FBI_FIELDS = Constants::InvolvedPerson::FBI_FIELDS.freeze
27 |
28 | CUSTOM_LABELS_FOR_REVIEW = {
29 | officer_used_force: "Officer used force against civilian(s)?",
30 | received_force: "Officer assaulted by civilian(s)?",
31 | on_duty: "On duty?"
32 | }.merge(Constants::InvolvedPerson::CUSTOM_LABELS_FOR_REVIEW).freeze
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/app/views/pages/splash_whitelabel.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= render "layouts/partials/head" %>
4 |
5 |
6 |
This site demonstrates the Bayes Impact police use of force reporting system.
7 |
This is the first product of our larger Bridge initiative to improve police integrity and community relations.
8 |
This application, implemented first in California under the name
9 | URSUS , helps police departments determine which incidents require reporting, fill out or upload incident information, send the report to the state, and perform analysis.
11 |
Click the button below to log into the demo application as an example user of a fictitious police department.
12 | <%= form_tag do %>
13 |
ENTER
14 | <% end %>
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/helpers/form_controls_helper.rb:
--------------------------------------------------------------------------------
1 | # Helper methods pertaining to form controls and collections of form controls.
2 | module FormControlsHelper
3 | def yes_no_question(f, field, label, params = {})
4 | params.reverse_merge!(
5 | f: f,
6 | field: field,
7 | label: label,
8 | label_class: params[:class] || '',
9 | yes_label: 'Yes',
10 | no_label: 'No',
11 | check_yes: nil,
12 | check_no: nil,
13 | help_title: nil,
14 | help_content: nil
15 | ).except!(:class) # Remove 'class' param to avoid issues because it's a keyword.
16 |
17 | render partial: "yes_no_question", locals: params
18 | end
19 |
20 | def checkbox_grid(f, method, collection, width)
21 | content_tag :div, class: 'checkbox-grid' do
22 | f.collection_check_boxes method, collection, :to_s, :to_s, include_hidden: false do |b|
23 | content_tag :div, b.check_box + b.label, class: 'item', style: "width: #{width}px;"
24 | end
25 | end
26 | end
27 |
28 | def radio_button_grid(f, method, collection, width, popovers = {})
29 | content_tag :div, class: 'checkbox-grid' do
30 | collection.map do |item|
31 | content_tag :div, class: 'item', style: "width: #{width}px;" do
32 | content_tag(:label, f.radio_button(method, item) + content_tag(:span, item)) + popover(item, popovers[item])
33 | end
34 | end.reduce(&:+)
35 | end
36 | end
37 |
38 | def popover(title, content)
39 | if content
40 | content_tag :div, '', class: 'help-tip', title: title,
41 | data: { toggle: 'popover', trigger: 'hover', content: content }
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/app/models/involved_officer.rb:
--------------------------------------------------------------------------------
1 | # An Involved Officer form within an incident report.
2 | class InvolvedOfficer < InvolvedPerson
3 | include Constants::InvolvedOfficer
4 |
5 | table name: :involved_officers
6 |
7 | before_save :maybe_clear_additional_questions
8 |
9 | field :age, :string
10 | field :officer_used_force, :boolean
11 | field :officer_used_force_reason, :array, default: []
12 | field :received_force, :boolean
13 | field :received_force_type, :array, default: []
14 | field :on_duty, :boolean
15 | field :dress, :string
16 |
17 | validates :age, inclusion: { in: AGES }
18 | validates :officer_used_force, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD }
19 | validates :officer_used_force_reason, presence: true, subset: { in: OFFICER_USED_FORCE_REASON_TYPES }, if: :officer_used_force
20 | validates :received_force, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD }
21 | validates :on_duty, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD }
22 | validates :dress, inclusion: { in: DRESS_TYPES }
23 |
24 | with_options if: :received_force do
25 | validates :received_force_type, presence: true, subset: { in: RECEIVED_FORCE_TYPES }
26 | validates :received_force_location, presence: true, subset: { in: RECEIVED_FORCE_LOCATIONS }
27 | end
28 |
29 | private
30 |
31 | def maybe_clear_additional_questions
32 | unless received_force
33 | self[:received_force_type] = nil
34 | self[:received_force_location] = nil
35 | end
36 |
37 | self[:officer_used_force_reason] = nil unless officer_used_force
38 |
39 | super
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/db/migrations/20161111_1324_remove_contracting_ori_field.rb:
--------------------------------------------------------------------------------
1 | # Migration to remove the 'ori' field for Incidents and
2 | # rename the GeneralInfo 'contracting_for_ori' field to 'ori'.
3 | class RemoveContractingOriField < DynamoDB::Migration::Unit
4 | def update
5 | logger = Logger.new(STDOUT)
6 |
7 | logger.info "Running migration #{self.class.name}:"
8 |
9 | incident_ids = Incident.all.map(&:id)
10 | logger.info "Updating #{incident_ids.length} Incident records ..."
11 | incident_ids.each do |id|
12 | client.update_item(
13 | table_name: "#{Dynamoid.config.namespace}_incidents",
14 | key: { 'id' => id },
15 | update_expression: "REMOVE ori"
16 | )
17 | end
18 |
19 | gis = GeneralInfo.all
20 | logger.info "Updating #{gis.length} GeneralInfo records ..."
21 | gis.each do |gi|
22 | # First, rename contracting_for_ori field to ori.
23 | begin
24 | client.update_item(
25 | table_name: "#{Dynamoid.config.namespace}_general_infos",
26 | key: { 'id' => gi.id },
27 | update_expression: "SET ori = contracting_for_ori REMOVE contracting_for_ori",
28 | condition_expression: "attribute_exists (contracting_for_ori)"
29 | )
30 | rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
31 | # Then, for any incidents who don't have an explicit ori defined,
32 | # take the user's ORI.
33 | logger.debug " #{gi.id} has no contracting_for_ori - setting its ori to user's ori: #{gi.incident.user.ori}"
34 | gi.ori = gi.incident.user.ori
35 | gi.save!
36 | end
37 | end
38 |
39 | logger.info 'Done!'
40 | logger.info '================================'
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/app/queries/compute_analytics_query.rb:
--------------------------------------------------------------------------------
1 | # Computes department-level analytics.
2 | class ComputeAnalyticsQuery < Query
3 | private
4 |
5 | def perform_query(params)
6 | {
7 | all_years: all_years(params[:user]),
8 | num_incidents: num_incidents(params[:user], params[:year]),
9 | num_injured_civilians: num_injured_persons(params[:user], params[:year], :civilian),
10 | num_injured_officers: num_injured_persons(params[:user], params[:year], :officer),
11 | incidents_by_month: incidents_by_month(params[:user], params[:year])
12 | }
13 | end
14 |
15 | def all_years(user)
16 | all_incidents = GetAllIncidentsQuery.new.run(user: user)
17 | years = all_incidents.map(&:year).compact.to_set
18 |
19 | years.sort + ['all']
20 | end
21 |
22 | def num_incidents(user, year)
23 | incidents_for_year(user, year).count
24 | end
25 |
26 | def num_injured_persons(user, year, type)
27 | incidents_for_year(user, year)
28 | .map { |i| i.send("involved_#{type}s").all.count(&:seriously_injured_or_deceased?) }
29 | .sum
30 | end
31 |
32 | def incidents_by_month(user, year)
33 | incidents = incidents_for_year(user, year)
34 | months = Date::MONTHNAMES.each_with_index.drop(1) # Date::MONTHNAMES has a nil first element.
35 |
36 | months.map do |month, idx|
37 | [month, incidents.count { |i| i.month == idx }]
38 | end
39 | end
40 |
41 | def incidents_for_year(user, year)
42 | GetAllIncidentsQuery.new.run(user: user)
43 | .select { |i| i.year == year || year.blank? } # Filter by year if a year is given.
44 | .reject(&:draft?) # Don't count draft incidents in analytics.
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/app/views/application/_received_force.html.erb:
--------------------------------------------------------------------------------
1 | <% @klass = (person_type == 'Civilian') ? InvolvedCivilian : InvolvedOfficer %>
2 | <% @perpetrator = (person_type == 'Civilian') ? 'officer' : 'civilian' %>
3 | <% @victim = (person_type == 'Civilian') ? 'civilian' : 'officer' %>
4 |
5 |
6 |
7 | <%= f.label :received_force_type, "Type of force used on #@victim by #@perpetrator(s) - check all that apply", class: 'required' %>
8 | <%= checkbox_grid(f, :received_force_type, @klass::RECEIVED_FORCE_TYPES, 320) %>
9 |
10 |
11 |
12 | <%= f.label :received_force_location, "Location on #@victim where #@perpetrator(s) used force - check all that apply", class: 'required' %>
13 | <%= checkbox_grid(f, :received_force_location, @klass::RECEIVED_FORCE_LOCATIONS, 200) %>
14 |
15 |
16 | <% if person_type == 'Civilian' %>
17 |
31 | <% end %>
32 |
33 |
--------------------------------------------------------------------------------
/spec/mailers/bridge_mailer_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe BridgeMailer, type: :mailer do
4 | def extract_address_only(mail_target)
5 | # Email addresses may contain descriptive names, e.g. '"John Doe" '
6 | # This method extracts the john.doe@example.com part
7 | return mail_target unless mail_target.end_with?('>')
8 | mail_target.split('<')[-1][0..-2]
9 | end
10 |
11 | it 'extract_address_only helper method works' do
12 | expect(extract_address_only('"John Doe" ')).to eq('john.doe@example.com')
13 | expect(extract_address_only('john.doe@example.com')).to eq('john.doe@example.com')
14 | end
15 |
16 | describe 'feedback_email' do
17 | let(:user) { build :dummy_user }
18 | let(:feedback) { Feedback.build(source: "Source test", content: "Content test") }
19 | let(:mail) { BridgeMailer.feedback_email(feedback, user).deliver_now }
20 |
21 | it 'has the correct subject' do
22 | expect(mail.subject).to eq("URSUS feedback from #{user.full_name}")
23 | end
24 |
25 | it 'has the correct TO' do
26 | correct_addr = extract_address_only Rails.configuration.x.mail.feedback_to_address
27 | expect(mail.to).to eq([correct_addr])
28 | end
29 |
30 | it 'CCs the user who sent the feedback' do
31 | expect(mail.cc).to eq([user.email])
32 | end
33 |
34 | it 'has the correct sender email' do
35 | correct_addr = extract_address_only Rails.configuration.x.mail.from_address
36 | expect(mail.from).to eq([correct_addr])
37 | end
38 |
39 | it 'contains the feedback source and content' do
40 | expect(mail.body.encoded).to match(feedback.source)
41 | expect(mail.body.encoded).to match(feedback.content)
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/app/models/concerns/can_lookup_fields.rb:
--------------------------------------------------------------------------------
1 | # Provides helper methods for determining which fields of a Document are important.
2 | module CanLookupFields
3 | extend ActiveSupport::Concern
4 |
5 | UNIMPORTANT_FIELDS = [:id, :created_at, :updated_at, :partial].freeze
6 | UNIMPORTANT_FIELD_SUFFIXES = %w(_id _ids).freeze
7 |
8 | # [Class methods.]
9 | module ClassMethods
10 | def important_fields
11 | # They are the only parameters permitted in a create/update action for a given model
12 | # (but see #permitted_fields for how we must format them before passing to params.permit()).
13 | # These are also the fields that are used for serializing / checking equality of incidents.
14 | @important_fields ||= attributes.keys.select { |f| important_field? f }
15 | end
16 |
17 | def array_fields
18 | # These are the fields of a model that have {type: :array}.
19 | @array_fields ||= attributes.select { |_, v| v[:type] == :array }.keys
20 | end
21 |
22 | def permitted_fields
23 | # If important_fields are [:field1, :field2, :array_field]
24 | # and array_fields are [:array_field],
25 | # then permitted_fields are [:field1, :field2, {array_field: []}].
26 | # This is the format that's needed to match a combination of
27 | # scalar and array fields when doing params.permit() in a controller.
28 | @permitted_fields ||= important_fields.map do |field|
29 | if array_fields.include? field
30 | { field => [] }
31 | else
32 | field
33 | end
34 | end
35 | end
36 |
37 | private
38 |
39 | def important_field?(field)
40 | UNIMPORTANT_FIELDS.exclude?(field) && UNIMPORTANT_FIELD_SUFFIXES.none? { |suff| field.to_s.end_with? suff }
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | resource :screener, only: [:new, :create], path_names: { new: "" }
3 |
4 | resources :incidents, except: [:index, :create, :edit] do
5 | resource :general_info, only: [:update, :edit], path_names: { edit: "" }
6 | resources :involved_civilians, except: :show
7 | resources :involved_officers, except: :show
8 |
9 | member do
10 | get 'review'
11 | end
12 |
13 | collection do
14 | match 'upload', via: [:get, :post]
15 | post 'create_fake' if Rails.configuration.x.login.use_demo?
16 | end
17 | end
18 |
19 | controller :incidents do
20 | get 'dashboard(/:status(/:year))' => :index, as: 'dashboard'
21 | post 'dashboard' => :submit_to_state!
22 |
23 | get 'incidents.json' => :json
24 | end
25 |
26 | controller :doj do
27 | get 'doj' => :overview
28 | get 'doj/overview' => :overview
29 | get 'doj/window' => :window
30 | post 'doj/window' => :window_toggle
31 | get 'doj/whosubmitted' => :whosubmitted
32 | get 'doj/incidents(/:ori)' => :incidents
33 | get 'doj/analysis' => :analysis
34 | end
35 |
36 | controller :feedback do
37 | get 'feedback' => :new
38 | post 'feedback' => :create
39 | get 'thank_you' => :thank_you
40 | end
41 |
42 | controller :splash do
43 | get 'welcome' => :splash_show
44 | post 'welcome' => :splash_dismiss
45 | end
46 |
47 | controller :monitoring do
48 | get 'ping' => :ping
49 | get 'ping.html' => :ping
50 | end
51 |
52 | root to: "incidents#index"
53 |
54 | if Rails.configuration.x.login.use_devise?
55 | devise_for :users, controllers: { sessions: 'users/sessions' }
56 | elsif Rails.configuration.x.login.use_demo?
57 | post 'reset_demo' => "application#reset_demo!"
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/_style_reset.scss:
--------------------------------------------------------------------------------
1 | // CSS reset -- since browsers all have differet funky defaults for
2 | // different html elements.
3 | // Tweaked from Eric Meyer's http://meyerweb.com/eric/tools/css/reset/
4 |
5 | // To avoid accidentally importing multiple times, first @import this file,
6 | // then call @include style-reset() at the right invocation point.
7 | // @import "style-reset";
8 | // @include style-reset();
9 | @mixin style-reset() {
10 | html, body, div, span, applet, object, iframe,
11 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
12 | a, abbr, acronym, address, big, cite, code,
13 | del, dfn, em, img, ins, kbd, q, s, samp,
14 | small, strike, strong, sub, sup, tt, var,
15 | b, u, i, center,
16 | dl, dt, dd,
17 | fieldset, form, label, legend,
18 | table, caption, tbody, tfoot, thead, tr, th, td,
19 | article, aside, canvas, details, embed,
20 | figure, figcaption, footer, header, hgroup,
21 | menu, nav, output, ruby, section, summary,
22 | time, mark, audio, video {
23 | margin: 0;
24 | padding: 0;
25 | border: 0;
26 | font-size: 100%;
27 | font: inherit;
28 | vertical-align: baseline;
29 | }
30 | ol, ul, li {
31 | // padding: 0;
32 | border: 0;
33 | font-size: 100%;
34 | font: inherit;
35 | vertical-align: baseline;
36 | }
37 | /* HTML5 display-role reset for older browsers */
38 | article, aside, details, figcaption, figure,
39 | footer, header, hgroup, menu, nav, section {
40 | display: block;
41 | }
42 | body {
43 | line-height: 1;
44 | }
45 | blockquote, q {
46 | quotes: none;
47 | }
48 | blockquote:before, blockquote:after,
49 | q:before, q:after {
50 | content: '';
51 | content: none;
52 | }
53 | table {
54 | border-collapse: collapse;
55 | border-spacing: 0;
56 | }
57 | };
58 |
--------------------------------------------------------------------------------
/app/models/concerns/has_only_one_instance.rb:
--------------------------------------------------------------------------------
1 | # Models with this concern operate as a singleton. There should only ever be one
2 | # record in the table. The global_unique_string field ensures this.
3 | #
4 | # Usage: Use self.instance() to access (find or create) the instance,
5 | # and specify default fields to set (if any) in the self.DEFAULT_FIELDS hash.
6 | module HasOnlyOneInstance
7 | extend ActiveSupport::Concern
8 |
9 | included do
10 | self.DEFAULT_FIELDS = {}
11 | self.GLOBAL_UNIQUE_STRING = 'specific-string-to-ensure-a-single-state' # the actual string is irrelevant
12 | self.UNIQUENESS_ERROR_MESSAGE = "must set global_unique_string to '#{self.GLOBAL_UNIQUE_STRING}' (not '%{value}')"
13 |
14 | # Enforce that only one instance of this state ever exists.
15 | field :global_unique_string, :string
16 | validates :global_unique_string, uniqueness: true, inclusion: { in: [self.GLOBAL_UNIQUE_STRING],
17 | message: self.UNIQUENESS_ERROR_MESSAGE }
18 |
19 | # Set primary key.
20 | table key: :global_unique_string
21 | end
22 |
23 | # [Class methods.]
24 | module ClassMethods
25 | attr_accessor :DEFAULT_FIELDS, :GLOBAL_UNIQUE_STRING, :UNIQUENESS_ERROR_MESSAGE
26 |
27 | def instance
28 | # Get or create the singleton instance, being aware of concurrency issues
29 | find(self.GLOBAL_UNIQUE_STRING) || begin
30 | Rails.logger.info("Creating GlobalState singleton object")
31 | create(self.DEFAULT_FIELDS.merge(global_unique_string: self.GLOBAL_UNIQUE_STRING))
32 | # The above could fail if we race with another create attempt, but in
33 | # either case we can expect the state to be ready.
34 | find(self.GLOBAL_UNIQUE_STRING)
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/spec/models/agency_status_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe AgencyStatus, type: :model do
4 | let(:ori) { "ORI_1" }
5 | let(:other_ori) { "ORI_2" }
6 |
7 | let!(:status) { AgencyStatus.create(ori: ori) }
8 | let!(:other_status) { AgencyStatus.create(ori: other_ori) }
9 |
10 | it 'can look up an AgencyStatus by ori with #find_by_ori' do
11 | expect(AgencyStatus.find_by_ori(ori)).to eq(status)
12 | expect(AgencyStatus.find_by_ori(other_ori)).to eq(other_status)
13 | end
14 |
15 | it 'can indicate agency submission and retrieve information about past submissions' do
16 | expect(status.complete_submission_years).to eq []
17 |
18 | status.mark_year_submitted!(2016)
19 | expect(status.complete_submission_years).to eq [2016]
20 | expect(status.last_submission_year).to eq 2016
21 |
22 | status.mark_year_submitted!(2012)
23 | expect(status.complete_submission_years).to eq [2012, 2016]
24 | expect(status.last_submission_year).to eq 2016
25 |
26 | status.mark_year_submitted!(2014)
27 | expect(status.complete_submission_years).to eq [2012, 2014, 2016]
28 | expect(status.last_submission_year).to eq 2016
29 |
30 | # Other agency should be unaffected
31 | expect(other_status.complete_submission_years).to eq []
32 | expect(other_status.last_submission_year).to eq(-1)
33 | end
34 |
35 | it 'correctly calculates the agency\'s last submission year' do
36 | expect(AgencyStatus.get_agency_last_submission_year(ori)).to eq(-1)
37 | status.mark_year_submitted!(2014)
38 | expect(AgencyStatus.get_agency_last_submission_year(ori)).to eq 2014
39 | status.mark_year_submitted!(2012)
40 | expect(AgencyStatus.get_agency_last_submission_year(ori)).to eq 2014
41 | status.mark_year_submitted!(2016)
42 | expect(AgencyStatus.get_agency_last_submission_year(ori)).to eq 2016
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/app/assets/javascripts/controls.js:
--------------------------------------------------------------------------------
1 | function positionControls() {
2 | // Set margin-top of #controls based on what is above them.
3 |
4 | $('#controls').css('margin-top', '0px');
5 |
6 | if ($('#breadcrumbs').is(':visible')) {
7 | $('#controls').css('margin-top', parseInt($('#controls').css('margin-top')) + $('#breadcrumbs').outerHeight() + 'px');
8 | }
9 |
10 | if ($('#demoWarning').is(':visible')) {
11 | $('#controls').css('margin-top', parseInt($('#controls').css('margin-top')) + $('#demoWarning').outerHeight() + 'px');
12 | }
13 |
14 | // Enable StickyKit.
15 |
16 | if (navigator.userAgent.indexOf("PhantomJS") == -1) {
17 | // The StickyKit plugin causes bizarre MouseEventFailed errors in our Poltergeist tests,
18 | // so we just disable it if we detect that we're inside PhantomJS.
19 | var offset = parseInt($('#controls').css('margin-top'));
20 | $('#controls').stick_in_parent({bottoming: false, offset_top: -offset});
21 | }
22 | }
23 |
24 | function setZoomLevel(zoomLevel) {
25 | // See http://stackoverflow.com/a/9441618
26 | sessionStorage['zoomLevel'] = zoomLevel;
27 | $('body').css('zoom', (zoomLevel * 100) + "%");
28 | $('body').css('-moz-transform', "scale(" + zoomLevel + ")");
29 | $('body').css('-moz-transform-origin', "0 0");
30 | }
31 |
32 | function adjustZoom(delta) {
33 | var currentZoom = parseFloat(sessionStorage['zoomLevel']) || 1.0;
34 | setZoomLevel(currentZoom + delta);
35 | }
36 |
37 | $(function() {
38 | positionControls();
39 | $(window).resize(positionControls);
40 |
41 | if (sessionStorage['zoomLevel']) {
42 | setZoomLevel(parseFloat(sessionStorage['zoomLevel']));
43 | }
44 |
45 | $("#zoomIn").click(function () { adjustZoom(0.1); });
46 | $("#zoomReset").click(function () { setZoomLevel(1.0); });
47 | $("#zoomOut").click(function () { adjustZoom(-0.1); });
48 | $("#print").click(function () { window.print(); });
49 | });
50 |
--------------------------------------------------------------------------------
/db/migrations/20161026_1154_add_secondary_index_to_users.rb:
--------------------------------------------------------------------------------
1 | # Migration to add a secondary index to the Incidents table.
2 | class AddSecondaryIndexToUsers < DynamoDB::Migration::Unit
3 | def update
4 | logger = Logger.new(STDOUT)
5 |
6 | logger.info "Running migration #{self.class.name}:"
7 |
8 | table_name = "#{Dynamoid.config.namespace}_users"
9 | index_name = User.indexes['ori'].name
10 |
11 | existing_indexes = client.describe_table(table_name: table_name).table.global_secondary_indexes
12 | existing_index_names = existing_indexes ? existing_indexes.flat_map(&:index_name) : []
13 |
14 | if existing_index_names.include? index_name
15 | logger.info "Index #{index_name} already exists!"
16 | else
17 | logger.info "Creating index #{index_name} ..."
18 | client.update_table(
19 | table_name: table_name,
20 | attribute_definitions: [
21 | {
22 | attribute_name: "id",
23 | attribute_type: "S"
24 | },
25 | {
26 | attribute_name: "ori",
27 | attribute_type: "S"
28 | }
29 | ],
30 | global_secondary_index_updates: [
31 | {
32 | create: {
33 | index_name: index_name,
34 | key_schema: [
35 | {
36 | attribute_name: "ori",
37 | key_type: "HASH"
38 | }
39 | ],
40 | projection: {
41 | projection_type: "ALL"
42 | },
43 | provisioned_throughput: {
44 | read_capacity_units: Dynamoid::Config.read_capacity,
45 | write_capacity_units: Dynamoid::Config.write_capacity
46 | }
47 | }
48 | }
49 | ]
50 | )
51 | end
52 |
53 | logger.info 'Done!'
54 | logger.info '================================'
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/app/validators/incident_date_validator.rb:
--------------------------------------------------------------------------------
1 | # Validates that a date is in the appropriate format and in the past.
2 | class IncidentDateValidator < ActiveModel::EachValidator
3 | DATE_REGEX = %r{((0[1-9])|(1[0-2]))\/((0[1-9])|([12][0-9])|(3[01]))\/(\d{4})}i
4 |
5 | def validate_each(record, attribute, value)
6 | valid_years = GlobalState.valid_new_incident_years
7 | valid_years_str = valid_years.to_a.join(' or ')
8 |
9 | begin
10 | raise ArgumentError unless value =~ DATE_REGEX
11 | date = Date.strptime(value, '%m/%d/%Y')
12 |
13 | if valid_years.exclude? date.year
14 | record.errors[attribute] << "invalid year #{date.year} - you can only create incidents for #{valid_years_str}"
15 | elsif date > Time.zone.today
16 | record.errors[attribute] << "future date #{value} not allowed (today is #{Time.zone.today.strftime('%m/%d/%Y')})"
17 | else
18 | validate_year_not_submitted_yet(record, attribute, date.year)
19 | end
20 | rescue ArgumentError
21 | record.errors[attribute] << "must be a valid date in MM/DD/YYYY format (you gave #{value})"
22 | end
23 | end
24 |
25 | private
26 |
27 | # Ensure that the incident is not being created for a year that has already
28 | # been submitted for by the given ORI.
29 | def validate_year_not_submitted_yet(record, attribute, year)
30 | incident = record.incident.target
31 |
32 | # Only perform this check if the GeneralInfo model has an associated incident
33 | # (this may not be the case, i.e. when the incident is being created via bulk upload).
34 | if incident.present?
35 | submitted_years = AgencyStatus.find_or_create_by_ori(incident.ori)
36 | .complete_submission_years
37 |
38 | if submitted_years.include? year
39 | record.errors[attribute] << "ORI #{ori} has already submitted for this year"
40 | end
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/app/assets/javascripts/incidents.js:
--------------------------------------------------------------------------------
1 | // Note: To keep this file as small and straightforward as possible, put
2 | // reusable helper methods in util.js and moderately-sized self-contained
3 | // components into incidents.*.js files (e.g. incidents.address.js).
4 | $(function(){
5 | $("#dashboardSideNav li").on('click', function(e) {
6 | window.location.href = $(this).find("a").attr('href');
7 | });
8 |
9 | $('#screener_ori').change(function () {
10 | if ($('#screener_ori').val() == $('#screener_ori option:first').val()) {
11 | $('#filling-out-for').hide();
12 | } else {
13 | $('#filling-out-for-ori').text($('#screener_ori').val());
14 | $('#filling-out-for').show();
15 | }
16 | }).change();
17 |
18 | // Add datetime formatter for event date.
19 | $('#general_info_incident_date_str').formatter({
20 | 'pattern': '{{99}}/{{99}}/{{9999}}',
21 | 'persistent': true
22 | });
23 |
24 | $('#showHideAuditLog').toggleLinkFor($('#auditLog'));
25 |
26 | $('#upload-control input[type=file]').change(function () {
27 | $('#upload-control').submit();
28 | });
29 |
30 | // Partial save link disables validation and triggers form submit.
31 | $('#save_and_return').click(function (e) {
32 | e.preventDefault();
33 | $('#validate_and_continue').val("false");
34 | $("[type=submit]").click();
35 | });
36 |
37 |
38 | // For the erratic behavior question, uncheck other options when
39 | // the 'None of these' choice is selected, and uncheck 'None of these'
40 | // when another choice is selected.
41 | $('input[name="involved_civilian[mental_status][]"]').on('change', function() {
42 | if ($(this).attr('value') == 'None') {
43 | $('input[name="involved_civilian[mental_status][]"]').filter(function() {
44 | return ($(this).attr('value') != 'None')
45 | }).attr('checked', false);
46 | } else {
47 | $('#involved_civilian_mental_status_none').attr('checked', false);
48 | }
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/app/assets/javascripts/incidents.charges.js:
--------------------------------------------------------------------------------
1 | $(function(){
2 | function formatCrime(crimeText) {
3 | // Parse (MISDEMEANOR) and (FELONY)
4 | var isMisdemeanor = crimeText.indexOf("(MISDEMEANOR)") > -1;
5 | var isFelony = crimeText.indexOf("(FELONY)") > -1;
6 | var type = isMisdemeanor ? "M" : (isFelony ? "F" : " ");
7 | crimeText = crimeText.replace(" (MISDEMEANOR)", "").replace(" (FELONY)", "");
8 |
9 | if (isNaN(crimeText.split(" ")[1][0])) {
10 | // first char of second group is NOT a number: e.g.
11 | // 148(A)(1) PC OBSTRUCT/ETC PUB OFCR/ETC (MISDEMEANOR)
12 | var num = crimeText.split(" ")[0];
13 | var code = crimeText.split(" ")[1];
14 | var text = crimeText.split(" ").slice(2).join(" ");
15 | } else {
16 | // first char of second group IS a number: e.g.
17 | // 18 3148(A) US VIOL CONDITION OF RELEASE (FELONY)
18 | var num = crimeText.split(" ").slice(0, 2).join(" ");
19 | var code = crimeText.split(" ")[2];
20 | var text = crimeText.split(" ").slice(3).join(" ");
21 | }
22 |
23 | return pad(num, 17) + " " + pad(code, 6) + " " + pad(text, 30) + " " + type;
24 | }
25 |
26 | // Add autocomplete for crime codes.
27 | $('#involved_civilian_highest_charge').autocomplete({
28 | source: $.map(window.CRIMES, function (c) { return {'value': c, 'label': formatCrime(c)}; })
29 | });
30 | // Add a CSS class to the autocomplete widget so that we can style it individually.
31 | $('#involved_civilian_highest_charge').autocomplete('widget').addClass('charge-dropdown');
32 |
33 | // Add autocomplete for crime qualifier codes.
34 | $('#involved_civilian_crime_qualifier').autocomplete({
35 | source: $.map(window.CRIME_QUALIFIERS, function (c) { return {'value': c, 'label': formatCrime(c)}; })
36 | });
37 | // Add a CSS class to the autocomplete widget so that we can style it individually.
38 | $('#involved_civilian_crime_qualifier').autocomplete('widget').addClass('charge-dropdown');
39 | });
40 |
--------------------------------------------------------------------------------
/db/migrations/20161026_1148_add_secondary_index_to_incidents.rb:
--------------------------------------------------------------------------------
1 | # Migration to add a secondary index to the Incidents table.
2 | class AddSecondaryIndexToIncidents < DynamoDB::Migration::Unit
3 | def update
4 | logger = Logger.new(STDOUT)
5 |
6 | logger.info "Running migration #{self.class.name}:"
7 |
8 | table_name = "#{Dynamoid.config.namespace}_incidents"
9 | index_name = Incident.indexes['incident_id_str'].name
10 |
11 | existing_indexes = client.describe_table(table_name: table_name).table.global_secondary_indexes
12 | existing_index_names = existing_indexes ? existing_indexes.flat_map(&:index_name) : []
13 |
14 | if existing_index_names.include? index_name
15 | logger.info "Index #{index_name} already exists!"
16 | else
17 | logger.info "Creating index #{index_name} ..."
18 | client.update_table(
19 | table_name: table_name,
20 | attribute_definitions: [
21 | {
22 | attribute_name: "id",
23 | attribute_type: "S"
24 | },
25 | {
26 | attribute_name: "incident_id_str",
27 | attribute_type: "S"
28 | }
29 | ],
30 | global_secondary_index_updates: [
31 | {
32 | create: {
33 | index_name: index_name,
34 | key_schema: [
35 | {
36 | attribute_name: "incident_id_str",
37 | key_type: "HASH"
38 | }
39 | ],
40 | projection: {
41 | projection_type: "INCLUDE",
42 | non_key_attributes: ["id"]
43 | },
44 | provisioned_throughput: {
45 | read_capacity_units: Dynamoid::Config.read_capacity,
46 | write_capacity_units: Dynamoid::Config.write_capacity
47 | }
48 | }
49 | }
50 | ]
51 | )
52 | end
53 |
54 | logger.info 'Done!'
55 | logger.info '================================'
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/siteminder.rb:
--------------------------------------------------------------------------------
1 | require 'base64'
2 | require 'openssl'
3 | require 'cgi'
4 |
5 | # This module allows the encryption/decryption of data in the same
6 | # fashion as DOJ's Siteminder auth server. Needed in a production environment
7 | # where authentication is done via a proxy Siteminder auth server, which
8 | # then passes an encrypted cookie to this app. This code lets us decrypt
9 | # the cookie so we can read the user's information.
10 | module Siteminder
11 | def self.run_algorithm(str, encrypt, key, init_v)
12 | des = OpenSSL::Cipher::Cipher.new('DES-EDE3-CBC')
13 | des.key = [key].pack('H*')
14 | des.iv = [init_v].pack('H*')
15 | if encrypt
16 | des.encrypt
17 | else
18 | des.decrypt
19 | end
20 | des.update(str) + des.final
21 | end
22 |
23 | def self.encrypt_cookie(str, key, init_v, url_escape = false)
24 | str = run_algorithm(str, true, key, init_v)
25 | str = Base64.strict_encode64(str)
26 | str = CGI.escape(str) if url_escape
27 | str
28 | end
29 |
30 | def self.decrypt_cookie(str, key, init_v, url_escape = false)
31 | # Cookies arrive Base64-encoded
32 | str = CGI.unescape(str) if url_escape
33 | str = Base64.strict_decode64(str)
34 | run_algorithm(str, false, key, init_v)
35 | end
36 |
37 | def self.encrypt_decrypt_cookie(str, key, init_v, url_escape = false)
38 | encrypted = encrypt_cookie(str, key, init_v, url_escape)
39 | decrypt_cookie(encrypted, key, init_v, url_escape)
40 | end
41 |
42 | def self.parse_cookie_str_to_hash(str)
43 | str.split(";")
44 | .select { |part| part.include? '=' }
45 | .map { |part| [part.split('=', 2)[0], part.split('=', 2)[1]] }
46 | .to_h
47 | end
48 |
49 | def self.extract_role(str)
50 | str.split(',')
51 | .find { |part| part.start_with? 'cn=' }
52 | .try { |role| role.split('=', 2)[1] }
53 | end
54 |
55 | def self.encode_role(role)
56 | "cn=#{role}"
57 | end
58 |
59 | def self.extract_ori(str)
60 | str.split('-').last.strip if str.present?
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/review.scss:
--------------------------------------------------------------------------------
1 | #incidentStep.review {
2 | .incident-status {
3 | display: none;
4 | }
5 |
6 | .table-hover tbody tr:hover td, .table-hover tbody tr:hover th {
7 | background-color: #C0E1F8;
8 | }
9 |
10 | .review-table {
11 | max-width: 760px;
12 | }
13 |
14 | th, td {
15 | height: 30px;
16 | padding: 0;
17 | padding-left: 5px;
18 | line-height: 30px;
19 | vertical-align: middle;
20 | }
21 |
22 | .review-table-key {
23 | width: 250px;
24 | padding-left: 30px;
25 | position: relative;
26 | vertical-align: top;
27 | }
28 |
29 | .review-table-value {
30 | color: #000;
31 | }
32 |
33 | h1 {
34 | display: inline-block;
35 | }
36 |
37 | .review-edit, .review-delete {
38 | float: right;
39 | text-decoration: none;
40 | display: inline-block;
41 | vertical-align: middle;
42 | height: 28px;
43 | line-height: 28px;
44 | margin-bottom: 7px;
45 | font-size: 14px;
46 | padding: 0 14px;
47 | border: none;
48 | border-radius: 3px;
49 | color: white;
50 | }
51 |
52 | .review-edit {
53 | background-color: $header-text-color;
54 | }
55 |
56 | .review-delete {
57 | margin-left: 7px; // Separate the two buttons
58 | background-color: $color-secondary-darkest-18f
59 | }
60 |
61 | .fbi-seal {
62 | position: absolute;
63 | padding: 0;
64 | margin: 0;
65 | left: 5px;
66 | top: 5px;
67 | width: 20px;
68 | height: 20px;
69 | background-color: Transparent;
70 | background: url(asset-path('fbi-bw-large.png'));
71 | background-repeat: no-repeat;
72 | background-size: 100%;
73 | outline: none;
74 | border: none;
75 | }
76 |
77 | .review-submit {
78 | margin-bottom: 0;
79 | }
80 |
81 | #auditLog {
82 | display: none;
83 | }
84 |
85 | #showHideAuditLog {
86 | margin-left: 1em;
87 | cursor: pointer;
88 | }
89 |
90 | #review-submit-explanation {
91 | margin-left: 20px;
92 | }
93 |
94 | #delete-incident-link {
95 | margin-top: 15px;
96 | }
97 | }
--------------------------------------------------------------------------------
/app/models/screener.rb:
--------------------------------------------------------------------------------
1 | # A Screener form within an incident report.
2 | class Screener
3 | include Dynamoid::Document
4 | include AllowsPartialSave
5 | include Serializable
6 |
7 | belongs_to :incident
8 |
9 | field :multiple_agencies, :boolean
10 | field :shots_fired, :boolean
11 | field :officer_used_force, :boolean
12 | field :civilian_seriously_injured, :boolean
13 | field :civilian_used_force, :boolean
14 | field :officer_seriously_injured, :boolean
15 |
16 | validates :multiple_agencies, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD }
17 | validates :shots_fired, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD }
18 |
19 | with_options unless: :shots_fired do
20 | validates :civilian_used_force, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD }
21 |
22 | validates :officer_used_force, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD }
23 |
24 | validates :officer_seriously_injured, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD },
25 | if: :civilian_used_force
26 |
27 | validates :civilian_seriously_injured, inclusion: { in: [true, false], message: Constants::ERROR_BLANK_FIELD },
28 | if: :officer_used_force
29 | end
30 |
31 | before_validation :maybe_clear_additional_questions
32 |
33 | def maybe_clear_additional_questions
34 | self[:officer_used_force] = nil if shots_fired
35 | self[:civilian_used_force] = nil if shots_fired
36 | self[:civilian_seriously_injured] = nil unless officer_used_force
37 | self[:officer_seriously_injured] = nil unless civilian_used_force
38 | end
39 |
40 | def civilian_injured_by_force?
41 | officer_used_force && civilian_seriously_injured
42 | end
43 |
44 | def officer_injured_by_force?
45 | civilian_used_force && officer_seriously_injured
46 | end
47 |
48 | def forms_necessary?
49 | [shots_fired, officer_injured_by_force?, civilian_injured_by_force?].any?
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | Rails.application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
5 |
6 | # Render exceptions instead of raising them
7 | config.consider_all_requests_local = true
8 |
9 | # In the development environment your application's code is reloaded on
10 | # every request. This slows down response time but is perfect for development
11 | # since you don't have to restart the web server when you make code changes.
12 | config.cache_classes = false
13 |
14 | # Do not eager load code on boot.
15 | config.eager_load = false
16 |
17 | # Disable caching.
18 | config.action_controller.perform_caching = false
19 |
20 | # For debugging, save mail to files.
21 | config.action_mailer.preview_path = "#{Rails.root}/spec/mailers/previews"
22 | config.action_mailer.perform_deliveries = true
23 | config.action_mailer.raise_delivery_errors = true
24 | config.action_mailer.delivery_method = :smtp
25 | # Use the mailcatcher gem to run a simple local smtp server
26 | config.action_mailer.smtp_settings = { address: 'mailcatcher', port: 1025 }
27 |
28 | # Print deprecation notices to the Rails logger.
29 | config.active_support.deprecation = :log
30 |
31 | # Debug mode disables concatenation and preprocessing of assets.
32 | # This option may cause significant delays in view rendering with a large
33 | # number of complex assets.
34 | config.assets.debug = false
35 |
36 | # Asset digests allow you to set far-future HTTP expiration dates on all assets,
37 | # yet still be able to expire them through the digest params.
38 | config.assets.digest = true
39 |
40 | # Adds additional error checking when serving assets at runtime.
41 | # Checks for improperly declared sprockets dependencies.
42 | # Raises helpful error messages.
43 | config.assets.raise_runtime_errors = true
44 |
45 | # Raises error for missing translations
46 | # config.action_view.raise_on_missing_translations = true
47 | end
48 |
--------------------------------------------------------------------------------
/app/controllers/doj_controller.rb:
--------------------------------------------------------------------------------
1 | # Controller for DOJ dashboard.
2 | class DojController < ApplicationController
3 | before_action :block_non_doj_users
4 |
5 | def overview
6 | end
7 |
8 | def window
9 | end
10 |
11 | def window_toggle
12 | if params[:submission_year] == Time.last_year.to_s && params[:submission_open] == GlobalState.submission_open?.to_s
13 | GlobalState.toggle_submission_window!
14 | else
15 | logger.error "Invalid submission window toggle - hidden form fields do not match up with current submission state"
16 | logger.error "Expected (#{Time.last_year}, #{GlobalState.submission_open?}) - " \
17 | "got (#{params[:submission_year]}, #{params[:submission_open]})"
18 | end
19 | redirect_to doj_window_path
20 | end
21 |
22 | def whosubmitted
23 | @agency_statuses = AgencyStatus.all
24 | @agencies = Constants::DEPARTMENT_BY_ORI.map { |ori, dept| get_agency_status_hash(ori, dept) }
25 | .sort_by { |a| a[:ori] }
26 | end
27 |
28 | def incidents
29 | return unless params[:ori].present?
30 |
31 | @ori = params[:ori].strip.split[-1]
32 |
33 | if Constants::DEPARTMENT_BY_ORI.include? @ori
34 | @dept = Constants::DEPARTMENT_BY_ORI[@ori].split.map(&:capitalize).join(' ')
35 | all_oris = [@ori] + Constants::CONTRACTING_ORIS[@ori].to_a
36 | @incidents = Incident.all.select { |i| i.submitted? && all_oris.include?(i.ori) }
37 | @agency_last_submission_year = AgencyStatus.get_agency_last_submission_year(@ori)
38 | else
39 | @bad_ori = true
40 | end
41 | end
42 |
43 | def analysis
44 | end
45 |
46 | private
47 |
48 | def block_non_doj_users
49 | raise ActionController::BadRequest.new unless @current_user.doj?
50 | end
51 |
52 | def get_agency_status_hash(ori, dept)
53 | status = @agency_statuses.find { |s| s.ori == ori }
54 | {
55 | ori: ori,
56 | department: dept,
57 | submitted: status && status.last_submission_year && status.last_submission_year >= Time.last_year
58 | }
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/app/models/constants/general_info.rb:
--------------------------------------------------------------------------------
1 | module Constants
2 | # Constants related to the GeneralInfo model.
3 | module GeneralInfo
4 | DISPLAY_FIELDS = [
5 | :incident_id, :display_agency, :display_datetime, :address, :city, :state, :zip_code, :county,
6 | :multiple_locations, :on_k12_campus, :arrest_made, :crime_report_filed, :contact_reason,
7 | :in_custody_reason, :num_involved_civilians, :num_involved_officers
8 | ].freeze
9 |
10 | FBI_FIELDS = [
11 | :incident_date_str, :incident_time_str, :address, :city,
12 | :state, :zip_code, :county, :contact_reason
13 | ].freeze
14 |
15 | CUSTOM_LABELS_FOR_REVIEW = {
16 | incident_id: "Incident ID",
17 | display_agency: "Agency involved",
18 | display_datetime: "Incident time",
19 | num_involved_civilians: "Number of civilians involved",
20 | num_involved_officers: "Number of officers involved",
21 | multiple_locations: "Multiple incident locations?",
22 | on_k12_campus: "On a K-12 campus?",
23 | arrest_made: "Arrest made?",
24 | crime_report_filed: "Crime report filed?"
25 | }.freeze
26 |
27 | CONTACT_REASON_IN_CUSTODY = 'In Custody Event'.freeze
28 |
29 | CONTACT_REASONS = [
30 | 'Call for Service',
31 | CONTACT_REASON_IN_CUSTODY,
32 | 'Consensual Encounter / Public Contact / Flag Down',
33 | 'Vehicle / Bike / Pedestrian Stop',
34 | 'Pre-Planned Activity (arrest/search warrant, parole/probation search)',
35 | 'Welfare Check',
36 | 'Crime in Progress / Investigating Suspicious Persons or Circumstances',
37 | 'Civil Assembly',
38 | 'Civil Disorder',
39 | 'Ambush - No warning'
40 | ].freeze
41 |
42 | IN_CUSTODY_REASONS = [
43 | 'In Transit', 'Awaiting Booking', 'Booked - No Charges Filed',
44 | 'Booked - Awaiting Trial', 'Out to Court', 'Sentenced', 'Other'
45 | ].freeze
46 |
47 | STATES = %w(AL AK AZ AR CA CZ CO CT DE DC FL GA GU HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO
48 | MT NE NV NH NJ NM NY NC ND OH OK OR PA PR RI SC SD TN TX UT VT VI VA WA WV WI WY).freeze
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/tasks/siteminder_cookie_handling.rake:
--------------------------------------------------------------------------------
1 | # These rake tasks let you encrypt/decrypt Siteminder cookies.
2 | # Useful for setting cookies you want to use in dev/testing, and
3 | # for debugging.
4 | require 'cgi'
5 | require "#{Rails.root}/lib/siteminder"
6 |
7 | # Reads an environment variable file and imports settings to ENV
8 | def import_env_variables(env_file)
9 | if env_file.present?
10 | File.open(env_file, "r") do |f|
11 | f.each_line do |line|
12 | parts = line.split("=", 2)
13 | parts << ""
14 | ENV[parts[0]] = parts[1]
15 | end
16 | end
17 | else
18 | puts "env_file is nil -- skipping importing env variables"
19 | end
20 | end
21 |
22 | namespace :siteminder do
23 | desc "Encrypt an SMOFC cookie"
24 | task :encrypt_cookie, :cookie_str, :env_file do |_, args|
25 | cookie_str = args[:cookie_str]
26 | puts "Encrypting SMOFC cookie '#{cookie_str}' for siteminder"
27 |
28 | env_file = args.env_file
29 | import_env_variables(env_file)
30 |
31 | hash = Siteminder.parse_cookie_str_to_hash(cookie_str)
32 | puts "Parsed cookie as: " + hash.to_s
33 |
34 | encrypted = Siteminder.encrypt_cookie(cookie_str,
35 | ENV["SITEMINDER_DECRYPT_KEY"],
36 | ENV["SITEMINDER_DECRYPT_INIT_V"])
37 | puts "Encrypted + URL escaped (to paste into your browser e.g. EditThisCookie):"
38 | puts "*" * 40
39 | puts CGI.escape(encrypted)
40 | puts "*" * 40
41 | puts "(Remember, the cookie key is SMOFC)"
42 | end
43 |
44 | desc "Decrypt an SMOFC cookie"
45 | task :decrypt_cookie, :cookie_str, :env_file do |_, args|
46 | cookie_str = args[:cookie_str]
47 | puts "Decrypting SMOFC cookie '#{cookie_str}'"
48 | cookie_str = CGI.unescape(cookie_str)
49 |
50 | env_file = args.env_file
51 | import_env_variables(env_file)
52 |
53 | decrypted = Siteminder.decrypt_cookie(cookie_str,
54 | ENV["SITEMINDER_DECRYPT_KEY"],
55 | ENV["SITEMINDER_DECRYPT_INIT_V"])
56 | puts "Decrypted cookie: " + decrypted
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/app/views/application/siteminder_auth_fail.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Authentication fail (401)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
Sorry, we weren't able to log you in to URSUS.
61 |
Try <%= link_to "logging out", Rails.configuration.x.login.siteminder_url_logout %> from Siteminder, then logging back in.
62 |
If that doesn't help, send us an email to <%= link_to @concise_address, @mailto_url %> with the error text below, and we will resolve your issue as quickly as we can.
63 |
64 |
65 | Errors (<%= @siteminder_errors.length %>)
66 | <% @siteminder_errors.each do |e| %>
67 | <%= e %>
68 | <% end %>
69 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/app/assets/javascripts/lib/jquery.flexverticalcenter.js:
--------------------------------------------------------------------------------
1 | /*global jQuery */
2 | /*!
3 | * FlexVerticalCenter.js 1.0
4 | *
5 | * Copyright 2011, Paul Sprangers http://paulsprangers.com
6 | * Released under the WTFPL license
7 | * http://sam.zoy.org/wtfpl/
8 | *
9 | * Date: Fri Oct 28 19:12:00 2011 +0100
10 | */
11 | (function( $ ){
12 |
13 | $.fn.flexVerticalCenter = function( options ) {
14 | var settings = $.extend({
15 | cssAttribute: 'margin-top', // the attribute to apply the calculated value to
16 | verticalOffset: 0, // the number of pixels to offset the vertical alignment by
17 | parentSelector: null, // a selector representing the parent to vertically center this element within
18 | debounceTimeout: 25, // a default debounce timeout in milliseconds
19 | deferTilWindowLoad: false // if true, nothing will take effect until the $(window).load event
20 | }, options || {});
21 |
22 | return this.each(function(){
23 | var $this = $(this); // store the object
24 | var debounce;
25 |
26 | // recalculate the distance to the top of the element to keep it centered
27 | var resizer = function () {
28 |
29 | var parentHeight = (settings.parentSelector && $this.parents(settings.parentSelector).length) ?
30 | $this.parents(settings.parentSelector).first().height() : $this.parent().height();
31 |
32 | $this.css(
33 | settings.cssAttribute, ( ( ( parentHeight - $this.height() ) / 2 ) + parseInt(settings.verticalOffset) )
34 | );
35 | if (settings.complete !== undefined) {
36 | settings.complete();
37 | }
38 | };
39 |
40 | // Call on resize. Opera debounces their resize by default.
41 | $(window).resize(function () {
42 | clearTimeout(debounce);
43 | debounce = setTimeout(resizer, settings.debounceTimeout);
44 | });
45 |
46 | if (!settings.deferTilWindowLoad) {
47 | // call it once, immediately.
48 | resizer();
49 | }
50 |
51 | // Call again to set after window (frames, images, etc) loads.
52 | $(window).load(function () {
53 | resizer();
54 | });
55 |
56 | });
57 |
58 | };
59 |
60 | })( jQuery );
--------------------------------------------------------------------------------
/app/views/feedback/new.html.erb:
--------------------------------------------------------------------------------
1 | <%= title "Help" %>
2 |
3 |
58 |
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | machine:
2 | pre:
3 | - curl -sSL https://s3.amazonaws.com/circle-downloads/install-circleci-docker.sh | bash -s -- 1.10.0
4 | - sudo sh -c "curl -L https://github.com/docker/compose/releases/download/1.8.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose"
5 | - sudo chmod +x /usr/local/bin/docker-compose
6 | services:
7 | - docker
8 |
9 | dependencies:
10 | pre:
11 | - gem install rubocop -v 0.43
12 | - gem install rubocop-rspec
13 |
14 | override:
15 | # Linting happens in the dependencies step in order to fail fast.
16 | - rubocop -D
17 |
18 | # If this branch is not relevant, should_run_ci.sh will create a skip-tests file.
19 | - ./should_run_ci.sh
20 |
21 | # We set the environment variables via the CirclCI web dashboard, but
22 | # we need to pass them via local.env to the Docker containers.
23 | - touch local.env
24 | - echo "SITEMINDER_DECRYPT_KEY=$SITEMINDER_DECRYPT_KEY" >> local.env
25 | - echo "SITEMINDER_DECRYPT_INIT_V=$SITEMINDER_DECRYPT_INIT_V" >> local.env
26 | - echo "MAIL_FROM=test_from@example.com" >> local.env
27 | - echo "FEEDBACK_MAIL_TO=test_feedback_to@example.com" >> local.env
28 | - echo "BRANDING=ursus" >> local.env
29 | - echo "INCIDENT_ID_PREFIX=TEST" >> local.env
30 |
31 | # Set up AWS for deployment
32 | - mkdir ~/.aws
33 | - touch ~/.aws/credentials
34 | - echo "[default]" >> ~/.aws/credentials
35 | - echo "aws_access_key_id = $AWS_ACCESS_KEY" >> ~/.aws/credentials
36 | - echo "aws_secret_access_key = $AWS_SECRET_KEY" >> ~/.aws/credentials
37 | - touch ~/.aws/config
38 | - echo "[default]" >> ~/.aws/config
39 | - echo "region=$AWS_REGION" >> ~/.aws/config
40 |
41 | # Build Docker images if and only if we're going to run tests.
42 | - test -e skip-tests || docker-compose build test
43 | - test -e skip-tests || docker-compose build test-devise-only
44 |
45 | test:
46 | override:
47 | - test -e skip-tests || docker-compose run test
48 | - test -e skip-tests || docker-compose run test-devise-only
49 |
50 | deployment:
51 | master:
52 | branch: master
53 | commands:
54 | - $(aws ecr get-login --region us-west-1)
55 | - GIT_SHA1=$CIRCLE_SHA1 docker-compose build web
56 | - docker push ${DOCKER_REGISTRY_PATH}bridge-uof:latest
57 |
--------------------------------------------------------------------------------
/spec/requests/devise_signup_spec.rb:
--------------------------------------------------------------------------------
1 | require 'rails_helper'
2 |
3 | describe '[Devise-related tests]', type: :request do
4 | it 'allows Devise login via test helper methods' do
5 | login_with_devise
6 | logout
7 | end
8 |
9 | it 'sign-up page is linked from sign-in page' do
10 | visit new_user_session_path
11 | click_link 'Not registered'
12 | expect(current_path).to eq(new_user_registration_path)
13 | end
14 |
15 | def visit_registration_and_fill_form
16 | visit new_user_registration_path
17 | fill_in 'First Name', with: 'Elvis'
18 | fill_in 'Last Name', with: 'Presley'
19 | fill_in 'Email', with: 'elvis@example.com'
20 | fill_in 'ORI', with: 'other-ORI'
21 | fill_in 'Department', with: 'other-dept'
22 | fill_in 'Role', with: 'other-role'
23 | fill_in 'User ID (from ECARS)', with: 'other-user-id'
24 | fill_in 'user_password', with: 'p@ssw0rd'
25 | fill_in 'Confirm password', with: 'p@ssw0rd'
26 | end
27 |
28 | it 'allows sign up of a new user' do
29 | visit_registration_and_fill_form
30 | find('input[type=submit]').click
31 | expect(current_path).to eq(welcome_path)
32 | end
33 |
34 | describe '[with an existing user]' do
35 | let!(:dummy_user) { create :dummy_user }
36 |
37 | it 'allows sign up of an existing user with a new userid' do
38 | visit new_user_registration_path
39 | fill_in 'First Name', with: dummy_user.first_name
40 | fill_in 'Last Name', with: dummy_user.last_name
41 | fill_in 'Email', with: dummy_user.email
42 | fill_in 'ORI', with: dummy_user.ori
43 | fill_in 'Department', with: dummy_user.department
44 | fill_in 'Role', with: dummy_user.role
45 | fill_in 'User ID (from ECARS)', with: (dummy_user.user_id + "2")
46 | fill_in 'user_password', with: dummy_user.password
47 | fill_in 'Confirm password', with: dummy_user.password
48 | find('input[type=submit]').click
49 | expect(current_path).to eq(welcome_path)
50 | end
51 |
52 | it 'forbids sign up of an existing user with the same userid' do
53 | visit_registration_and_fill_form
54 | fill_in 'User ID (from ECARS)', with: dummy_user.user_id
55 | find('input[type=submit]').click
56 | expect(current_path).not_to eq(welcome_path)
57 | expect(page).to have_content('User is already taken')
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/app/models/constants/involved_person.rb:
--------------------------------------------------------------------------------
1 | module Constants
2 | # Constants related to involved person models.
3 | module InvolvedPerson
4 | OTHER = 'Other'.freeze
5 |
6 | GENDERS = %w(Female Male Transgender).freeze
7 |
8 | NON_ASIAN_RACES = ['American Indian', 'Asian Indian', 'Black', 'Hispanic', 'White'].freeze
9 |
10 | ASIAN_RACE_STR = 'Asian / Pacific Islander'.freeze
11 |
12 | ASIAN_RACES = [
13 | 'Cambodian', 'Chinese', 'Filipino', 'Guamanian',
14 | 'Hawaiian', 'Japanese', 'Korean', 'Laotian', 'Samoan', 'Vietnamese',
15 | 'Other Asian', 'Other Pacific Islander'
16 | ].freeze
17 |
18 | RACES = (NON_ASIAN_RACES + [ASIAN_RACE_STR, OTHER]).freeze
19 |
20 | MULTIRACIAL = 'Multiracial**'.freeze
21 |
22 | RECEIVED_FORCE_LOCATIONS = [
23 | '(Not applicable)', 'Head', 'Neck/throat', 'Front upper torso/chest',
24 | 'Rear upper torso/back', 'Front lower torso/abdomen', 'Rear lower torso/back',
25 | 'Front below waist/groin area', 'Rear below waist/buttocks',
26 | 'Arms/hands ', 'Front legs/feet', 'Rear legs'
27 | ].freeze
28 |
29 | INJURY_LEVELS = ['Injury', 'Serious bodily injury', 'Death'].freeze
30 |
31 | INJURY_TYPES = [
32 | 'Unconscious', 'Contusion', 'Concussion', 'Bone fracture',
33 | 'Internal injury', 'Abrasion/Laceration', 'Obvious disfigurement',
34 | 'Gunshot wound', 'Stabbing wound'
35 | ].freeze
36 |
37 | MEDICAL_AID = [
38 | 'No medical assistance or refused assistance',
39 | 'Medical assistance - treated on scene',
40 | 'Medical assistance - treated at facility and released',
41 | 'Admitted to hospital - precautionary measure only',
42 | 'Admitted to hospital - critical injuries',
43 | 'Admitted to hospital - other circumstance'
44 | ].freeze
45 |
46 | DISPLAY_FIELDS = [
47 | :received_force, :type_of_force_used, :received_force_location, :gender, :age, :display_race,
48 | :injured, :injury_level, :medical_aid, :injury_type, :injury_from_preexisting_condition
49 | ].freeze
50 |
51 | FBI_FIELDS = [:received_force, :type_of_force_used, :gender, :age, :display_race, :injury_type].freeze
52 |
53 | CUSTOM_LABELS_FOR_REVIEW = {
54 | display_race: "Race",
55 | injured: "Injured?",
56 | injury_from_preexisting_condition: "Injury from a pre-existing condition?"
57 | }.freeze
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | Rails.application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | config.after_initialize do
5 | # Set the local time for all tests.
6 | # Use a far future date instead of a current-ish date so that
7 | # we don't hit any weird edge cases where tests pass because of when
8 | # they happened to be run.
9 | Timecop.travel(Time.zone.local(2040, 12, 31, 16, 30, 0))
10 | end
11 |
12 | # Ensure logger.xxxx prints to stdout, so we can debug
13 | config.logger = Logger.new(STDOUT)
14 | config.log_level = :ERROR
15 |
16 | # The test environment is used exclusively to run your application's
17 | # test suite. You never need to work with it otherwise. Remember that
18 | # your test database is "scratch space" for the test suite and is wiped
19 | # and recreated between test runs. Don't rely on the data there!
20 | config.cache_classes = true
21 |
22 | # Do not eager load code on boot. This avoids loading your whole application
23 | # just for the purpose of running a single test. If you are using a tool that
24 | # preloads Rails for running tests, you may have to set it to true.
25 | config.eager_load = false
26 |
27 | # Configure static file server for tests with Cache-Control for performance.
28 | config.serve_static_files = true
29 | config.static_cache_control = 'public, max-age=3600'
30 |
31 | # Show full error reports and disable caching.
32 | config.consider_all_requests_local = true
33 | config.action_controller.perform_caching = false
34 |
35 | # Raise exceptions instead of rendering exception templates.
36 | config.action_dispatch.show_exceptions = false
37 |
38 | # Disable request forgery protection in test environment.
39 | config.action_controller.allow_forgery_protection = false
40 |
41 | # Tell Action Mailer not to deliver emails to the real world.
42 | # The :test delivery method accumulates sent emails in the
43 | # ActionMailer::Base.deliveries array.
44 | config.action_mailer.delivery_method = :test
45 |
46 | # Randomize the order test cases are executed.
47 | config.active_support.test_order = :random
48 |
49 | # Print deprecation notices to the stderr.
50 | config.active_support.deprecation = :stderr
51 |
52 | # Raises error for missing translations
53 | # config.action_view.raise_on_missing_translations = true
54 | end
55 |
--------------------------------------------------------------------------------
/app/services/incident_stats_service.rb:
--------------------------------------------------------------------------------
1 | # IncidentStatsService.get_stats_for_incident handles per-incident statistics,
2 | # returning a hash of different stats.
3 | class IncidentStatsService
4 | def self.get_stats_for_incident(incident, opts)
5 | stats = {
6 | "Year" => incident.year,
7 | "Month" => incident.month,
8 |
9 | "Num Civilians" => incident.general_info.num_involved_civilians,
10 | "Num Officers" => incident.general_info.num_involved_officers,
11 |
12 | "Civilian Race" => array_to_str(incident.involved_civilians.map { |c| race_str(c) }),
13 | "Officer Race" => array_to_str(incident.involved_officers.map { |o| race_str(o) }),
14 |
15 | "Civilian Armed?" => array_to_str(incident.involved_civilians.map { |c| confirmed_armed_str(c) }),
16 | "Civilian Weapon" => array_to_str(incident.involved_civilians.map { |c| confirmed_weapon_str(c) }),
17 | "Officer Force Used" => array_to_str(incident.involved_civilians.map { |c| force_received_str(c) })
18 | }
19 |
20 | stats.except!("Year") if opts[:exclude_year]
21 |
22 | stats
23 | end
24 |
25 | # "Private methods"
26 |
27 | def self.array_to_str(array)
28 | if array.blank?
29 | Constants::NONE
30 | elsif array.to_set.count > 1
31 | Constants::VARIOUS
32 | else
33 | array.first
34 | end
35 | end
36 |
37 | def self.race_str(person)
38 | if person.race.blank?
39 | Constants::UNCONFIRMED_FLED
40 | elsif person.race.count > 1
41 | InvolvedPerson::MULTIRACIAL
42 | else
43 | person.race.first
44 | end
45 | end
46 |
47 | def self.confirmed_armed_str(civilian)
48 | if civilian.fled?
49 | Constants::UNCONFIRMED_FLED
50 | else
51 | civilian.confirmed_armed ? "Yes" : "No"
52 | end
53 | end
54 |
55 | def self.confirmed_weapon_str(civilian)
56 | if civilian.fled?
57 | Constants::UNCONFIRMED_FLED
58 | elsif civilian.confirmed_armed_weapon.blank?
59 | Constants::NONE
60 | elsif civilian.confirmed_armed_weapon.count > 1
61 | Constants::MULTIPLE
62 | else
63 | civilian.confirmed_armed_weapon.first
64 | end
65 | end
66 |
67 | def self.force_received_str(civilian)
68 | if civilian.received_force_type.blank?
69 | Constants::NONE
70 | elsif civilian.received_force_type.count > 1
71 | Constants::MULTIPLE
72 | else
73 | civilian.received_force_type.first
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------