From b64e3dafe3269ea6431adcd3cda97e980643c54d Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sat, 19 Jun 2021 22:10:57 -0700 Subject: [PATCH 001/137] Swap the fields that are shown by default in browse view --- OpenOversight/app/templates/list_officer.html | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/OpenOversight/app/templates/list_officer.html b/OpenOversight/app/templates/list_officer.html index 5d11e1a28..28450c105 100644 --- a/OpenOversight/app/templates/list_officer.html +++ b/OpenOversight/app/templates/list_officer.html @@ -13,7 +13,7 @@

Last name

-
+
@@ -25,7 +25,7 @@

Last name

Badge

-
+
@@ -49,7 +49,7 @@

Unique ID

Race

-
+
{% for choice in choices['race'] %} @@ -65,7 +65,7 @@

Race

Gender

-
+
{% for choice in choices['gender'] %} @@ -81,7 +81,7 @@

Gender

Rank

-
+
{% for choice in choices['rank'] %} @@ -97,7 +97,7 @@

Rank

Unit

-
+
- - - - - - - {% endif %} + {% include "partials/officer_assignment_history.html" %}
{# end col #} - {% if officer.salaries or current_user.is_administrator or - (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} -
-

Salary

- {% if officer.salaries %} - - - - - - - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} - - {% endif %} - - - {% for salary in officer.salaries %} - - - {% if salary.overtime_pay %} - {% if salary.overtime_pay > 0 %} - - {% else %} - - {% endif %} - - {% else %} - - - {% endif %} - - - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} - - {% endif %} - - {% endfor %} - -
Annual SalaryOvertimeTotal PayYearEdit
{{ '${:,.2f}'.format(salary.salary) }}{{ '${:,.2f}'.format(salary.overtime_pay) }}{{ '${:,.2f}'.format(salary.salary + salary.overtime_pay) }}{% if salary.is_fiscal_year: %}FY {% endif %}{{ salary.year }} - - Edit - - -
- {% endif %} - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} - New Salary - {% endif %} -
{# end col #} + {% if officer.salaries or is_admin_or_coordinator %} +
+ {% include "partials/officer_salary.html" %} +
{# end col #} {% endif %} - {% if officer.incidents or current_user.is_administrator or - (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} -
-

Incidents

- {% if officer.incidents %} - - - {% for incident in officer.incidents %} - {% if not loop.first %} - - {% endif %} - - - - {% include 'partials/incident_fields.html' %} - {% endfor %} -
 
-

- - Incident - {% if incident.report_number %} - {{ incident.report_number }} - {% else %} - {{ incident.id}} - {% endif %} - - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} - - - - {% endif %} -

-
- {% endif %} - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} - New Incident - {% endif %} -
{# end col #} + {% if officer.incidents or is_admin_or_coordinator %} +
+ {% include "partials/officer_incidents.html" %} +
{# end col #} {% endif %} - {% if officer.descriptions or current_user.is_administrator or - (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} -
-

Descriptions

- {% if officer.descriptions %} -
    - {% for description in officer.descriptions %} -
  • - {{ description.date_updated.strftime('%b %d, %Y')}}
    - {{ description.text_contents | markdown }} - {{ description.creator.username }} - {% if description.creator_id == current_user.get_id() or current_user.is_administrator %} - - Edit - - - - Delete - - - {% endif %} -
  • - {% endfor %} -
- {% endif %} - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} - New description - {% endif %} -
{# end col #} + + {% if is_admin_or_coordinator %} +
+ {% include "partials/officer_descriptions.html" %} +
{# end col #} {% endif %} - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} + + {% if is_admin_or_coordinator %}
-

Notes

-
    - {% for note in officer.notes %} -
  • - {{ note.date_updated.strftime('%b %d, %Y')}}
    - {{ note.text_contents | markdown }} - {{ note.creator.username }} - {% if note.creator_id == current_user.get_id() or current_user.is_administrator %} - - Edit - - - - Delete - - - {% endif %} -
  • - {% endfor %} -
- New Note + {% include "partials/officer_notes.html" %}
{# end col #} {% endif %}
{# end row #} diff --git a/OpenOversight/app/templates/partials/links_and_videos_row.html b/OpenOversight/app/templates/partials/links_and_videos_row.html index 21a48ab5e..b29bc3de9 100644 --- a/OpenOversight/app/templates/partials/links_and_videos_row.html +++ b/OpenOversight/app/templates/partials/links_and_videos_row.html @@ -1,5 +1,4 @@ -{% if obj.links|length > 0 or current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == obj.department_id) %} +{% if obj.links|length > 0 or is_admin_or_coordinator %}

Links

@@ -8,10 +7,8 @@

Links

    {% for link in list %}
  • - {{ link.title or link.url }} - {% if officer and (current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) - or link.creator_id == current_user.id) %} + {{ link.title or link.url }} + {% if officer and (is_admin_or_coordinator or link.creator_id == current_user.id) %} Edit diff --git a/OpenOversight/app/templates/partials/officer_add_photos.html b/OpenOversight/app/templates/partials/officer_add_photos.html new file mode 100644 index 000000000..3e859e9c0 --- /dev/null +++ b/OpenOversight/app/templates/partials/officer_add_photos.html @@ -0,0 +1,3 @@ +{% if is_admin_or_coordinator %} + Add photos of this officer +{% endif %} diff --git a/OpenOversight/app/templates/partials/officer_assignment_history.html b/OpenOversight/app/templates/partials/officer_assignment_history.html new file mode 100644 index 000000000..fa48bbb4d --- /dev/null +++ b/OpenOversight/app/templates/partials/officer_assignment_history.html @@ -0,0 +1,150 @@ +

    Assignment History

    + + + + + + + + {% if is_admin_or_coordinator %} + + {% endif %} + + + {% for assignment in assignments|rejectattr('star_date','ne',None) %} + + + + + + + + + {% endfor %} + {% for assignment in assignments | rejectattr('star_date', 'none') | sort(attribute='star_date', reverse=True) %} + + + + + + + + + {% endfor %} + +
    Job TitleBadge No.UnitStart DateEnd DateEdit
    {{ assignment.job.job_title }}{{ assignment.star_no }} + {% if assignment.unit_id %}{{ assignment.unit.descrip }}{% endif %} + + {% if assignment.star_date %} + {{ assignment.star_date }} + {% else %} + Unknown + {% endif %} + + {% if assignment.resign_date %} {{ assignment.resign_date }} {% endif %} + + {% if is_admin_or_coordinator %} + + Edit + + + {% endif %} +
    {{ assignment.job.job_title }}{{ assignment.star_no }} + {% if assignment.unit_id %}{{ assignment.unit.descrip }}{% endif %} + + {% if assignment.star_date: %} + {{ assignment.star_date }} + {% else %} + Unknown + {% endif %} + + {% if assignment.resign_date %} {{ assignment.resign_date }} {% endif %} + + {% if is_admin_or_coordinator %} + + Edit + + + {% endif %} +
    + +{% if is_admin_or_coordinator %} +

    Add Assignment Admin only

    + + + + {{ form.hidden_tag() }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    New badge number: + {{ form.star_no }} + {% for error in form.star_no.errors %} +

    [{{ error }}]

    + {% endfor %} +
    New job title: + {{ form.job_title }} + {% for error in form.job_title.errors %} +

    [{{ error }}]

    + {% endfor %} +
    New unit: + {{ form.unit }} + {% for error in form.unit.errors %} +

    [{{ error }}]

    + {% endfor %} +
    Start date of new assignment: + {{ form.star_date }} + {% for error in form.star_date.errors %} +

    [{{ error }}]

    + {% endfor %} +
    End date of new assignment: + {{ form.resign_date }} + {% for error in form.resign_date.errors %} +

    [{{ error }}]

    + {% endfor %} +
    + +
    +{% endif %} diff --git a/OpenOversight/app/templates/partials/officer_descriptions.html b/OpenOversight/app/templates/partials/officer_descriptions.html new file mode 100644 index 000000000..5f4a3d545 --- /dev/null +++ b/OpenOversight/app/templates/partials/officer_descriptions.html @@ -0,0 +1,36 @@ +

    Descriptions

    +{% if officer.descriptions %} +
      + {% for description in officer.descriptions %} +
    • + {{ description.date_updated.strftime('%b %d, %Y')}}
      + {{ description.text_contents | markdown }} + {{ description.creator.username }} + {% if description.creator_id == current_user.get_id() or + current_user.is_administrator %} + + Edit + + + + Delete + + + {% endif %} +
    • + {% endfor %} +
    +{% endif %} + +{% if is_admin_or_coordinator %} + + New description + +{% endif %} diff --git a/OpenOversight/app/templates/partials/officer_faces.html b/OpenOversight/app/templates/partials/officer_faces.html new file mode 100644 index 000000000..1123acb76 --- /dev/null +++ b/OpenOversight/app/templates/partials/officer_faces.html @@ -0,0 +1,13 @@ +{% for path in paths %} + +{% if is_admin_or_coordinator %} + +{% endif %} + +Submission + +{% if is_admin_or_coordinator %} + +{% endif %} + +{% endfor %} diff --git a/OpenOversight/app/templates/partials/officer_general_information.html b/OpenOversight/app/templates/partials/officer_general_information.html new file mode 100644 index 000000000..746038be3 --- /dev/null +++ b/OpenOversight/app/templates/partials/officer_general_information.html @@ -0,0 +1,48 @@ +
    +
    +

    + General Information + {% if is_admin_or_coordinator %} + + + + {% endif %} +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ officer.full_name() }}
    OpenOversight ID{{ officer.id }}
    Unique Internal Identifier{{ officer.unique_internal_identifier }}
    Department{{ officer.department.name }}
    Race{{ officer.race_label() }}
    Gender{{ officer.gender_label() }}
    Birth Year (Age){{ officer.birth_year }} ({{ officer.birth_year|get_age }})
    First Employment Date{{ officer.employment_date }}
    +
    +
    diff --git a/OpenOversight/app/templates/partials/officer_incidents.html b/OpenOversight/app/templates/partials/officer_incidents.html new file mode 100644 index 000000000..dd85226be --- /dev/null +++ b/OpenOversight/app/templates/partials/officer_incidents.html @@ -0,0 +1,34 @@ +

    Incidents

    +{% if officer.incidents %} + + + {% for incident in officer.incidents %} + {% if not loop.first %} + + {% endif %} + + + + {% include 'partials/incident_fields.html' %} + {% endfor %} +
     
    +

    + + Incident + {% if incident.report_number %} + {{ incident.report_number }} + {% else %} + {{ incident.id}} + {% endif %} + + {% if current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} + + + + {% endif %} +

    +
    +{% endif %} +{% if is_admin_or_coordinator %} + New Incident +{% endif %} diff --git a/OpenOversight/app/templates/partials/officer_notes.html b/OpenOversight/app/templates/partials/officer_notes.html new file mode 100644 index 000000000..d573403c6 --- /dev/null +++ b/OpenOversight/app/templates/partials/officer_notes.html @@ -0,0 +1,29 @@ +

    Notes

    + +
      + {% for note in officer.notes %} +
    • + {{ note.date_updated.strftime('%b %d, %Y')}} +
      + {{ note.text_contents | markdown }} + {{ note.creator.username }} + {% if note.creator_id == current_user.get_id() or current_user.is_administrator %} + + Edit + + + + Delete + + + {% endif %} +
    • + {% endfor %} +
    + + + New Note + diff --git a/OpenOversight/app/templates/partials/officer_salary.html b/OpenOversight/app/templates/partials/officer_salary.html new file mode 100644 index 000000000..115338eb1 --- /dev/null +++ b/OpenOversight/app/templates/partials/officer_salary.html @@ -0,0 +1,54 @@ +

    Salary

    +{% if officer.salaries %} + + + + + + + {% if is_admin_or_coordinator %} + + {% endif %} + + + {% for salary in officer.salaries %} + + + {% if salary.overtime_pay %} + {% if salary.overtime_pay > 0 %} + + {% else %} + + {% endif %} + + {% else %} + + + {% endif %} + + + + {% if is_admin_or_coordinator %} + + {% endif %} + + {% endfor %} + +
    Annual SalaryOvertimeTotal PayYearEdit
    {{ '${:,.2f}'.format(salary.salary) }}{{ '${:,.2f}'.format(salary.overtime_pay) }}{{ '${:,.2f}'.format(salary.salary + salary.overtime_pay) }}{% if salary.is_fiscal_year: %}FY {% endif %}{{ salary.year }} + + Edit + + +
    +{% endif %} + +{% if is_admin_or_coordinator %} + New Salary +{% endif %} From 389ea5a682a86544af4669ce828b623e3d96d899 Mon Sep 17 00:00:00 2001 From: sarayourfriend <24264157+sarayourfriend@users.noreply.github.com> Date: Thu, 8 Jul 2021 06:00:00 -0700 Subject: [PATCH 012/137] Stop auto cropping on cop photo upload (OrcaCollective#30) * Remove auto-cropping * Correct attach to match our docker config * Add EOF line * Fix cop face footer overlapping descriptions * Add explanatory comment --- OpenOversight/app/main/views.py | 6 ++---- OpenOversight/app/static/css/openoversight.css | 4 ++++ OpenOversight/app/templates/base.html | 2 +- OpenOversight/app/templates/cop_face.html | 17 ++++++++++++----- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 65dcfed0e..4d66db862 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -1094,11 +1094,9 @@ def upload(department_id, officer_id=None): if officer_id: image.is_tagged = True image.contains_cops = True - cropped_image = crop_image(image, department_id=department_id) - cropped_image.contains_cops = True - cropped_image.is_tagged = True face = Face(officer_id=officer_id, - img_id=cropped_image.id, + # Assuming photos uploaded with an officer ID are already cropped, so we set both images to the uploaded one + img_id=image.id, original_image_id=image.id, user_id=current_user.get_id()) db.session.add(face) diff --git a/OpenOversight/app/static/css/openoversight.css b/OpenOversight/app/static/css/openoversight.css index 1351d4af1..16fbc22ea 100644 --- a/OpenOversight/app/static/css/openoversight.css +++ b/OpenOversight/app/static/css/openoversight.css @@ -563,3 +563,7 @@ tr:hover .row-actions { .setup-content { margin-bottom: 20px; } + +.bottom-10 { + bottom: 10px; +} diff --git a/OpenOversight/app/templates/base.html b/OpenOversight/app/templates/base.html index ac13fb670..d0c6f42b3 100644 --- a/OpenOversight/app/templates/base.html +++ b/OpenOversight/app/templates/base.html @@ -100,7 +100,7 @@ {% endwith %} {% block content %}{% endblock %} -
    +

    diff --git a/OpenOversight/app/templates/cop_face.html b/OpenOversight/app/templates/cop_face.html index 01fd83e75..139e69300 100644 --- a/OpenOversight/app/templates/cop_face.html +++ b/OpenOversight/app/templates/cop_face.html @@ -118,11 +118,16 @@

    {{ department.name }}


    -
    -
    Explanation: click this button ONLY when all officers in it have been identified. This will remove it from the identification queue for ALL users.
    -
    Explanation: click this button if you would like to move on to the next image, without saving any info about this image.
    -
    Explanation: click this button to open the police roster. Use the roster to find the officer's OpenOversight ID.
    -
    +
    +
    +
    +
    Explanation: click this button ONLY when all officers in it have been identified. This will remove it from the identification queue for ALL users.
    +
    Explanation: click this button if you would like to move on to the next image, without saving any info about this image.
    +
    Explanation: click this button to open the police roster. Use the roster to find the officer's OpenOversight ID.
    +
    +
    +
    +
@@ -139,3 +144,5 @@

{{ department.name }}

{% endblock %} + +{% block footer_class %}bottom-10{% endblock %} From a2dd7de681300e4edb99a4fb45b293f9f0a055dc Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sat, 17 Jul 2021 12:37:36 -0700 Subject: [PATCH 013/137] Bugfix: close the image to free up memory (OrcaCollective#35) --- OpenOversight/app/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/OpenOversight/app/utils.py b/OpenOversight/app/utils.py index 377020b34..34432ff37 100644 --- a/OpenOversight/app/utils.py +++ b/OpenOversight/app/utils.py @@ -524,6 +524,7 @@ def upload_image_to_s3_and_store_in_db(image_buf, user_id, department_id=None): pimage.getexif().clear() scrubbed_image_buf = BytesIO() pimage.save(scrubbed_image_buf, image_type) + pimage.close() scrubbed_image_buf.seek(0) image_data = scrubbed_image_buf.read() hash_img = compute_hash(image_data) From ed6714b6b507fa44d54dcf62898143e4ac0d2481 Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Tue, 7 Sep 2021 17:36:44 -0700 Subject: [PATCH 014/137] Show ambiguity in age based on birth year (OrcaCollective#49) --- .../app/templates/partials/officer_general_information.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenOversight/app/templates/partials/officer_general_information.html b/OpenOversight/app/templates/partials/officer_general_information.html index 746038be3..be5cac6ee 100644 --- a/OpenOversight/app/templates/partials/officer_general_information.html +++ b/OpenOversight/app/templates/partials/officer_general_information.html @@ -36,7 +36,7 @@

Birth Year (Age) - {{ officer.birth_year }} ({{ officer.birth_year|get_age }}) + {{ officer.birth_year }} (~{{ officer.birth_year|get_age }} y/o) First Employment Date From 89662024036f29143afff284b5554e73537e886c Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Thu, 9 Sep 2021 14:32:09 -0700 Subject: [PATCH 015/137] Currently on the force computed field (OrcaCollective#55) * Add "currently on the force" computed field * Show "data missing" rather than "None" in fields --- OpenOversight/app/__init__.py | 7 +++++++ OpenOversight/app/models.py | 2 ++ .../partials/officer_general_information.html | 12 +++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/OpenOversight/app/__init__.py b/OpenOversight/app/__init__.py index ec02e540c..7de1381a7 100644 --- a/OpenOversight/app/__init__.py +++ b/OpenOversight/app/__init__.py @@ -99,6 +99,13 @@ def get_age_from_birth_year(birth_year): if birth_year: return int(datetime.datetime.now().year - birth_year) + @app.template_filter('currently_on_force') + def officer_currently_on_force(assignments): + if not assignments: + return "Uncertain" + most_recent = max(assignments, key=lambda x: x.star_date or datetime.date.min) + return "Yes" if most_recent.resign_date is None else "No" + @app.template_filter('markdown') def markdown(text): html = bleach.clean(_markdown.markdown(text), markdown_tags, markdown_attrs) diff --git a/OpenOversight/app/models.py b/OpenOversight/app/models.py index 4a2bdbbb0..4f81adc3e 100644 --- a/OpenOversight/app/models.py +++ b/OpenOversight/app/models.py @@ -133,6 +133,8 @@ def full_name(self): return '{} {}'.format(self.first_name, self.last_name) def race_label(self): + if self.race is None: + return 'Data Missing' from .main.choices import RACE_CHOICES for race, label in RACE_CHOICES: if self.race == race: diff --git a/OpenOversight/app/templates/partials/officer_general_information.html b/OpenOversight/app/templates/partials/officer_general_information.html index be5cac6ee..c126b1710 100644 --- a/OpenOversight/app/templates/partials/officer_general_information.html +++ b/OpenOversight/app/templates/partials/officer_general_information.html @@ -36,12 +36,22 @@

Birth Year (Age) - {{ officer.birth_year }} (~{{ officer.birth_year|get_age }} y/o) + + {% if officer.birth_year %} + {{ officer.birth_year }} (~{{ officer.birth_year|get_age }} y/o) + {% else %} + Data Missing + {% endif %} + First Employment Date {{ officer.employment_date }} + + Currently on the force + {{ assignments | currently_on_force }} +

From 4f7ab954cfe041ff71fb775c65f484f7e498dc28 Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Fri, 10 Sep 2021 13:56:49 -0700 Subject: [PATCH 016/137] Improve data download links (OrcaCollective#54) * Improve look of download page * Add salaries * Abstract CSV generalization logic * Add unit description to assignments * Add links * Show descriptions regardless of user status * Add descriptions --- OpenOversight/app/main/downloads.py | 165 +++++++++++++++++ OpenOversight/app/main/views.py | 198 +++++---------------- OpenOversight/app/templates/all_depts.html | 34 ++-- OpenOversight/app/templates/officer.html | 3 +- 4 files changed, 234 insertions(+), 166 deletions(-) create mode 100644 OpenOversight/app/main/downloads.py diff --git a/OpenOversight/app/main/downloads.py b/OpenOversight/app/main/downloads.py new file mode 100644 index 000000000..8361fadc4 --- /dev/null +++ b/OpenOversight/app/main/downloads.py @@ -0,0 +1,165 @@ +import csv +import io +from datetime import date +from typing import List, TypeVar, Callable, Dict, Any + +from flask import Response, abort +from sqlalchemy.orm import Query + +from OpenOversight.app.models import ( + Department, + Salary, + Officer, + Assignment, + Incident, + Link, + Description, +) + +T = TypeVar("T") +_Record = Dict[str, Any] + + +######################################################################################## +# Check util methods +######################################################################################## + + +def check_output(output_str): + if output_str == "Not Sure": + return "" + return output_str + + +######################################################################################## +# Route assistance function +######################################################################################## + + +def make_downloadable_csv( + query: Query, + department_id: int, + csv_suffix: str, + field_names: List[str], + record_maker: Callable[[T], _Record], +) -> Response: + department = Department.query.filter_by(id=department_id).first() + if not department: + abort(404) + + csv_output = io.StringIO() + csv_writer = csv.DictWriter(csv_output, fieldnames=field_names) + csv_writer.writeheader() + + for entity in query: + record = record_maker(entity) + csv_writer.writerow(record) + + dept_name = department.name.replace(" ", "_") + csv_name = dept_name + "_" + csv_suffix + ".csv" + + csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} + return Response(csv_output.getvalue(), mimetype="text/csv", headers=csv_headers) + + +######################################################################################## +# Record makers +######################################################################################## + + +def salary_record_maker(salary: Salary) -> _Record: + return { + "id": salary.id, + "officer id": salary.officer_id, + "first name": salary.officer.first_name, + "last name": salary.officer.last_name, + "salary": salary.salary, + "overtime_pay": salary.overtime_pay, + "year": salary.year, + "is_fiscal_year": salary.is_fiscal_year, + } + + +def officer_record_maker(officer: Officer) -> _Record: + if officer.assignments_lazy: + most_recent_assignment = max( + officer.assignments_lazy, key=lambda a: a.star_date or date.min + ) + most_recent_title = most_recent_assignment.job and check_output( + most_recent_assignment.job.job_title + ) + else: + most_recent_assignment = None + most_recent_title = None + if officer.salaries: + most_recent_salary = max(officer.salaries, key=lambda s: s.year) + else: + most_recent_salary = None + return { + "id": officer.id, + "unique identifier": officer.unique_internal_identifier, + "last name": officer.last_name, + "first name": officer.first_name, + "middle initial": officer.middle_initial, + "suffix": officer.suffix, + "gender": check_output(officer.gender), + "race": check_output(officer.race), + "birth year": officer.birth_year, + "employment date": officer.employment_date, + "badge number": most_recent_assignment and most_recent_assignment.star_no, + "job title": most_recent_title, + "most recent salary": most_recent_salary and most_recent_salary.salary, + } + + +def assignment_record_maker(assignment: Assignment) -> _Record: + officer = assignment.baseofficer + return { + "id": assignment.id, + "officer id": assignment.officer_id, + "officer unique identifier": officer and officer.unique_internal_identifier, + "badge number": assignment.star_no, + "job title": assignment.job and check_output(assignment.job.job_title), + "start date": assignment.star_date, + "end date": assignment.resign_date, + "unit id": assignment.unit and assignment.unit.id, + "unit description": assignment.unit and assignment.unit.descrip, + } + + +def incidents_record_maker(incident: Incident) -> _Record: + return { + "id": incident.id, + "report_num": incident.report_number, + "date": incident.date, + "time": incident.time, + "description": incident.description, + "location": incident.address, + "licenses": " ".join(map(str, incident.license_plates)), + "links": " ".join(map(str, incident.links)), + "officers": " ".join(map(str, incident.officers)), + } + + +def links_record_maker(link: Link) -> _Record: + return { + "id": link.id, + "title": link.title, + "url": link.url, + "link_type": link.link_type, + "description": link.description, + "author": link.author, + "officers": [officer.id for officer in link.officers], + "incidents": [incident.id for incident in link.incidents], + } + + +def descriptions_record_maker(description: Description) -> _Record: + return { + "id": description.id, + "text_contents": description.text_contents, + "creator_id": description.creator_id, + "officer_id": description.officer_id, + "date_created": description.date_created, + "date_updated": description.date_updated, + } diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 4d66db862..74a34f4e5 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -1,6 +1,3 @@ -import csv -from datetime import date -import io import os import re from sqlalchemy.exc import IntegrityError @@ -10,10 +7,10 @@ from traceback import format_exc from flask import (abort, render_template, request, redirect, url_for, - flash, current_app, jsonify, Response) + flash, current_app, jsonify) from flask_login import current_user, login_required, login_user -from . import main +from . import main, downloads from .. import limiter, sitemap from ..utils import (serve_image, compute_leaderboard_stats, get_random_image, allowed_file, add_new_assignment, edit_existing_assignment, @@ -881,63 +878,9 @@ def submit_data(): return render_template('submit_image.html', form=form, preferred_dept_id=preferred_dept_id) -def check_input(str_input): - if str_input is None or str_input == "Not Sure": - return "" - else: - return str(str_input).replace(",", " ") # no commas allowed - - -@main.route('/download/department/', methods=['GET']) -@limiter.limit('5/minute') -def deprecated_download_dept_csv(department_id): - department = Department.query.filter_by(id=department_id).first() - records = Officer.query.filter_by(department_id=department_id).all() - if not department or not records: - abort(404) - dept_name = records[0].department.name.replace(" ", "_") - first_row = "id, last, first, middle, suffix, gender, "\ - "race, born, employment_date, assignments\n" - - assign_dict = {} - assign_records = Assignment.query.all() - for r in assign_records: - if r.officer_id not in assign_dict: - assign_dict[r.officer_id] = [] - assign_dict[r.officer_id].append("(#%s %s %s %s %s)" % (check_input(r.star_no), check_input(r.job_id), check_input(r.unit_id), check_input(r.star_date), check_input(r.resign_date))) - - record_list = ["%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n" % - (str(record.id), - check_input(record.last_name), - check_input(record.first_name), - check_input(record.middle_initial), - check_input(record.suffix), - check_input(record.gender), - check_input(record.race), - check_input(record.birth_year), - check_input(record.employment_date), - " ".join(assign_dict.get(record.id, [])), - ) for record in records] - - csv_name = dept_name + "_Officers.csv" - csv = first_row + "".join(record_list) - csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} - return Response(csv, mimetype="text/csv", headers=csv_headers) - - -def check_output(output_str): - if output_str == "Not Sure": - return "" - return output_str - - @main.route('/download/department//officers', methods=['GET']) @limiter.limit('5/minute') def download_dept_officers_csv(department_id): - department = Department.query.filter_by(id=department_id).first() - if not department: - abort(404) - officers = (db.session.query(Officer) .options(joinedload(Officer.assignments_lazy) .joinedload(Assignment.job) @@ -946,56 +889,14 @@ def download_dept_officers_csv(department_id): .filter_by(department_id=department_id) ) - if not officers: - abort(404) - csv_output = io.StringIO() - csv_fieldnames = ["id", "unique identifier", "last name", "first name", "middle initial", "suffix", "gender", - "race", "birth year", "employment date", "badge number", "job title", "most recent salary"] - csv_writer = csv.DictWriter(csv_output, fieldnames=csv_fieldnames) - csv_writer.writeheader() - - for officer in officers: - if officer.assignments_lazy: - most_recent_assignment = max(officer.assignments_lazy, key=lambda a: a.star_date or date.min) - most_recent_title = most_recent_assignment.job and check_output(most_recent_assignment.job.job_title) - else: - most_recent_assignment = None - most_recent_title = None - if officer.salaries: - most_recent_salary = max(officer.salaries, key=lambda s: s.year) - else: - most_recent_salary = None - record = { - "id": officer.id, - "unique identifier": officer.unique_internal_identifier, - "last name": officer.last_name, - "first name": officer.first_name, - "middle initial": officer.middle_initial, - "suffix": officer.suffix, - "gender": check_output(officer.gender), - "race": check_output(officer.race), - "birth year": officer.birth_year, - "employment date": officer.employment_date, - "badge number": most_recent_assignment and most_recent_assignment.star_no, - "job title": most_recent_title, - "most recent salary": most_recent_salary and most_recent_salary.salary, - } - csv_writer.writerow(record) - - dept_name = department.name.replace(" ", "_") - csv_name = dept_name + "_Officers.csv" - - csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} - return Response(csv_output.getvalue(), mimetype="text/csv", headers=csv_headers) + field_names = ["id", "unique identifier", "last name", "first name", "middle initial", "suffix", "gender", + "race", "birth year", "employment date", "badge number", "job title", "most recent salary"] + return downloads.make_downloadable_csv(officers, department_id, "Officers", field_names, downloads.officer_record_maker) @main.route('/download/department//assignments', methods=['GET']) @limiter.limit('5/minute') def download_dept_assignments_csv(department_id): - department = Department.query.filter_by(id=department_id).first() - if not department: - abort(404) - assignments = (db.session.query(Assignment) .join(Assignment.baseofficer) .filter(Officer.department_id == department_id) @@ -1004,58 +905,55 @@ def download_dept_assignments_csv(department_id): .options(joinedload(Assignment.job)) ) - csv_output = io.StringIO() - csv_fieldnames = ["id", "officer id", "officer unique identifier", "badge number", "job title", "start date", "end date", "unit id"] - csv_writer = csv.DictWriter(csv_output, fieldnames=csv_fieldnames) - csv_writer.writeheader() + field_names = ["id", "officer id", "officer unique identifier", "badge number", "job title", "start date", "end date", "unit id", "unit description"] + return downloads.make_downloadable_csv(assignments, department_id, "Assignments", field_names, downloads.assignment_record_maker) - for assignment in assignments: - officer = assignment.baseofficer - record = { - "id": assignment.id, - "officer id": assignment.officer_id, - "officer unique identifier": officer and officer.unique_internal_identifier, - "badge number": assignment.star_no, - "job title": assignment.job and check_output(assignment.job.job_title), - "start date": assignment.star_date, - "end date": assignment.resign_date, - "unit id": assignment.unit and assignment.unit.id, - } - csv_writer.writerow(record) - dept_name = department.name.replace(" ", "_") - csv_name = dept_name + "_Assignments.csv" +@main.route('/download/department//incidents', methods=['GET']) +@limiter.limit('5/minute') +def download_incidents_csv(department_id): + incidents = Incident.query.filter_by(department_id=department_id).all() + field_names = ["id", "report_num", "date", "time", "description", "location", "licences", "links", "officers"] + return downloads.make_downloadable_csv(incidents, department_id, "Incidents", field_names, downloads.incidents_record_maker) - csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} - return Response(csv_output.getvalue(), mimetype="text/csv", headers=csv_headers) +@main.route('/download/department//salaries', methods=['GET']) +@limiter.limit('5/minute') +def download_dept_salaries_csv(department_id): + salaries = (db.session.query(Salary) + .join(Salary.officer) + .filter(Officer.department_id == department_id) + .options(contains_eager(Salary.officer)) + ) -@main.route('/download/department//incidents', methods=['GET']) + field_names = ["id", "officer id", "first name", "last name", "salary", "overtime_pay", "year", "is_fiscal_year"] + return downloads.make_downloadable_csv(salaries, department_id, "Salaries", field_names, downloads.salary_record_maker) + + +@main.route('/download/department//links', methods=['GET']) @limiter.limit('5/minute') -def download_incidents_csv(department_id): - department = Department.query.filter_by(id=department_id).first() - records = Incident.query.filter_by(department_id=department.id).all() - if not department or not records: - abort(404) - dept_name = records[0].department.name.replace(" ", "_") - first_row = "id,report_num,date,time,description,location,licences,links,officers\n" - - record_list = ["%s,%s,%s,%s,%s,%s,%s,%s,%s\n" % - (str(record.id), - check_input(record.report_number), - check_input(record.date), - check_input(record.time), - check_input(record.description), - check_input(record.address), - " ".join(map(lambda x: str(x), record.license_plates)), - " ".join(map(lambda x: str(x), record.links)), - " ".join(map(lambda x: str(x), record.officers)), - ) for record in records] - - csv_name = dept_name + "_Incidents.csv" - csv = first_row + "".join(record_list) - csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} - return Response(csv, mimetype="text/csv", headers=csv_headers) +def download_dept_links_csv(department_id): + links = (db.session.query(Link) + .join(Link.officers) + .filter(Officer.department_id == department_id) + .options(contains_eager(Link.officers)) + ) + + field_names = ["id", "title", "url", "link_type", "description", "author", "officers", "incidents"] + return downloads.make_downloadable_csv(links, department_id, "Links", field_names, downloads.links_record_maker) + + +@main.route('/download/department//descriptions', methods=['GET']) +@limiter.limit('5/minute') +def download_dept_descriptions_csv(department_id): + notes = (db.session.query(Description) + .join(Description.officer) + .filter(Officer.department_id == department_id) + .options(contains_eager(Description.officer)) + ) + + field_names = ["id", "text_contents", "creator_id", "officer_id", "date_created", "date_updated"] + return downloads.make_downloadable_csv(notes, department_id, "Notes", field_names, downloads.descriptions_record_maker) @sitemap_include diff --git a/OpenOversight/app/templates/all_depts.html b/OpenOversight/app/templates/all_depts.html index fdb3719a6..ceaf4c4b0 100644 --- a/OpenOversight/app/templates/all_depts.html +++ b/OpenOversight/app/templates/all_depts.html @@ -2,21 +2,25 @@ {% import "bootstrap/wtf.html" as wtf %} {% block content %} -
+
- - {% for dept in departments %} -

- {{ dept.name }} - officers.csv - assignments.csv - {% if dept.incidents %} - incidents.csv - {% endif %} - -

- {% endfor %} - -
+
+ {% for dept in departments %} +

+

{{ dept.name }}

+ +

+ {% endfor %} +
+
{% endblock %} diff --git a/OpenOversight/app/templates/officer.html b/OpenOversight/app/templates/officer.html index ff17615ab..604377ff3 100644 --- a/OpenOversight/app/templates/officer.html +++ b/OpenOversight/app/templates/officer.html @@ -123,12 +123,13 @@

Officer Detail: {{ officer.full_name() }}

{# end col #} {% endif %} - {% if is_admin_or_coordinator %} + {% if officer.descriptions or is_admin_or_coordinator %}
{% include "partials/officer_descriptions.html" %}
{# end col #} {% endif %} + {# Notes are for internal use #} {% if is_admin_or_coordinator %}
{% include "partials/officer_notes.html" %} From c47ea9e7703034a2335d322de1b21edd379aee9f Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Fri, 10 Sep 2021 14:44:47 -0700 Subject: [PATCH 017/137] Hotfix: Use relative imports (OrcaCollective#58) --- OpenOversight/app/main/downloads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenOversight/app/main/downloads.py b/OpenOversight/app/main/downloads.py index 8361fadc4..886a5a914 100644 --- a/OpenOversight/app/main/downloads.py +++ b/OpenOversight/app/main/downloads.py @@ -6,7 +6,7 @@ from flask import Response, abort from sqlalchemy.orm import Query -from OpenOversight.app.models import ( +from ..models import ( Department, Salary, Officer, From 004091fd6449188bff54abf570db19493b60abf9 Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Fri, 8 Oct 2021 20:15:19 -0700 Subject: [PATCH 018/137] Set minimum image size for officer faces (OrcaCollective#63) --- OpenOversight/app/static/css/openoversight.css | 5 +++++ OpenOversight/app/templates/image.html | 2 +- OpenOversight/app/templates/list_officer.html | 2 +- OpenOversight/app/templates/partials/officer_faces.html | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/OpenOversight/app/static/css/openoversight.css b/OpenOversight/app/static/css/openoversight.css index 16fbc22ea..2115b20ae 100644 --- a/OpenOversight/app/static/css/openoversight.css +++ b/OpenOversight/app/static/css/openoversight.css @@ -567,3 +567,8 @@ tr:hover .row-actions { .bottom-10 { bottom: 10px; } + +.officer-face { + min-width: 200px; + min-height: 200px; +} diff --git a/OpenOversight/app/templates/image.html b/OpenOversight/app/templates/image.html index 64f777f2a..6321fcd0d 100644 --- a/OpenOversight/app/templates/image.html +++ b/OpenOversight/app/templates/image.html @@ -10,7 +10,7 @@

Image Submission Detail

- Submission + Submission
diff --git a/OpenOversight/app/templates/list_officer.html b/OpenOversight/app/templates/list_officer.html index 28450c105..886596c64 100644 --- a/OpenOversight/app/templates/list_officer.html +++ b/OpenOversight/app/templates/list_officer.html @@ -141,7 +141,7 @@

Age range

diff --git a/OpenOversight/app/templates/partials/officer_faces.html b/OpenOversight/app/templates/partials/officer_faces.html index 1123acb76..72ac1e952 100644 --- a/OpenOversight/app/templates/partials/officer_faces.html +++ b/OpenOversight/app/templates/partials/officer_faces.html @@ -4,7 +4,7 @@ {% endif %} -Submission +Submission {% if is_admin_or_coordinator %} From f413e13f2e9cf983fdf674e2cf9b59b306e6a79f Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sun, 26 Dec 2021 15:07:05 -0800 Subject: [PATCH 019/137] Fix test_utils --- OpenOversight/tests/test_utils.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/OpenOversight/tests/test_utils.py b/OpenOversight/tests/test_utils.py index a7d8dfabe..b5e4e0441 100644 --- a/OpenOversight/tests/test_utils.py +++ b/OpenOversight/tests/test_utils.py @@ -11,6 +11,12 @@ # Utils tests +upload_s3_patch = patch( + "OpenOversight.app.utils.upload_obj_to_s3", + MagicMock(return_value="https://s3-some-bucket/someaddress.jpg"), +) + + def test_department_filter(mockdata): department = OpenOversight.app.models.Department.query.first() results = OpenOversight.app.utils.grab_officers( @@ -179,24 +185,32 @@ def test_unit_choices(mockdata): assert 'Unit: Bureau of Organized Crime' in unit_choices -@patch('OpenOversight.app.utils.upload_obj_to_s3', MagicMock(return_value='https://s3-some-bucket/someaddress.jpg')) -def test_upload_image_to_s3_and_store_in_db_increases_images_in_db(mockdata, test_png_BytesIO, client): +@upload_s3_patch +def test_upload_image_to_s3_and_store_in_db_increases_images_in_db( + mockdata, test_png_BytesIO, client +): original_image_count = Image.query.count() upload_image_to_s3_and_store_in_db(test_png_BytesIO, 1, 1) assert Image.query.count() == original_image_count + 1 -@patch('OpenOversight.app.utils.upload_obj_to_s3', MagicMock(return_value='https://s3-some-bucket/someaddress.jpg')) -def test_upload_existing_image_to_s3_and_store_in_db_returns_existing_image(mockdata, test_png_BytesIO, client): +@upload_s3_patch +def test_upload_existing_image_to_s3_and_store_in_db_returns_existing_image( + mockdata, test_png_BytesIO, client +): + # Disable file closing for this test + test_png_BytesIO.close = lambda: None firstUpload = upload_image_to_s3_and_store_in_db(test_png_BytesIO, 1, 1) secondUpload = upload_image_to_s3_and_store_in_db(test_png_BytesIO, 1, 1) assert type(secondUpload) == Image assert firstUpload.id == secondUpload.id -@patch('OpenOversight.app.utils.upload_obj_to_s3', MagicMock(return_value='https://s3-some-bucket/someaddress.jpg')) -def test_upload_image_to_s3_and_store_in_db_does_not_set_tagged(mockdata, test_png_BytesIO, client): +@upload_s3_patch +def test_upload_image_to_s3_and_store_in_db_does_not_set_tagged( + mockdata, test_png_BytesIO, client +): upload = upload_image_to_s3_and_store_in_db(test_png_BytesIO, 1, 1) assert not upload.is_tagged From 1becf2a6fceb52e7ec951b84f74f6a8b13b86d03 Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sun, 26 Dec 2021 15:16:55 -0800 Subject: [PATCH 020/137] Fix incidents test --- OpenOversight/app/main/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 74a34f4e5..871c9da94 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -913,7 +913,7 @@ def download_dept_assignments_csv(department_id): @limiter.limit('5/minute') def download_incidents_csv(department_id): incidents = Incident.query.filter_by(department_id=department_id).all() - field_names = ["id", "report_num", "date", "time", "description", "location", "licences", "links", "officers"] + field_names = ["id", "report_num", "date", "time", "description", "location", "licenses", "links", "officers"] return downloads.make_downloadable_csv(incidents, department_id, "Incidents", field_names, downloads.incidents_record_maker) From 28e0a7e40fa1ac499d2f4cdc7663ca88a42b56e4 Mon Sep 17 00:00:00 2001 From: abandoned-prototype <41744410+abandoned-prototype@users.noreply.github.com> Date: Fri, 17 Feb 2023 23:32:46 -0600 Subject: [PATCH 021/137] Format codebase as part of backport (#901) * Remove stand-alone scripts. These flicker and twitter download scripts have not been maintained and are not really part of OpenOversight. We could set them up in their own repository if needed. * Automatic changes from running black. * Remove executable from non-executable files. * Fix blanket noqa issues. * Fix pydocstyle issues. * Fix flake8 issues. --- .github/workflows/main.yml | 4 +- .gitignore | 2 - CONTRIB.md | 20 +- Makefile | 4 +- OpenOversight/app/__init__.py | 69 +- OpenOversight/app/auth/__init__.py | 5 +- OpenOversight/app/auth/forms.py | 153 +- OpenOversight/app/auth/utils.py | 9 +- OpenOversight/app/auth/views.py | 308 +-- OpenOversight/app/commands.py | 347 +-- OpenOversight/app/config.py | 54 +- OpenOversight/app/custom.py | 2 +- OpenOversight/app/email.py | 13 +- OpenOversight/app/formfields.py | 20 +- OpenOversight/app/main/__init__.py | 5 +- OpenOversight/app/main/choices.py | 43 +- OpenOversight/app/main/downloads.py | 11 +- OpenOversight/app/main/forms.py | 650 ++++-- OpenOversight/app/main/model_view.py | 110 +- OpenOversight/app/main/views.py | 1428 +++++++----- OpenOversight/app/model_imports.py | 10 +- OpenOversight/app/models.py | 444 ++-- OpenOversight/app/static/css/cropper.css | 0 OpenOversight/app/static/css/cropper.min.css | 2 +- .../app/static/css/font-awesome.min.css | 0 .../app/static/css/jquery-ui.min.css | 2 +- .../app/static/css/openoversight.css | 4 +- OpenOversight/app/static/css/qunit.css | 0 .../app/static/fonts/FontAwesome.otf | Bin .../glyphicons-halflings-regular.svg | 2 +- .../app/static/fonts/fontawesome-webfont.eot | Bin .../app/static/fonts/fontawesome-webfont.svg | 2 +- .../app/static/fonts/fontawesome-webfont.ttf | Bin .../app/static/fonts/fontawesome-webfont.woff | Bin .../static/fonts/fontawesome-webfont.woff2 | Bin OpenOversight/app/static/js/bootstrap.js | 0 OpenOversight/app/static/js/bootstrap.min.js | 2 +- OpenOversight/app/static/js/cropper.js | 0 OpenOversight/app/static/js/cropper.min.js | 2 +- OpenOversight/app/static/js/dropzone.js | 6 +- OpenOversight/app/static/js/find_officer.js | 6 +- OpenOversight/app/static/js/html5shiv.min.js | 2 +- .../app/static/js/incidentDescription.js | 2 +- OpenOversight/app/static/js/jquery-ui.min.js | 2 +- OpenOversight/app/static/js/npm.js | 2 +- OpenOversight/app/static/js/qunit.js | 0 OpenOversight/app/static/js/respond.min.js | 2 +- OpenOversight/app/templates/base.html | 2 +- OpenOversight/app/templates/complaint.html | 2 +- .../app/templates/incident_detail.html | 2 +- OpenOversight/app/templates/index.html | 10 +- .../app/templates/input_find_officer.html | 2 +- OpenOversight/app/templates/label_data.html | 2 +- .../templates/partials/incident_fields.html | 2 +- .../app/templates/partials/officer_faces.html | 2 +- .../partials/officer_form_fields_hidden.html | 2 +- .../partials/roster_form_fields.html | 2 +- OpenOversight/app/utils.py | 485 ++-- OpenOversight/app/validators.py | 11 +- OpenOversight/app/widgets.py | 45 +- OpenOversight/migrations/README | 2 +- OpenOversight/migrations/env.py | 44 +- OpenOversight/migrations/script.py.mako | 0 .../migrations/versions/0acbb0f0b1ef_.py | 73 +- ...ed957db0058_add_description_to_officers.py | 35 +- .../migrations/versions/114919b27a9f_.py | 47 +- .../migrations/versions/2040f0c804b0_.py | 34 +- ...2a9064a2507c_remove_dots_middle_initial.py | 9 +- ...e66e_add_unique_internal_identifier_to_.py | 22 +- .../versions/3015d1dd9eb4_add_jobs_table.py | 93 +- .../migrations/versions/42233d18ac7b_.py | 28 +- .../4a490771dda1_add_original_image_fk.py | 44 +- .../562bd5f1bc1f_add_order_to_jobs.py | 39 +- ...59e9993c169c_change_faces_to_thumbnails.py | 15 +- .../versions/5c5b80cab45e_add_approved.py | 10 +- ..._split_apart_date_and_time_in_incidents.py | 49 +- .../migrations/versions/6065d7cdcbf8_.py | 60 +- .../770ed51b4e16_add_salaries_table.py | 40 +- ...85454f_add_featured_flag_to_faces_table.py | 13 +- ...53dee8ac9_add_suffix_column_to_officers.py | 14 +- .../versions/86eb228e4bc0_refactor_links.py | 24 +- ...c2_add_unique_internal_identifier_label.py | 15 +- .../migrations/versions/8ce7926aa132_.py | 28 +- .../93fc3e074dcc_remove_link_length_cap.py | 2 +- .../migrations/versions/9e2827dae28c_.py | 17 +- .../migrations/versions/af933dc1ef93_.py | 14 +- .../migrations/versions/bd0398fe4aab_.py | 34 +- ...c26073f85_rank_rename_po_police_officer.py | 4 +- .../migrations/versions/ca95c047bf42_.py | 14 +- ...3b5360_constrain_officer_gender_options.py | 44 +- .../migrations/versions/cfc5f3fd5efe_.py | 24 +- .../migrations/versions/d86feb8fa5d1_.py | 197 +- .../migrations/versions/e14a1aa4b58f_.py | 20 +- .../versions/e2c2efde8b55_face_model_fix.py | 39 +- .../migrations/versions/f4a41e328a06_.py | 38 +- OpenOversight/tests/conftest.py | 403 ++-- OpenOversight/tests/routes/route_helpers.py | 47 +- OpenOversight/tests/routes/test_auth.py | 297 ++- .../tests/routes/test_descriptions.py | 267 ++- .../tests/routes/test_image_tagging.py | 320 +-- OpenOversight/tests/routes/test_incidents.py | 417 ++-- OpenOversight/tests/routes/test_notes.py | 214 +- .../routes/test_officer_and_department.py | 1970 +++++++++-------- OpenOversight/tests/routes/test_other.py | 37 +- OpenOversight/tests/routes/test_user_api.py | 146 +- OpenOversight/tests/test_alembic.py | 4 +- OpenOversight/tests/test_commands.py | 55 +- OpenOversight/tests/test_csvs/assignments.csv | 2 +- OpenOversight/tests/test_csvs/incidents.csv | 2 +- OpenOversight/tests/test_csvs/links.csv | 2 +- OpenOversight/tests/test_csvs/officers.csv | 2 +- OpenOversight/tests/test_csvs/salaries.csv | 2 +- OpenOversight/tests/test_functional.py | 82 +- OpenOversight/tests/test_models.py | 246 +- OpenOversight/tests/test_utils.py | 184 +- PULL_REQUEST_TEMPLATE.md | 8 +- UX-Docs/UX-Docs/readme.md | 2 +- create_db.py | 3 +- database/README.md | 2 +- db_backup.py | 7 +- dev-requirements.txt | 14 +- docs/advanced_csv_import.rst | 4 +- docs/conf.py | 59 +- fabfile.py | 96 +- flickrscraper/flickrgroup.py | 75 - flickrscraper/readme.md | 43 - flickrscraper/resize.and.detect.py | 133 -- package.json | 12 +- proposals/1_DesignDocument.md | 58 +- proposals/2_UserStories.md | 4 +- requirements.txt | 32 +- setup.py | 28 +- socmint/README.md | 22 - socmint/chicago.txt | 26 - socmint/grab_socmint.py | 21 - test_data.py | 19 +- 136 files changed, 6143 insertions(+), 4727 deletions(-) mode change 100755 => 100644 OpenOversight/app/static/css/cropper.css mode change 100755 => 100644 OpenOversight/app/static/css/cropper.min.css mode change 100755 => 100644 OpenOversight/app/static/css/font-awesome.min.css mode change 100755 => 100644 OpenOversight/app/static/css/qunit.css mode change 100755 => 100644 OpenOversight/app/static/fonts/FontAwesome.otf mode change 100755 => 100644 OpenOversight/app/static/fonts/fontawesome-webfont.eot mode change 100755 => 100644 OpenOversight/app/static/fonts/fontawesome-webfont.svg mode change 100755 => 100644 OpenOversight/app/static/fonts/fontawesome-webfont.ttf mode change 100755 => 100644 OpenOversight/app/static/fonts/fontawesome-webfont.woff mode change 100755 => 100644 OpenOversight/app/static/fonts/fontawesome-webfont.woff2 mode change 100755 => 100644 OpenOversight/app/static/js/bootstrap.js mode change 100755 => 100644 OpenOversight/app/static/js/bootstrap.min.js mode change 100755 => 100644 OpenOversight/app/static/js/cropper.js mode change 100755 => 100644 OpenOversight/app/static/js/cropper.min.js mode change 100755 => 100644 OpenOversight/app/static/js/npm.js mode change 100755 => 100644 OpenOversight/app/static/js/qunit.js mode change 100755 => 100644 OpenOversight/migrations/README mode change 100755 => 100644 OpenOversight/migrations/env.py mode change 100755 => 100644 OpenOversight/migrations/script.py.mako delete mode 100644 flickrscraper/flickrgroup.py delete mode 100644 flickrscraper/readme.md delete mode 100644 flickrscraper/resize.and.detect.py delete mode 100644 socmint/README.md delete mode 100644 socmint/chicago.txt delete mode 100644 socmint/grab_socmint.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e41f872ae..c61ba4081 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,10 +1,10 @@ name: CI -# Controls when the action will run. +# Controls when the action will run. on: # Triggers the workflow on push or pull request events but only for the develop branch push: - branches: + branches: - develop - main pull_request: diff --git a/.gitignore b/.gitignore index da5707c66..f7b6c27ef 100644 --- a/.gitignore +++ b/.gitignore @@ -95,5 +95,3 @@ vagrant/puppet/.tmp node_modules/ OpenOverSight/app/static/dist/ - - diff --git a/CONTRIB.md b/CONTRIB.md index 14f92168c..86f4cfdb6 100644 --- a/CONTRIB.md +++ b/CONTRIB.md @@ -120,14 +120,14 @@ One way to avoid hitting version incompatibility errors when running `flask` com python3 -m virtualenv env ``` -Confirm you're in the virtualenv by running +Confirm you're in the virtualenv by running ```bash -which python +which python ``` -The response should point to your `env` directory. -If you want to exit the virtualenv, run +The response should point to your `env` directory. +If you want to exit the virtualenv, run ```bash deactivate @@ -139,7 +139,7 @@ To reactivate the virtualenv, run source env/bin/activate ``` -While in the virtualenv, you can install project dependencies by running +While in the virtualenv, you can install project dependencies by running ```bash pip install -r requirements.txt @@ -200,14 +200,14 @@ Administrator redshiftzero successfully added In `docker-compose.yml`, below the line specifying the port number, add the following lines to the `web` service: ```yml stdin_open: true - tty: true + tty: true ``` Also in `docker-compose.yml`, below the line specifying the `FLASK_ENV`, add the following to the `environment` portion of the `web` service: ```yml FLASK_DEBUG: 0 ``` The above line disables the werkzeug reloader, which can otherwise cause a bug when you place a breakpoint in code that loads at import time, such as classes. The werkzeug reloader will start one pdb process at import time and one when you navigate to the class. This makes it impossible to interact with the pdb prompt, but we can fix it by disabling the reloader. - + To set a breakpoint in OpenOversight, first import the pdb module by adding `import pdb` to the file you want to debug. Call `pdb.set_trace()` on its own line wherever you want to break for debugging. Next, in your terminal run `docker ps` to find the container id of the `openoversight_web` image, then run `docker attach ${container_id}` to connect to the debugger in your terminal. You can now use pdb prompts to step through the app. @@ -222,10 +222,10 @@ where `` is the name of a single test function, such as `test_ac Similarly, you can run all the tests in a file by specifying the file path: ```bash -docker-compose run --rm web pytest --pdb -v path/to/test/file +docker-compose run --rm web pytest --pdb -v path/to/test/file ``` -where `path/to/test/file` is the relative file path, minus the initial `OpenOversight`, such as +where `path/to/test/file` is the relative file path, minus the initial `OpenOversight`, such as `tests/routes/test_officer_and_department.py`. -Again, add `import pdb` to the file you want to debug, then write `pdb.set_trace()` wherever you want to drop a breakpoint. Once the test is up and running in your terminal, you can debug it using pdb prompts. \ No newline at end of file +Again, add `import pdb` to the file you want to debug, then write `pdb.set_trace()` wherever you want to drop a breakpoint. Once the test is up and running in your terminal, you can debug it using pdb prompts. diff --git a/Makefile b/Makefile index 29c195f58..ce9698ced 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ test: start ## Run tests fi .PHONY: lint -lint: +lint: docker-compose run --no-deps --rm web /bin/bash -c 'flake8; mypy app --config="../mypy.ini"' .PHONY: cleanassets @@ -82,4 +82,4 @@ help: ## Print this message and exit | column -s ':' -t attach: - docker-compose exec postgres psql -h localhost -U openoversight openoversight-dev \ No newline at end of file + docker-compose exec postgres psql -h localhost -U openoversight openoversight-dev diff --git a/OpenOversight/app/__init__.py b/OpenOversight/app/__init__.py index 7de1381a7..bf8883dba 100644 --- a/OpenOversight/app/__init__.py +++ b/OpenOversight/app/__init__.py @@ -1,10 +1,11 @@ import datetime import logging -from logging.handlers import RotatingFileHandler import os +from logging.handlers import RotatingFileHandler import bleach -from bleach_allowlist import markdown_tags, markdown_attrs +import markdown as _markdown +from bleach_allowlist import markdown_attrs, markdown_tags from flask import Flask, render_template from flask_bootstrap import Bootstrap from flask_limiter import Limiter @@ -14,7 +15,6 @@ from flask_migrate import Migrate from flask_sitemap import Sitemap from flask_wtf.csrf import CSRFProtect -import markdown as _markdown from markupsafe import Markup from .config import config @@ -24,21 +24,22 @@ mail = Mail() login_manager = LoginManager() -login_manager.session_protection = 'strong' -login_manager.login_view = 'auth.login' +login_manager.session_protection = "strong" +login_manager.login_view = "auth.login" -limiter = Limiter(key_func=get_remote_address, - default_limits=["100 per minute", "5 per second"]) +limiter = Limiter( + key_func=get_remote_address, default_limits=["100 per minute", "5 per second"] +) sitemap = Sitemap() csrf = CSRFProtect() -def create_app(config_name='default'): +def create_app(config_name="default"): app = Flask(__name__) app.config.from_object(config[config_name]) config[config_name].init_app(app) - from .models import db # noqa + from .models import db bootstrap.init_app(app) mail.init_app(app) @@ -48,74 +49,86 @@ def create_app(config_name='default'): sitemap.init_app(app) csrf.init_app(app) - from .main import main as main_blueprint # noqa + from .main import main as main_blueprint + app.register_blueprint(main_blueprint) - from .auth import auth as auth_blueprint # noqa - app.register_blueprint(auth_blueprint, url_prefix='/auth') + from .auth import auth as auth_blueprint + + app.register_blueprint(auth_blueprint, url_prefix="/auth") max_log_size = 10 * 1024 * 1024 # start new log file after 10 MB num_logs_to_keep = 5 - file_handler = RotatingFileHandler('/tmp/openoversight.log', 'a', - max_log_size, num_logs_to_keep) + file_handler = RotatingFileHandler( + "/tmp/openoversight.log", "a", max_log_size, num_logs_to_keep + ) file_handler.setFormatter( logging.Formatter( - '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' + "%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]" ) ) app.logger.setLevel(logging.INFO) file_handler.setLevel(logging.INFO) app.logger.addHandler(file_handler) - app.logger.info('OpenOversight startup') + app.logger.info("OpenOversight startup") # Also log when endpoints are getting hit hard limiter.logger.addHandler(file_handler) @app.errorhandler(404) def page_not_found(e): - return render_template('404.html'), 404 + return render_template("404.html"), 404 @app.errorhandler(403) def forbidden(e): - return render_template('403.html'), 403 + return render_template("403.html"), 403 @app.errorhandler(500) def internal_error(e): - return render_template('500.html'), 500 + return render_template("500.html"), 500 @app.errorhandler(429) def rate_exceeded(e): - return render_template('429.html'), 429 + return render_template("429.html"), 429 # create jinja2 filter for titles with multiple capitals - @app.template_filter('capfirst') + @app.template_filter("capfirst") def capfirst_filter(s): return s[0].capitalize() + s[1:] # only change 1st letter - @app.template_filter('get_age') + @app.template_filter("get_age") def get_age_from_birth_year(birth_year): if birth_year: return int(datetime.datetime.now().year - birth_year) - @app.template_filter('currently_on_force') + @app.template_filter("currently_on_force") def officer_currently_on_force(assignments): if not assignments: return "Uncertain" most_recent = max(assignments, key=lambda x: x.star_date or datetime.date.min) return "Yes" if most_recent.resign_date is None else "No" - @app.template_filter('markdown') + @app.template_filter("markdown") def markdown(text): html = bleach.clean(_markdown.markdown(text), markdown_tags, markdown_attrs) return Markup(html) # Add commands - Migrate(app, db, os.path.join(os.path.dirname(__file__), '..', 'migrations')) # Adds 'db' command - from .commands import (make_admin_user, link_images_to_department, - link_officers_to_department, bulk_add_officers, - add_department, add_job_title, advanced_csv_import) + Migrate( + app, db, os.path.join(os.path.dirname(__file__), "..", "migrations") + ) # Adds 'db' command + from .commands import ( + add_department, + add_job_title, + advanced_csv_import, + bulk_add_officers, + link_images_to_department, + link_officers_to_department, + make_admin_user, + ) + app.cli.add_command(make_admin_user) app.cli.add_command(link_images_to_department) app.cli.add_command(link_officers_to_department) diff --git a/OpenOversight/app/auth/__init__.py b/OpenOversight/app/auth/__init__.py index 888425d78..09585ca9a 100644 --- a/OpenOversight/app/auth/__init__.py +++ b/OpenOversight/app/auth/__init__.py @@ -1,5 +1,6 @@ from flask import Blueprint -auth = Blueprint('auth', __name__) # noqa -from . import views # noqa +auth = Blueprint("auth", __name__) + +from . import views # noqa: E402,F401 diff --git a/OpenOversight/app/auth/forms.py b/OpenOversight/app/auth/forms.py index 8924efb99..ac2ed7d85 100644 --- a/OpenOversight/app/auth/forms.py +++ b/OpenOversight/app/auth/forms.py @@ -1,105 +1,146 @@ from flask_wtf import FlaskForm as Form -from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms import ( + BooleanField, + PasswordField, + StringField, + SubmitField, + ValidationError, +) from wtforms.ext.sqlalchemy.fields import QuerySelectField -from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo, Optional -from wtforms import ValidationError +from wtforms.validators import DataRequired, Email, EqualTo, Length, Optional, Regexp from ..models import User from ..utils import dept_choices class LoginForm(Form): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) - password = PasswordField('Password', validators=[DataRequired()]) - remember_me = BooleanField('Keep me logged in') - submit = SubmitField('Log In') + email = StringField("Email", validators=[DataRequired(), Length(1, 64), Email()]) + password = PasswordField("Password", validators=[DataRequired()]) + remember_me = BooleanField("Keep me logged in") + submit = SubmitField("Log In") class RegistrationForm(Form): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) - username = StringField('Username', validators=[ - DataRequired(), Length(6, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, - 'Usernames must have only letters, ' - 'numbers, dots or underscores')]) - password = PasswordField('Password', validators=[ - DataRequired(), Length(8, 64), - EqualTo('password2', message='Passwords must match.')]) - password2 = PasswordField('Confirm password', validators=[DataRequired()]) - submit = SubmitField('Register') + email = StringField("Email", validators=[DataRequired(), Length(1, 64), Email()]) + username = StringField( + "Username", + validators=[ + DataRequired(), + Length(6, 64), + Regexp( + "^[A-Za-z][A-Za-z0-9_.]*$", + 0, + "Usernames must have only letters, " "numbers, dots or underscores", + ), + ], + ) + password = PasswordField( + "Password", + validators=[ + DataRequired(), + Length(8, 64), + EqualTo("password2", message="Passwords must match."), + ], + ) + password2 = PasswordField("Confirm password", validators=[DataRequired()]) + submit = SubmitField("Register") def validate_email(self, field): if User.query.filter_by(email=field.data).first(): - raise ValidationError('Email already registered.') + raise ValidationError("Email already registered.") def validate_username(self, field): if User.query.filter_by(username=field.data).first(): - raise ValidationError('Username already in use.') + raise ValidationError("Username already in use.") class ChangePasswordForm(Form): - old_password = PasswordField('Old password', validators=[DataRequired()]) - password = PasswordField('New password', validators=[ - DataRequired(), Length(8, 64), - EqualTo('password2', message='Passwords must match')]) - password2 = PasswordField('Confirm new password', validators=[DataRequired()]) - submit = SubmitField('Update Password') + old_password = PasswordField("Old password", validators=[DataRequired()]) + password = PasswordField( + "New password", + validators=[ + DataRequired(), + Length(8, 64), + EqualTo("password2", message="Passwords must match"), + ], + ) + password2 = PasswordField("Confirm new password", validators=[DataRequired()]) + submit = SubmitField("Update Password") class PasswordResetRequestForm(Form): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) - submit = SubmitField('Reset Password') + email = StringField("Email", validators=[DataRequired(), Length(1, 64), Email()]) + submit = SubmitField("Reset Password") class PasswordResetForm(Form): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) - password = PasswordField('New Password', validators=[ - DataRequired(), EqualTo('password2', message='Passwords must match')]) - password2 = PasswordField('Confirm password', validators=[DataRequired()]) - submit = SubmitField('Reset Password') + email = StringField("Email", validators=[DataRequired(), Length(1, 64), Email()]) + password = PasswordField( + "New Password", + validators=[ + DataRequired(), + EqualTo("password2", message="Passwords must match"), + ], + ) + password2 = PasswordField("Confirm password", validators=[DataRequired()]) + submit = SubmitField("Reset Password") def validate_email(self, field): if User.query.filter_by(email=field.data).first() is None: - raise ValidationError('Unknown email address.') + raise ValidationError("Unknown email address.") class ChangeEmailForm(Form): - email = StringField('New Email', validators=[DataRequired(), Length(1, 64), - Email()]) - password = PasswordField('Password', validators=[DataRequired()]) - submit = SubmitField('Update Email Address') + email = StringField( + "New Email", validators=[DataRequired(), Length(1, 64), Email()] + ) + password = PasswordField("Password", validators=[DataRequired()]) + submit = SubmitField("Update Email Address") def validate_email(self, field): if User.query.filter_by(email=field.data).first(): - raise ValidationError('Email already registered.') + raise ValidationError("Email already registered.") class ChangeDefaultDepartmentForm(Form): - dept_pref = QuerySelectField('Default Department (Optional)', validators=[Optional()], - query_factory=dept_choices, get_label='name', allow_blank=True) - submit = SubmitField('Update Default') + dept_pref = QuerySelectField( + "Default Department (Optional)", + validators=[Optional()], + query_factory=dept_choices, + get_label="name", + allow_blank=True, + ) + submit = SubmitField("Update Default") class EditUserForm(Form): - is_area_coordinator = BooleanField('Is area coordinator?', false_values={'False', 'false', ''}) - ac_department = QuerySelectField('Department', validators=[Optional()], - query_factory=dept_choices, get_label='name', allow_blank=True) - is_administrator = BooleanField('Is administrator?', false_values={'False', 'false', ''}) - is_disabled = BooleanField('Disabled?', false_values={'False', 'false', ''}) - approved = BooleanField('Approved?', false_values={'False', 'false', ''}) - confirmed = BooleanField('Confirmed?', false_values={'False,', 'false', ''}) - submit = SubmitField(label='Update', false_values={'False', 'false', ''}) - resend = SubmitField(label='Resend', false_values={'False', 'false', ''}) - delete = SubmitField(label='Delete', false_values={'False', 'false', ''}) + is_area_coordinator = BooleanField( + "Is area coordinator?", false_values={"False", "false", ""} + ) + ac_department = QuerySelectField( + "Department", + validators=[Optional()], + query_factory=dept_choices, + get_label="name", + allow_blank=True, + ) + is_administrator = BooleanField( + "Is administrator?", false_values={"False", "false", ""} + ) + is_disabled = BooleanField("Disabled?", false_values={"False", "false", ""}) + approved = BooleanField("Approved?", false_values={"False", "false", ""}) + confirmed = BooleanField("Confirmed?", false_values={"False,", "false", ""}) + submit = SubmitField(label="Update", false_values={"False", "false", ""}) + resend = SubmitField(label="Resend", false_values={"False", "false", ""}) + delete = SubmitField(label="Delete", false_values={"False", "false", ""}) def validate(self): success = super(EditUserForm, self).validate() if self.is_area_coordinator.data and not self.ac_department.data: self.is_area_coordinator.errors = list(self.is_area_coordinator.errors) - self.is_area_coordinator.errors.append('Area coordinators must have a department') + self.is_area_coordinator.errors.append( + "Area coordinators must have a department" + ) success = False return success diff --git a/OpenOversight/app/auth/utils.py b/OpenOversight/app/auth/utils.py index 961f8123b..8008579e1 100644 --- a/OpenOversight/app/auth/utils.py +++ b/OpenOversight/app/auth/utils.py @@ -1,4 +1,5 @@ from functools import wraps + from flask import abort from flask_login import current_user @@ -9,16 +10,18 @@ def decorated_function(*args, **kwargs): if current_user.is_anonymous or not current_user.is_administrator: abort(403) return f(*args, **kwargs) + return decorated_function def ac_or_admin_required(f): """Decorate that requires that the user be an area coordinator or administrator""" + @wraps(f) def decorated_function(*args, **kwargs): - if current_user.is_anonymous or \ - not (current_user.is_administrator or - current_user.is_area_coordinator): + if current_user.is_anonymous or not ( + current_user.is_administrator or current_user.is_area_coordinator + ): abort(403) return f(*args, **kwargs) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 85b779160..560147f2b 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -1,15 +1,23 @@ -from flask import render_template, redirect, request, url_for, flash, current_app -from flask_login import login_user, logout_user, login_required, \ - current_user -from . import auth +from flask import current_app, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required, login_user, logout_user + from .. import sitemap -from ..models import User, db from ..email import send_email -from .forms import LoginForm, RegistrationForm, ChangePasswordForm,\ - PasswordResetRequestForm, PasswordResetForm, ChangeEmailForm, ChangeDefaultDepartmentForm, \ - EditUserForm -from .utils import admin_required +from ..models import User, db from ..utils import set_dynamic_default +from . import auth +from .forms import ( + ChangeDefaultDepartmentForm, + ChangeEmailForm, + ChangePasswordForm, + EditUserForm, + LoginForm, + PasswordResetForm, + PasswordResetRequestForm, + RegistrationForm, +) +from .utils import admin_required + sitemap_endpoints = [] @@ -22,168 +30,199 @@ def sitemap_include(view): @sitemap.register_generator def static_routes(): for endpoint in sitemap_endpoints: - yield 'auth.' + endpoint, {} + yield "auth." + endpoint, {} @auth.before_app_request def before_request(): - if current_user.is_authenticated \ - and not current_user.confirmed \ - and request.endpoint \ - and request.endpoint[:5] != 'auth.' \ - and request.endpoint != 'static': - return redirect(url_for('auth.unconfirmed')) + if ( + current_user.is_authenticated + and not current_user.confirmed + and request.endpoint + and request.endpoint[:5] != "auth." + and request.endpoint != "static" + ): + return redirect(url_for("auth.unconfirmed")) -@auth.route('/unconfirmed') +@auth.route("/unconfirmed") def unconfirmed(): if current_user.is_anonymous or current_user.confirmed: - return redirect(url_for('main.index')) - if current_app.config['APPROVE_REGISTRATIONS']: - return render_template('auth/unapproved.html') + return redirect(url_for("main.index")) + if current_app.config["APPROVE_REGISTRATIONS"]: + return render_template("auth/unapproved.html") else: - return render_template('auth/unconfirmed.html') + return render_template("auth/unconfirmed.html") @sitemap_include -@auth.route('/login', methods=['GET', 'POST']) +@auth.route("/login", methods=["GET", "POST"]) def login(): form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() if user is not None and user.verify_password(form.password.data): login_user(user, form.remember_me.data) - return redirect(request.args.get('next') or url_for('main.index')) - flash('Invalid username or password.') + return redirect(request.args.get("next") or url_for("main.index")) + flash("Invalid username or password.") else: current_app.logger.info(form.errors) - return render_template('auth/login.html', form=form) + return render_template("auth/login.html", form=form) -@auth.route('/logout') +@auth.route("/logout") @login_required def logout(): logout_user() - flash('You have been logged out.') - return redirect(url_for('main.index')) + flash("You have been logged out.") + return redirect(url_for("main.index")) @sitemap_include -@auth.route('/register', methods=['GET', 'POST']) +@auth.route("/register", methods=["GET", "POST"]) def register(): - jsloads = ['js/zxcvbn.js', 'js/password.js'] + jsloads = ["js/zxcvbn.js", "js/password.js"] form = RegistrationForm() if form.validate_on_submit(): - user = User(email=form.email.data, - username=form.username.data, - password=form.password.data, - approved=False if current_app.config['APPROVE_REGISTRATIONS'] else True) + user = User( + email=form.email.data, + username=form.username.data, + password=form.password.data, + approved=False if current_app.config["APPROVE_REGISTRATIONS"] else True, + ) db.session.add(user) db.session.commit() - if current_app.config['APPROVE_REGISTRATIONS']: + if current_app.config["APPROVE_REGISTRATIONS"]: admins = User.query.filter_by(is_administrator=True).all() for admin in admins: - send_email(admin.email, 'New user registered', - 'auth/email/new_registration', user=user, admin=admin) - flash('Once an administrator approves your registration, you will ' - 'receive a confirmation email to activate your account.') + send_email( + admin.email, + "New user registered", + "auth/email/new_registration", + user=user, + admin=admin, + ) + flash( + "Once an administrator approves your registration, you will " + "receive a confirmation email to activate your account." + ) else: token = user.generate_confirmation_token() - send_email(user.email, 'Confirm Your Account', - 'auth/email/confirm', user=user, token=token) - flash('A confirmation email has been sent to you.') - return redirect(url_for('auth.login')) + send_email( + user.email, + "Confirm Your Account", + "auth/email/confirm", + user=user, + token=token, + ) + flash("A confirmation email has been sent to you.") + return redirect(url_for("auth.login")) else: current_app.logger.info(form.errors) - return render_template('auth/register.html', form=form, jsloads=jsloads) + return render_template("auth/register.html", form=form, jsloads=jsloads) -@auth.route('/confirm/', methods=['GET']) +@auth.route("/confirm/", methods=["GET"]) @login_required def confirm(token): if current_user.confirmed: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) if current_user.confirm(token): admins = User.query.filter_by(is_administrator=True).all() for admin in admins: - send_email(admin.email, 'New user confirmed', - 'auth/email/new_confirmation', user=current_user, admin=admin) - flash('You have confirmed your account. Thanks!') + send_email( + admin.email, + "New user confirmed", + "auth/email/new_confirmation", + user=current_user, + admin=admin, + ) + flash("You have confirmed your account. Thanks!") else: - flash('The confirmation link is invalid or has expired.') - return redirect(url_for('main.index')) + flash("The confirmation link is invalid or has expired.") + return redirect(url_for("main.index")) -@auth.route('/confirm') +@auth.route("/confirm") @login_required def resend_confirmation(): token = current_user.generate_confirmation_token() - send_email(current_user.email, 'Confirm Your Account', - 'auth/email/confirm', user=current_user, token=token) - flash('A new confirmation email has been sent to you.') - return redirect(url_for('main.index')) - - -@auth.route('/change-password', methods=['GET', 'POST']) + send_email( + current_user.email, + "Confirm Your Account", + "auth/email/confirm", + user=current_user, + token=token, + ) + flash("A new confirmation email has been sent to you.") + return redirect(url_for("main.index")) + + +@auth.route("/change-password", methods=["GET", "POST"]) @login_required def change_password(): - jsloads = ['js/zxcvbn.js', 'js/password.js'] + jsloads = ["js/zxcvbn.js", "js/password.js"] form = ChangePasswordForm() if form.validate_on_submit(): if current_user.verify_password(form.old_password.data): current_user.password = form.password.data db.session.add(current_user) db.session.commit() - flash('Your password has been updated.') - return redirect(url_for('main.index')) + flash("Your password has been updated.") + return redirect(url_for("main.index")) else: - flash('Invalid password.') + flash("Invalid password.") else: current_app.logger.info(form.errors) return render_template("auth/change_password.html", form=form, jsloads=jsloads) -@auth.route('/reset', methods=['GET', 'POST']) +@auth.route("/reset", methods=["GET", "POST"]) def password_reset_request(): if not current_user.is_anonymous: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) form = PasswordResetRequestForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() if user: token = user.generate_reset_token() - send_email(user.email, 'Reset Your Password', - 'auth/email/reset_password', - user=user, token=token, - next=request.args.get('next')) - flash('An email with instructions to reset your password has been ' - 'sent to you.') - return redirect(url_for('auth.login')) + send_email( + user.email, + "Reset Your Password", + "auth/email/reset_password", + user=user, + token=token, + next=request.args.get("next"), + ) + flash( + "An email with instructions to reset your password has been " "sent to you." + ) + return redirect(url_for("auth.login")) else: current_app.logger.info(form.errors) - return render_template('auth/reset_password.html', form=form) + return render_template("auth/reset_password.html", form=form) -@auth.route('/reset/', methods=['GET', 'POST']) +@auth.route("/reset/", methods=["GET", "POST"]) def password_reset(token): if not current_user.is_anonymous: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) form = PasswordResetForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() if user is None: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) if user.reset_password(token, form.password.data): - flash('Your password has been updated.') - return redirect(url_for('auth.login')) + flash("Your password has been updated.") + return redirect(url_for("auth.login")) else: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) else: current_app.logger.info(form.errors) - return render_template('auth/reset_password.html', form=form) + return render_template("auth/reset_password.html", form=form) -@auth.route('/change-email', methods=['GET', 'POST']) +@auth.route("/change-email", methods=["GET", "POST"]) @login_required def change_email_request(): form = ChangeEmailForm() @@ -191,30 +230,36 @@ def change_email_request(): if current_user.verify_password(form.password.data): new_email = form.email.data token = current_user.generate_email_change_token(new_email) - send_email(new_email, 'Confirm your email address', - 'auth/email/change_email', - user=current_user, token=token) - flash('An email with instructions to confirm your new email ' - 'address has been sent to you.') - return redirect(url_for('main.index')) + send_email( + new_email, + "Confirm your email address", + "auth/email/change_email", + user=current_user, + token=token, + ) + flash( + "An email with instructions to confirm your new email " + "address has been sent to you." + ) + return redirect(url_for("main.index")) else: - flash('Invalid email or password.') + flash("Invalid email or password.") else: current_app.logger.info(form.errors) return render_template("auth/change_email.html", form=form) -@auth.route('/change-email/') +@auth.route("/change-email/") @login_required def change_email(token): if current_user.change_email(token): - flash('Your email address has been updated.') + flash("Your email address has been updated.") else: - flash('Invalid request.') - return redirect(url_for('main.index')) + flash("Invalid request.") + return redirect(url_for("main.index")) -@auth.route('/change-dept/', methods=['GET', 'POST']) +@auth.route("/change-dept/", methods=["GET", "POST"]) @login_required def change_dept(): form = ChangeDefaultDepartmentForm() @@ -227,85 +272,94 @@ def change_dept(): current_user.dept_pref = None db.session.add(current_user) db.session.commit() - flash('Updated!') - return redirect(url_for('main.index')) + flash("Updated!") + return redirect(url_for("main.index")) else: current_app.logger.info(form.errors) - return render_template('auth/change_dept_pref.html', form=form) + return render_template("auth/change_dept_pref.html", form=form) -@auth.route('/users/', methods=['GET']) +@auth.route("/users/", methods=["GET"]) @admin_required def get_users(): - if request.args.get('page'): - page = int(request.args.get('page')) + if request.args.get("page"): + page = int(request.args.get("page")) else: page = 1 - USERS_PER_PAGE = int(current_app.config['USERS_PER_PAGE']) - users = User.query.order_by(User.username) \ - .paginate(page, USERS_PER_PAGE, False) + USERS_PER_PAGE = int(current_app.config["USERS_PER_PAGE"]) + users = User.query.order_by(User.username).paginate(page, USERS_PER_PAGE, False) - return render_template('auth/users.html', objects=users) + return render_template("auth/users.html", objects=users) -@auth.route('/users/', methods=['GET', 'POST']) +@auth.route("/users/", methods=["GET", "POST"]) @admin_required def edit_user(user_id): user = User.query.get(user_id) if not user: - return render_template('404.html'), 404 + return render_template("404.html"), 404 - if request.method == 'GET': + if request.method == "GET": form = EditUserForm(obj=user) - return render_template('auth/user.html', user=user, form=form) - elif request.method == 'POST': + return render_template("auth/user.html", user=user, form=form) + elif request.method == "POST": form = EditUserForm() if form.delete.data: # forward to confirm delete - return redirect(url_for('auth.delete_user', user_id=user.id)) + return redirect(url_for("auth.delete_user", user_id=user.id)) elif form.resend.data: return admin_resend_confirmation(user) elif form.submit.data: if form.validate_on_submit(): # prevent user from removing own admin rights (or disabling account) if user.id == current_user.id: - flash('You cannot edit your own account!') + flash("You cannot edit your own account!") form = EditUserForm(obj=user) - return render_template('auth/user.html', user=user, form=form) - if current_app.config['APPROVE_REGISTRATIONS'] and form.approved.data and not user.approved and not user.confirmed: + return render_template("auth/user.html", user=user, form=form) + if ( + current_app.config["APPROVE_REGISTRATIONS"] + and form.approved.data + and not user.approved + and not user.confirmed + ): admin_resend_confirmation(user) form.populate_obj(user) db.session.add(user) db.session.commit() - flash('{} has been updated!'.format(user.username)) - return redirect(url_for('auth.edit_user', user_id=user.id)) + flash("{} has been updated!".format(user.username)) + return redirect(url_for("auth.edit_user", user_id=user.id)) else: - flash('Invalid entry') - return render_template('auth/user.html', user=user, form=form) + flash("Invalid entry") + return render_template("auth/user.html", user=user, form=form) -@auth.route('/users//delete', methods=['GET', 'POST']) +@auth.route("/users//delete", methods=["GET", "POST"]) @admin_required def delete_user(user_id): user = User.query.get(user_id) if not user or user.is_administrator: - return render_template('403.html'), 403 - if request.method == 'POST': + return render_template("403.html"), 403 + if request.method == "POST": username = user.username db.session.delete(user) db.session.commit() - flash('User {} has been deleted!'.format(username)) - return redirect(url_for('auth.get_users')) + flash("User {} has been deleted!".format(username)) + return redirect(url_for("auth.get_users")) - return render_template('auth/user_delete.html', user=user) + return render_template("auth/user_delete.html", user=user) def admin_resend_confirmation(user): if user.confirmed: - flash('User {} is already confirmed.'.format(user.username)) + flash("User {} is already confirmed.".format(user.username)) else: token = user.generate_confirmation_token() - send_email(user.email, 'Confirm Your Account', - 'auth/email/confirm', user=user, token=token) - flash('A new confirmation email has been sent to {}.'.format(user.email)) - return redirect(url_for('auth.get_users')) + send_email( + user.email, + "Confirm Your Account", + "auth/email/confirm", + user=user, + token=token, + ) + flash("A new confirmation email has been sent to {}.".format(user.email)) + return redirect(url_for("auth.get_users")) diff --git a/OpenOversight/app/commands.py b/OpenOversight/app/commands.py index 52c50f92e..230d5f9ce 100644 --- a/OpenOversight/app/commands.py +++ b/OpenOversight/app/commands.py @@ -3,25 +3,24 @@ import csv import sys from builtins import input -from datetime import datetime, date -from dateutil.parser import parse +from datetime import date, datetime from getpass import getpass from typing import Dict, List import click +from dateutil.parser import parse from flask import current_app from flask.cli import with_appcontext -from .models import db, Assignment, Department, Officer, User, Salary, Job -from .utils import get_officer, str_is_true, normalize_gender, prompt_yes_no - from .csv_imports import import_csv_files +from .models import Assignment, Department, Job, Officer, Salary, User, db +from .utils import get_officer, normalize_gender, prompt_yes_no, str_is_true @click.command() @with_appcontext def make_admin_user(): - "Add confirmed administrator account" + """Add confirmed administrator account""" while True: username = input("Username: ") user = User.query.filter_by(username=username).one_or_none() @@ -46,13 +45,19 @@ def make_admin_user(): break print("Passwords did not match") - u = User(username=username, email=email, password=password, - confirmed=True, is_administrator=True) + u = User( + username=username, + email=email, + password=password, + confirmed=True, + is_administrator=True, + ) db.session.add(u) db.session.commit() print("Administrator {} successfully added".format(username)) - current_app.logger.info('Administrator {} added with email {}'.format(username, - email)) + current_app.logger.info( + "Administrator {} added with email {}".format(username, email) + ) @click.command() @@ -60,6 +65,7 @@ def make_admin_user(): def link_images_to_department(): """Link existing images to first department""" from app.models import Image, db + images = Image.query.all() print("Linking images to first department:") for image in images: @@ -111,20 +117,22 @@ def log_new_officer(cls, officer): @classmethod def print_create_logs(cls): officers = Officer.query.filter( - Officer.id.in_(cls.created_officers.keys())).all() + Officer.id.in_(cls.created_officers.keys()) + ).all() for officer in officers: - print('Created officer {}'.format(officer)) + print("Created officer {}".format(officer)) for msg in cls.created_officers[officer.id]: - print(' --->', msg) + print(" --->", msg) @classmethod def print_update_logs(cls): officers = Officer.query.filter( - Officer.id.in_(cls.updated_officers.keys())).all() + Officer.id.in_(cls.updated_officers.keys()) + ).all() for officer in officers: - print('Updates to officer {}:'.format(officer)) + print("Updates to officer {}:".format(officer)) for msg in cls.updated_officers[officer.id]: - print(' --->', msg) + print(" --->", msg) @classmethod def print_logs(cls): @@ -154,10 +162,10 @@ def set_field_from_row(row, obj, attribute, allow_blank=True, fieldname=None): fieldname = fieldname or attribute if fieldname in row and (row[fieldname] or allow_blank): try: - val = datetime.strptime(row[fieldname], '%Y-%m-%d').date() + val = datetime.strptime(row[fieldname], "%Y-%m-%d").date() except ValueError: val = row[fieldname] - if attribute == 'gender': + if attribute == "gender": val = normalize_gender(val) setattr(obj, attribute, val) @@ -167,35 +175,37 @@ def update_officer_field(fieldname): if fieldname not in row: return - if fieldname == 'gender': + if fieldname == "gender": row[fieldname] = normalize_gender(row[fieldname]) if row[fieldname] and getattr(officer, fieldname) != row[fieldname]: ImportLog.log_change( officer, - 'Updated {}: {} --> {}'.format( - fieldname, getattr(officer, fieldname), row[fieldname])) + "Updated {}: {} --> {}".format( + fieldname, getattr(officer, fieldname), row[fieldname] + ), + ) setattr(officer, fieldname, row[fieldname]) # Name and gender are the only potentially changeable fields, so update those - update_officer_field('last_name') - update_officer_field('first_name') - update_officer_field('middle_initial') + update_officer_field("last_name") + update_officer_field("first_name") + update_officer_field("middle_initial") - update_officer_field('suffix') - update_officer_field('gender') + update_officer_field("suffix") + update_officer_field("gender") # The rest should be static static_fields = [ - 'unique_internal_identifier', - 'race', - 'employment_date', - 'birth_year' + "unique_internal_identifier", + "race", + "employment_date", + "birth_year", ] for fieldname in static_fields: if fieldname in row: - if row[fieldname] == '': + if row[fieldname] == "": row[fieldname] = None old_value = getattr(officer, fieldname) # If we're expecting a date type, attempt to parse row[fieldname] as a datetime @@ -211,7 +221,7 @@ def update_officer_field(fieldname): row[fieldname], officer.first_name, officer.last_name, - e + e, ) raise Exception(msg) else: @@ -219,12 +229,12 @@ def update_officer_field(fieldname): if old_value is None: update_officer_field(fieldname) elif str(old_value) != str(new_value): - msg = 'Officer {} {} has differing {} field. Old: {}, new: {}'.format( + msg = "Officer {} {} has differing {} field. Old: {}, new: {}".format( officer.first_name, officer.last_name, fieldname, old_value, - new_value + new_value, ) if update_static_fields: print(msg) @@ -240,15 +250,15 @@ def create_officer_from_row(row, department_id): officer = Officer() officer.department_id = department_id - set_field_from_row(row, officer, 'last_name', allow_blank=False) - set_field_from_row(row, officer, 'first_name', allow_blank=False) - set_field_from_row(row, officer, 'middle_initial') - set_field_from_row(row, officer, 'suffix') - set_field_from_row(row, officer, 'race') - set_field_from_row(row, officer, 'gender') - set_field_from_row(row, officer, 'employment_date', allow_blank=False) - set_field_from_row(row, officer, 'birth_year') - set_field_from_row(row, officer, 'unique_internal_identifier') + set_field_from_row(row, officer, "last_name", allow_blank=False) + set_field_from_row(row, officer, "first_name", allow_blank=False) + set_field_from_row(row, officer, "middle_initial") + set_field_from_row(row, officer, "suffix") + set_field_from_row(row, officer, "race") + set_field_from_row(row, officer, "gender") + set_field_from_row(row, officer, "employment_date", allow_blank=False) + set_field_from_row(row, officer, "birth_year") + set_field_from_row(row, officer, "unique_internal_identifier") db.session.add(officer) db.session.flush() @@ -259,7 +269,7 @@ def create_officer_from_row(row, department_id): def is_equal(a, b): - """exhaustive equality checking, originally to compare a sqlalchemy result object of various types to a csv string + """Run an exhaustive equality check, originally to compare a sqlalchemy result object of various types to a csv string Note: Stringifying covers object cases (as in the datetime example below) >>> is_equal("1", 1) # string == int True @@ -272,6 +282,7 @@ def is_equal(a, b): >>> is_equal(datetime(2020, 1, 1), "2020-01-01 00:00:00") # datetime == string True """ + def try_else_false(comparable): try: return comparable(a, b) @@ -280,55 +291,68 @@ def try_else_false(comparable): except ValueError: return False - return any([ - try_else_false(lambda _a, _b: str(_a) == str(_b)), - try_else_false(lambda _a, _b: int(_a) == int(_b)), - try_else_false(lambda _a, _b: float(_a) == float(_b)) - ]) + return any( + [ + try_else_false(lambda _a, _b: str(_a) == str(_b)), + try_else_false(lambda _a, _b: int(_a) == int(_b)), + try_else_false(lambda _a, _b: float(_a) == float(_b)), + ] + ) def process_assignment(row, officer, compare=False): assignment_fields = { - 'required': [], - 'optional': [ - 'job_title', - 'star_no', - 'unit_id', - 'star_date', - 'resign_date'] + "required": [], + "optional": ["job_title", "star_no", "unit_id", "star_date", "resign_date"], } # See if the row has assignment data - if row_has_data(row, assignment_fields['required'], assignment_fields['optional']): + if row_has_data(row, assignment_fields["required"], assignment_fields["optional"]): add_assignment = True if compare: # Get existing assignments for officer and compare to row data - assignments = db.session.query(Assignment, Job)\ - .filter(Assignment.job_id == Job.id)\ - .filter_by(officer_id=officer.id)\ - .all() + assignments = ( + db.session.query(Assignment, Job) + .filter(Assignment.job_id == Job.id) + .filter_by(officer_id=officer.id) + .all() + ) for (assignment, job) in assignments: - assignment_fieldnames = ['star_no', 'unit_id', 'star_date', 'resign_date'] + assignment_fieldnames = [ + "star_no", + "unit_id", + "star_date", + "resign_date", + ] i = 0 for fieldname in assignment_fieldnames: current = getattr(assignment, fieldname) # Test if fields match between row and existing assignment - if (current and fieldname in row and is_equal(row[fieldname], current)) or \ - (not current and (fieldname not in row or not row[fieldname])): + if ( + current + and fieldname in row + and is_equal(row[fieldname], current) + ) or (not current and (fieldname not in row or not row[fieldname])): i += 1 if i == len(assignment_fieldnames): job_title = job.job_title - if (job_title and row.get('job_title', 'Not Sure') == job_title) or \ - (not job_title and ('job_title' not in row or not row['job_title'])): + if ( + job_title and row.get("job_title", "Not Sure") == job_title + ) or ( + not job_title + and ("job_title" not in row or not row["job_title"]) + ): # Found match, so don't add new assignment add_assignment = False if add_assignment: - job = Job.query\ - .filter_by(job_title=row.get('job_title', 'Not Sure'), - department_id=officer.department_id)\ - .one_or_none() + job = Job.query.filter_by( + job_title=row.get("job_title", "Not Sure"), + department_id=officer.department_id, + ).one_or_none() if not job: - num_existing_ranks = len(Job.query.filter_by(department_id=officer.department_id).all()) + num_existing_ranks = len( + Job.query.filter_by(department_id=officer.department_id).all() + ) if num_existing_ranks > 0: auto_order = num_existing_ranks + 1 else: @@ -337,37 +361,34 @@ def process_assignment(row, officer, compare=False): job = Job( is_sworn_officer=False, department_id=officer.department_id, - order=auto_order + order=auto_order, ) - set_field_from_row(row, job, 'job_title', allow_blank=False) + set_field_from_row(row, job, "job_title", allow_blank=False) db.session.add(job) db.session.flush() # create new assignment assignment = Assignment() assignment.officer_id = officer.id assignment.job_id = job.id - set_field_from_row(row, assignment, 'star_no') - set_field_from_row(row, assignment, 'unit_id') - set_field_from_row(row, assignment, 'star_date', allow_blank=False) - set_field_from_row(row, assignment, 'resign_date', allow_blank=False) + set_field_from_row(row, assignment, "star_no") + set_field_from_row(row, assignment, "unit_id") + set_field_from_row(row, assignment, "star_date", allow_blank=False) + set_field_from_row(row, assignment, "resign_date", allow_blank=False) db.session.add(assignment) db.session.flush() - ImportLog.log_change(officer, 'Added assignment: {}'.format(assignment)) + ImportLog.log_change(officer, "Added assignment: {}".format(assignment)) def process_salary(row, officer, compare=False): salary_fields = { - 'required': [ - 'salary', - 'salary_year', - 'salary_is_fiscal_year'], - 'optional': ['overtime_pay'] + "required": ["salary", "salary_year", "salary_is_fiscal_year"], + "optional": ["overtime_pay"], } # See if the row has salary data - if row_has_data(row, salary_fields['required'], salary_fields['optional']): - is_fiscal_year = str_is_true(row['salary_is_fiscal_year']) + if row_has_data(row, salary_fields["required"], salary_fields["optional"]): + is_fiscal_year = str_is_true(row["salary_is_fiscal_year"]) add_salary = True if compare: @@ -375,14 +396,27 @@ def process_salary(row, officer, compare=False): salaries = Salary.query.filter_by(officer_id=officer.id).all() for salary in salaries: from decimal import Decimal + print(vars(salary)) print(row) - if Decimal('%.2f' % salary.salary) == Decimal('%.2f' % float(row['salary'])) and \ - salary.year == int(row['salary_year']) and \ - salary.is_fiscal_year == is_fiscal_year and \ - ((salary.overtime_pay and 'overtime_pay' in row and - Decimal('%.2f' % salary.overtime_pay) == Decimal('%.2f' % float(row['overtime_pay']))) or - (not salary.overtime_pay and ('overtime_pay' not in row or not row['overtime_pay']))): + if ( + Decimal("%.2f" % salary.salary) + == Decimal("%.2f" % float(row["salary"])) + and salary.year == int(row["salary_year"]) + and salary.is_fiscal_year == is_fiscal_year + and ( + ( + salary.overtime_pay + and "overtime_pay" in row + and Decimal("%.2f" % salary.overtime_pay) + == Decimal("%.2f" % float(row["overtime_pay"])) + ) + or ( + not salary.overtime_pay + and ("overtime_pay" not in row or not row["overtime_pay"]) + ) + ) + ): # Found match, so don't add new salary add_salary = False @@ -390,82 +424,106 @@ def process_salary(row, officer, compare=False): # create new salary salary = Salary( officer_id=officer.id, - salary=float(row['salary']), - year=int(row['salary_year']), + salary=float(row["salary"]), + year=int(row["salary_year"]), is_fiscal_year=is_fiscal_year, ) - if 'overtime_pay' in row and row['overtime_pay']: - salary.overtime_pay = float(row['overtime_pay']) + if "overtime_pay" in row and row["overtime_pay"]: + salary.overtime_pay = float(row["overtime_pay"]) db.session.add(salary) db.session.flush() - ImportLog.log_change(officer, 'Added salary: {}'.format(salary)) + ImportLog.log_change(officer, "Added salary: {}".format(salary)) @click.command() -@click.argument('filename') -@click.option('--no-create', is_flag=True, help='only update officers; do not create new ones') -@click.option('--update-by-name', is_flag=True, help='update officers by first and last name (useful when star_no or unique_internal_identifier are not available)') -@click.option('--update-static-fields', is_flag=True, help='allow updating normally-static fields like race, birth year, etc.') +@click.argument("filename") +@click.option( + "--no-create", is_flag=True, help="only update officers; do not create new ones" +) +@click.option( + "--update-by-name", + is_flag=True, + help="update officers by first and last name (useful when star_no or unique_internal_identifier are not available)", +) +@click.option( + "--update-static-fields", + is_flag=True, + help="allow updating normally-static fields like race, birth year, etc.", +) @with_appcontext def bulk_add_officers(filename, no_create, update_by_name, update_static_fields): """Add or update officers from a CSV file.""" - encoding = 'utf-8' + encoding = "utf-8" # handles unicode errors that can occur when the file was made in Excel - with open(filename, 'r') as f: - if u'\ufeff' in f.readline(): - encoding = 'utf-8-sig' + with open(filename, "r") as f: + if "\ufeff" in f.readline(): + encoding = "utf-8-sig" - with open(filename, 'r', encoding=encoding) as f: + with open(filename, "r", encoding=encoding) as f: ImportLog.clear_logs() csvfile = csv.DictReader(f) departments = {} required_fields = [ - 'department_id', - 'first_name', - 'last_name', + "department_id", + "first_name", + "last_name", ] # Assert required fields are in CSV file for field in required_fields: if field not in csvfile.fieldnames: - raise Exception('Missing required field {}'.format(field)) - if (not update_by_name - and 'star_no' not in csvfile.fieldnames - and 'unique_internal_identifier' not in csvfile.fieldnames): - raise Exception('CSV file must include either badge numbers or unique identifiers for officers') + raise Exception("Missing required field {}".format(field)) + if ( + not update_by_name + and "star_no" not in csvfile.fieldnames + and "unique_internal_identifier" not in csvfile.fieldnames + ): + raise Exception( + "CSV file must include either badge numbers or unique identifiers for officers" + ) for row in csvfile: - department_id = row['department_id'] + department_id = row["department_id"] department = departments.get(department_id) - if row['department_id'] not in departments: + if row["department_id"] not in departments: department = Department.query.filter_by(id=department_id).one_or_none() if department: departments[department_id] = department else: - raise Exception('Department ID {} not found'.format(department_id)) + raise Exception("Department ID {} not found".format(department_id)) if not update_by_name: # check for existing officer based on unique ID or name/badge - if 'unique_internal_identifier' in csvfile.fieldnames and row['unique_internal_identifier']: + if ( + "unique_internal_identifier" in csvfile.fieldnames + and row["unique_internal_identifier"] + ): officer = Officer.query.filter_by( department_id=department_id, - unique_internal_identifier=row['unique_internal_identifier'] + unique_internal_identifier=row["unique_internal_identifier"], ).one_or_none() - elif 'star_no' in csvfile.fieldnames and row['star_no']: - officer = get_officer(department_id, row['star_no'], - row['first_name'], row['last_name']) + elif "star_no" in csvfile.fieldnames and row["star_no"]: + officer = get_officer( + department_id, + row["star_no"], + row["first_name"], + row["last_name"], + ) else: - raise Exception('Officer {} {} missing badge number and unique identifier'.format(row['first_name'], - row['last_name'])) + raise Exception( + "Officer {} {} missing badge number and unique identifier".format( + row["first_name"], row["last_name"] + ) + ) else: officer = Officer.query.filter_by( department_id=department_id, - last_name=row['last_name'], - first_name=row['first_name'] + last_name=row["last_name"], + first_name=row["first_name"], ).one_or_none() if officer: @@ -474,7 +532,9 @@ def bulk_add_officers(filename, no_create, update_by_name, update_static_fields) create_officer_from_row(row, department_id) ImportLog.print_logs() - if current_app.config['ENV'] == 'testing' or prompt_yes_no("Do you want to commit the above changes?"): + if current_app.config["ENV"] == "testing" or prompt_yes_no( + "Do you want to commit the above changes?" + ): print("Commiting changes.") db.session.commit() else: @@ -526,34 +586,45 @@ def advanced_csv_import( links_csv, incidents_csv, force_create, - overwrite_assignments + overwrite_assignments, ) @click.command() -@click.argument('name') -@click.argument('short_name') -@click.argument('unique_internal_identifier', required=False) +@click.argument("name") +@click.argument("short_name") +@click.argument("unique_internal_identifier", required=False) @with_appcontext def add_department(name, short_name, unique_internal_identifier): """Add a new department to OpenOversight.""" - dept = Department(name=name, short_name=short_name, unique_internal_identifier_label=unique_internal_identifier) + dept = Department( + name=name, + short_name=short_name, + unique_internal_identifier_label=unique_internal_identifier, + ) db.session.add(dept) db.session.commit() print("Department added with id {}".format(dept.id)) @click.command() -@click.argument('department_id') -@click.argument('job_title') -@click.argument('is_sworn_officer', type=click.Choice(["true", "false"], case_sensitive=False)) -@click.argument('order', type=int) +@click.argument("department_id") +@click.argument("job_title") +@click.argument( + "is_sworn_officer", type=click.Choice(["true", "false"], case_sensitive=False) +) +@click.argument("order", type=int) @with_appcontext def add_job_title(department_id, job_title, is_sworn_officer, order): """Add a rank to a department.""" department = Department.query.filter_by(id=department_id).one_or_none() - is_sworn = (is_sworn_officer == "true") - job = Job(job_title=job_title, is_sworn_officer=is_sworn, order=order, department=department) + is_sworn = is_sworn_officer == "true" + job = Job( + job_title=job_title, + is_sworn_officer=is_sworn, + order=order, + department=department, + ) db.session.add(job) - print('Added {} to {}'.format(job.job_title, department.name)) + print("Added {} to {}".format(job.job_title, department.name)) db.session.commit() diff --git a/OpenOversight/app/config.py b/OpenOversight/app/config.py index ad62a2971..89aadff4e 100644 --- a/OpenOversight/app/config.py +++ b/OpenOversight/app/config.py @@ -1,5 +1,7 @@ import os -from dotenv import load_dotenv, find_dotenv + +from dotenv import find_dotenv, load_dotenv + load_dotenv(find_dotenv()) @@ -11,35 +13,37 @@ class BaseConfig(object): SQLALCHEMY_TRACK_MODIFICATIONS = False # pagination - OFFICERS_PER_PAGE = os.environ.get('OFFICERS_PER_PAGE', 20) - USERS_PER_PAGE = os.environ.get('USERS_PER_PAGE', 20) + OFFICERS_PER_PAGE = os.environ.get("OFFICERS_PER_PAGE", 20) + USERS_PER_PAGE = os.environ.get("USERS_PER_PAGE", 20) # Form Settings WTF_CSRF_ENABLED = True - SECRET_KEY = os.environ.get('SECRET_KEY', 'changemeplzorelsehax') + SECRET_KEY = os.environ.get("SECRET_KEY", "changemeplzorelsehax") # Mail Settings - MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com') + MAIL_SERVER = os.environ.get("MAIL_SERVER", "smtp.googlemail.com") MAIL_PORT = 587 MAIL_USE_TLS = True - MAIL_USERNAME = os.environ.get('MAIL_USERNAME') - MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') - OO_MAIL_SUBJECT_PREFIX = os.environ.get('OO_MAIL_SUBJECT_PREFIX', '[OpenOversight]') - OO_MAIL_SENDER = os.environ.get('OO_MAIL_SENDER', 'OpenOversight ') + MAIL_USERNAME = os.environ.get("MAIL_USERNAME") + MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") + OO_MAIL_SUBJECT_PREFIX = os.environ.get("OO_MAIL_SUBJECT_PREFIX", "[OpenOversight]") + OO_MAIL_SENDER = os.environ.get( + "OO_MAIL_SENDER", "OpenOversight " + ) # OO_ADMIN = os.environ.get('OO_ADMIN') # AWS Settings - AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') - AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') - AWS_DEFAULT_REGION = os.environ.get('AWS_DEFAULT_REGION') - S3_BUCKET_NAME = os.environ.get('S3_BUCKET_NAME') + AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") + AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") + AWS_DEFAULT_REGION = os.environ.get("AWS_DEFAULT_REGION") + S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME") # Upload Settings MAX_CONTENT_LENGTH = 50 * 1024 * 1024 - ALLOWED_EXTENSIONS = set(['jpeg', 'jpg', 'jpe', 'png', 'gif', 'webp']) + ALLOWED_EXTENSIONS = set(["jpeg", "jpg", "jpe", "png", "gif", "webp"]) # User settings - APPROVE_REGISTRATIONS = os.environ.get('APPROVE_REGISTRATIONS', False) + APPROVE_REGISTRATIONS = os.environ.get("APPROVE_REGISTRATIONS", False) SEED = 666 @@ -51,23 +55,23 @@ def init_app(app): class DevelopmentConfig(BaseConfig): DEBUG = True SQLALCHEMY_ECHO = True - SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') + SQLALCHEMY_DATABASE_URI = os.environ.get("SQLALCHEMY_DATABASE_URI") NUM_OFFICERS = 15000 - SITEMAP_URL_SCHEME = 'http' + SITEMAP_URL_SCHEME = "http" class TestingConfig(BaseConfig): TESTING = True - SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" WTF_CSRF_ENABLED = False NUM_OFFICERS = 120 APPROVE_REGISTRATIONS = False - SITEMAP_URL_SCHEME = 'http' + SITEMAP_URL_SCHEME = "http" class ProductionConfig(BaseConfig): - SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') - SITEMAP_URL_SCHEME = 'https' + SQLALCHEMY_DATABASE_URI = os.environ.get("SQLALCHEMY_DATABASE_URI") + SITEMAP_URL_SCHEME = "https" @classmethod def init_app(cls, app): # pragma: no cover @@ -75,8 +79,8 @@ def init_app(cls, app): # pragma: no cover config = { - 'development': DevelopmentConfig, - 'testing': TestingConfig, - 'production': ProductionConfig, + "development": DevelopmentConfig, + "testing": TestingConfig, + "production": ProductionConfig, } -config['default'] = config.get(os.environ.get('FLASK_ENV', ""), DevelopmentConfig) +config["default"] = config.get(os.environ.get("FLASK_ENV", ""), DevelopmentConfig) diff --git a/OpenOversight/app/custom.py b/OpenOversight/app/custom.py index 52c638828..3bd8d362b 100644 --- a/OpenOversight/app/custom.py +++ b/OpenOversight/app/custom.py @@ -27,7 +27,7 @@ def test_jpeg3(h, f): def add_jpeg_patch(): - """Custom JPEG identification patch. + """Add a custom JPEG identification patch. It turns out that imghdr sucks at identifying jpegs and needs a custom patch to behave correctly. This function adds that. diff --git a/OpenOversight/app/email.py b/OpenOversight/app/email.py index 5ad8445ca..cf8fc0f8f 100644 --- a/OpenOversight/app/email.py +++ b/OpenOversight/app/email.py @@ -1,6 +1,8 @@ from threading import Thread + from flask import current_app, render_template from flask_mail import Message + from . import mail @@ -11,10 +13,13 @@ def send_async_email(app, msg): def send_email(to, subject, template, **kwargs): app = current_app._get_current_object() - msg = Message(app.config['OO_MAIL_SUBJECT_PREFIX'] + ' ' + subject, - sender=app.config['OO_MAIL_SENDER'], recipients=[to]) - msg.body = render_template(template + '.txt', **kwargs) - msg.html = render_template(template + '.html', **kwargs) + msg = Message( + app.config["OO_MAIL_SUBJECT_PREFIX"] + " " + subject, + sender=app.config["OO_MAIL_SENDER"], + recipients=[to], + ) + msg.body = render_template(template + ".txt", **kwargs) + msg.html = render_template(template + ".html", **kwargs) # Only send email if we're in prod or staging, otherwise log it so devs can see it if app.env in ("staging", "production"): thr = Thread(target=send_async_email, args=[app, msg]) diff --git a/OpenOversight/app/formfields.py b/OpenOversight/app/formfields.py index 3dcc2220e..c35dbcfb7 100644 --- a/OpenOversight/app/formfields.py +++ b/OpenOversight/app/formfields.py @@ -1,27 +1,29 @@ -from wtforms.widgets.html5 import TimeInput -from wtforms import StringField import datetime +from wtforms import StringField +from wtforms.widgets.html5 import TimeInput + class TimeField(StringField): """HTML5 time input.""" + widget = TimeInput() - def __init__(self, label=None, validators=None, format='%H:%M:%S', **kwargs): + def __init__(self, label=None, validators=None, format="%H:%M:%S", **kwargs): super(TimeField, self).__init__(label, validators, **kwargs) self.format = format def _value(self): if self.raw_data: - return ' '.join(self.raw_data) + return " ".join(self.raw_data) else: - return self.data and self.data.strftime(self.format) or '' + return self.data and self.data.strftime(self.format) or "" def process_formdata(self, valuelist): - if valuelist and valuelist != [u'']: - time_str = ' '.join(valuelist) + if valuelist and valuelist != [""]: + time_str = " ".join(valuelist) try: - components = time_str.split(':') + components = time_str.split(":") hour = 0 minutes = 0 seconds = 0 @@ -36,4 +38,4 @@ def process_formdata(self, valuelist): self.data = datetime.time(hour, minutes, seconds) except ValueError: self.data = None - raise ValueError(self.gettext('Not a valid time')) + raise ValueError(self.gettext("Not a valid time")) diff --git a/OpenOversight/app/main/__init__.py b/OpenOversight/app/main/__init__.py index eb5779d0d..360c10c91 100644 --- a/OpenOversight/app/main/__init__.py +++ b/OpenOversight/app/main/__init__.py @@ -1,5 +1,6 @@ from flask import Blueprint -main = Blueprint('main', __name__) # noqa -from . import views # noqa +main = Blueprint("main", __name__) + +from . import views # noqa: E402,F401 diff --git a/OpenOversight/app/main/choices.py b/OpenOversight/app/main/choices.py index 2f159eb00..22256c19e 100644 --- a/OpenOversight/app/main/choices.py +++ b/OpenOversight/app/main/choices.py @@ -1,16 +1,39 @@ from us import states + # Choices are a list of (value, label) tuples -SUFFIX_CHOICES = [('', '-'), ('Jr', 'Jr'), ('Sr', 'Sr'), ('II', 'II'), - ('III', 'III'), ('IV', 'IV'), ('V', 'V')] -RACE_CHOICES = [('BLACK', 'Black'), ('WHITE', 'White'), ('ASIAN', 'Asian'), - ('HISPANIC', 'Hispanic'), - ('NATIVE AMERICAN', 'Native American'), - ('PACIFIC ISLANDER', 'Pacific Islander'), - ('Other', 'Other'), ('Not Sure', 'Not Sure')] +SUFFIX_CHOICES = [ + ("", "-"), + ("Jr", "Jr"), + ("Sr", "Sr"), + ("II", "II"), + ("III", "III"), + ("IV", "IV"), + ("V", "V"), +] +RACE_CHOICES = [ + ("BLACK", "Black"), + ("WHITE", "White"), + ("ASIAN", "Asian"), + ("HISPANIC", "Hispanic"), + ("NATIVE AMERICAN", "Native American"), + ("PACIFIC ISLANDER", "Pacific Islander"), + ("Other", "Other"), + ("Not Sure", "Not Sure"), +] -GENDER_CHOICES = [('Not Sure', 'Not Sure'), ('M', 'Male'), ('F', 'Female'), ('Other', 'Other')] +GENDER_CHOICES = [ + ("Not Sure", "Not Sure"), + ("M", "Male"), + ("F", "Female"), + ("Other", "Other"), +] -STATE_CHOICES = [('', '')] + [(state.abbr, state.name) for state in states.STATES] -LINK_CHOICES = [('', ''), ('link', 'Link'), ('video', 'YouTube Video'), ('other_video', 'Other Video')] +STATE_CHOICES = [("", "")] + [(state.abbr, state.name) for state in states.STATES] +LINK_CHOICES = [ + ("", ""), + ("link", "Link"), + ("video", "YouTube Video"), + ("other_video", "Other Video"), +] AGE_CHOICES = [(str(age), str(age)) for age in range(16, 101)] diff --git a/OpenOversight/app/main/downloads.py b/OpenOversight/app/main/downloads.py index 886a5a914..dadc5d502 100644 --- a/OpenOversight/app/main/downloads.py +++ b/OpenOversight/app/main/downloads.py @@ -1,21 +1,22 @@ import csv import io from datetime import date -from typing import List, TypeVar, Callable, Dict, Any +from typing import Any, Callable, Dict, List, TypeVar from flask import Response, abort from sqlalchemy.orm import Query from ..models import ( - Department, - Salary, - Officer, Assignment, + Department, + Description, Incident, Link, - Description, + Officer, + Salary, ) + T = TypeVar("T") _Record = Dict[str, Any] diff --git a/OpenOversight/app/main/forms.py b/OpenOversight/app/main/forms.py index 1e739237c..ea392e985 100644 --- a/OpenOversight/app/main/forms.py +++ b/OpenOversight/app/main/forms.py @@ -1,29 +1,55 @@ +import datetime +import re + from flask_wtf import FlaskForm as Form +from flask_wtf.file import FileAllowed, FileField, FileRequired +from wtforms import ( + BooleanField, + DecimalField, + FieldList, + FormField, + HiddenField, + IntegerField, + SelectField, + StringField, + SubmitField, + TextAreaField, +) from wtforms.ext.sqlalchemy.fields import QuerySelectField -from wtforms import (StringField, DecimalField, TextAreaField, - SelectField, IntegerField, SubmitField, - HiddenField, FormField, FieldList, BooleanField) from wtforms.fields.html5 import DateField +from wtforms.validators import ( + URL, + AnyOf, + DataRequired, + InputRequired, + Length, + NumberRange, + Optional, + Regexp, + ValidationError, +) -from wtforms.validators import (DataRequired, InputRequired, AnyOf, NumberRange, Regexp, - Length, Optional, URL, ValidationError) -from flask_wtf.file import FileField, FileAllowed, FileRequired - -from ..utils import unit_choices, dept_choices -from .choices import SUFFIX_CHOICES, GENDER_CHOICES, RACE_CHOICES, STATE_CHOICES, LINK_CHOICES, AGE_CHOICES from ..formfields import TimeField -from ..widgets import BootstrapListWidget, FormFieldWidget from ..models import Officer -import datetime -import re +from ..utils import dept_choices, unit_choices +from ..widgets import BootstrapListWidget, FormFieldWidget +from .choices import ( + AGE_CHOICES, + GENDER_CHOICES, + LINK_CHOICES, + RACE_CHOICES, + STATE_CHOICES, + SUFFIX_CHOICES, +) + # Normalizes the "not sure" option to what it needs to be when writing to the database. # Note this should only be used for forms which save a record to the DB--not those that # are used to look up existing records. db_genders = list(GENDER_CHOICES) for index, choice in enumerate(db_genders): - if choice == ('Not Sure', 'Not Sure'): - db_genders[index] = (None, 'Not Sure') # type: ignore + if choice == ("Not Sure", "Not Sure"): + db_genders[index] = (None, "Not Sure") # type: ignore def allowed_values(choices, empty_allowed=True): @@ -31,317 +57,430 @@ def allowed_values(choices, empty_allowed=True): def validate_money(form, field): - if not re.fullmatch(r'\d+(\.\d\d)?0*', str(field.data)): - raise ValidationError('Invalid monetary value') + if not re.fullmatch(r"\d+(\.\d\d)?0*", str(field.data)): + raise ValidationError("Invalid monetary value") def validate_end_date(form, field): if form.data["star_date"] and field.data: if form.data["star_date"] > field.data: - raise ValidationError('End date must come after start date.') + raise ValidationError("End date must come after start date.") class HumintContribution(Form): photo = FileField( - 'image', validators=[FileRequired(message='There was no file!'), - FileAllowed(['png', 'jpg', 'jpeg'], - message='Images only!')] + "image", + validators=[ + FileRequired(message="There was no file!"), + FileAllowed(["png", "jpg", "jpeg"], message="Images only!"), + ], ) - submit = SubmitField(label='Upload') + submit = SubmitField(label="Upload") class FindOfficerForm(Form): name = StringField( - 'name', default='', validators=[Regexp(r'\w*'), Length(max=50), - Optional()] - ) - badge = StringField('badge', default='', validators=[Regexp(r'\w*'), - Length(max=10)]) - unique_internal_identifier = StringField('unique_internal_identifier', default='', validators=[Regexp(r'\w*'), Length(max=55)]) - dept = QuerySelectField('dept', validators=[DataRequired()], - query_factory=dept_choices, get_label='name') - unit = StringField('unit', default='Not Sure', validators=[Optional()]) - rank = StringField('rank', default='Not Sure', validators=[Optional()]) # Gets rewritten by Javascript - race = SelectField('race', default='Not Sure', choices=RACE_CHOICES, - validators=[AnyOf(allowed_values(RACE_CHOICES))]) - gender = SelectField('gender', default='Not Sure', choices=GENDER_CHOICES, - validators=[AnyOf(allowed_values(GENDER_CHOICES))]) - min_age = IntegerField('min_age', default=16, validators=[ - NumberRange(min=16, max=100) - ]) - max_age = IntegerField('max_age', default=85, validators=[ - NumberRange(min=16, max=100) - ]) - latitude = DecimalField('latitude', default=False, validators=[ - NumberRange(min=-90, max=90) - ]) - longitude = DecimalField('longitude', default=False, validators=[ - NumberRange(min=-180, max=180) - ]) + "name", default="", validators=[Regexp(r"\w*"), Length(max=50), Optional()] + ) + badge = StringField( + "badge", default="", validators=[Regexp(r"\w*"), Length(max=10)] + ) + unique_internal_identifier = StringField( + "unique_internal_identifier", + default="", + validators=[Regexp(r"\w*"), Length(max=55)], + ) + dept = QuerySelectField( + "dept", + validators=[DataRequired()], + query_factory=dept_choices, + get_label="name", + ) + unit = StringField("unit", default="Not Sure", validators=[Optional()]) + rank = StringField( + "rank", default="Not Sure", validators=[Optional()] + ) # Gets rewritten by Javascript + race = SelectField( + "race", + default="Not Sure", + choices=RACE_CHOICES, + validators=[AnyOf(allowed_values(RACE_CHOICES))], + ) + gender = SelectField( + "gender", + default="Not Sure", + choices=GENDER_CHOICES, + validators=[AnyOf(allowed_values(GENDER_CHOICES))], + ) + min_age = IntegerField( + "min_age", default=16, validators=[NumberRange(min=16, max=100)] + ) + max_age = IntegerField( + "max_age", default=85, validators=[NumberRange(min=16, max=100)] + ) + latitude = DecimalField( + "latitude", default=False, validators=[NumberRange(min=-90, max=90)] + ) + longitude = DecimalField( + "longitude", default=False, validators=[NumberRange(min=-180, max=180)] + ) class FindOfficerIDForm(Form): name = StringField( - 'name', default='', validators=[ - Regexp(r'\w*'), Length(max=50), Optional() - ] + "name", default="", validators=[Regexp(r"\w*"), Length(max=50), Optional()] ) badge = StringField( - 'badge', default='', validators=[Regexp(r'\w*'), Length(max=10)] + "badge", default="", validators=[Regexp(r"\w*"), Length(max=10)] + ) + dept = QuerySelectField( + "dept", validators=[Optional()], query_factory=dept_choices, get_label="name" ) - dept = QuerySelectField('dept', validators=[Optional()], - query_factory=dept_choices, get_label='name') class FaceTag(Form): - officer_id = IntegerField('officer_id', validators=[DataRequired()]) - image_id = IntegerField('image_id', validators=[DataRequired()]) - dataX = IntegerField('dataX', validators=[InputRequired()]) - dataY = IntegerField('dataY', validators=[InputRequired()]) - dataWidth = IntegerField('dataWidth', validators=[InputRequired()]) - dataHeight = IntegerField('dataHeight', validators=[InputRequired()]) + officer_id = IntegerField("officer_id", validators=[DataRequired()]) + image_id = IntegerField("image_id", validators=[DataRequired()]) + dataX = IntegerField("dataX", validators=[InputRequired()]) + dataY = IntegerField("dataY", validators=[InputRequired()]) + dataWidth = IntegerField("dataWidth", validators=[InputRequired()]) + dataHeight = IntegerField("dataHeight", validators=[InputRequired()]) class AssignmentForm(Form): - star_no = StringField('Badge Number', default='', validators=[ - Regexp(r'\w*'), Length(max=50)]) - job_title = QuerySelectField('Job Title', validators=[DataRequired()], - get_label='job_title', get_pk=lambda x: x.id) # query set in view function - unit = QuerySelectField('Unit', validators=[Optional()], - query_factory=unit_choices, get_label='descrip', - allow_blank=True, blank_text=u'None') - star_date = DateField('Assignment start date', validators=[Optional()]) - resign_date = DateField('Assignment end date', validators=[Optional(), validate_end_date]) + star_no = StringField( + "Badge Number", default="", validators=[Regexp(r"\w*"), Length(max=50)] + ) + job_title = QuerySelectField( + "Job Title", + validators=[DataRequired()], + get_label="job_title", + get_pk=lambda x: x.id, + ) # query set in view function + unit = QuerySelectField( + "Unit", + validators=[Optional()], + query_factory=unit_choices, + get_label="descrip", + allow_blank=True, + blank_text="None", + ) + star_date = DateField("Assignment start date", validators=[Optional()]) + resign_date = DateField( + "Assignment end date", validators=[Optional(), validate_end_date] + ) class SalaryForm(Form): - salary = DecimalField('Salary', validators=[ - NumberRange(min=0, max=1000000), validate_money - ]) - overtime_pay = DecimalField('Overtime Pay', validators=[ - NumberRange(min=0, max=1000000), validate_money - ]) - year = IntegerField('Year', default=datetime.datetime.now().year, validators=[ - NumberRange(min=1900, max=2100) - ]) - is_fiscal_year = BooleanField('Is fiscal year?', default=False) + salary = DecimalField( + "Salary", validators=[NumberRange(min=0, max=1000000), validate_money] + ) + overtime_pay = DecimalField( + "Overtime Pay", validators=[NumberRange(min=0, max=1000000), validate_money] + ) + year = IntegerField( + "Year", + default=datetime.datetime.now().year, + validators=[NumberRange(min=1900, max=2100)], + ) + is_fiscal_year = BooleanField("Is fiscal year?", default=False) def validate(form, extra_validators=()): - if not form.data.get('salary') and not form.data.get('overtime_pay'): + if not form.data.get("salary") and not form.data.get("overtime_pay"): return True return super(SalaryForm, form).validate() # def process(self, *args, **kwargs): - # raise Exception(args[0]) + # raise Exception(args[0]) class DepartmentForm(Form): name = StringField( - 'Full name of law enforcement agency, e.g. Chicago Police Department', - default='', validators=[Regexp(r'\w*'), Length(max=255), DataRequired()] + "Full name of law enforcement agency, e.g. Chicago Police Department", + default="", + validators=[Regexp(r"\w*"), Length(max=255), DataRequired()], ) short_name = StringField( - 'Shortened acronym for law enforcement agency, e.g. CPD', - default='', validators=[Regexp(r'\w*'), Length(max=100), DataRequired()] + "Shortened acronym for law enforcement agency, e.g. CPD", + default="", + validators=[Regexp(r"\w*"), Length(max=100), DataRequired()], + ) + jobs = FieldList( + StringField("Job", default="", validators=[Regexp(r"\w*")]), label="Ranks" ) - jobs = FieldList(StringField('Job', default='', validators=[ - Regexp(r'\w*')]), label='Ranks') - submit = SubmitField(label='Add') + submit = SubmitField(label="Add") class EditDepartmentForm(DepartmentForm): - submit = SubmitField(label='Update') + submit = SubmitField(label="Update") class LinkForm(Form): title = StringField( - validators=[Length(max=100, message='Titles are limited to 100 characters.')], - description='Text that will be displayed as the link.') + validators=[Length(max=100, message="Titles are limited to 100 characters.")], + description="Text that will be displayed as the link.", + ) description = TextAreaField( - validators=[Length(max=600, message='Descriptions are limited to 600 characters.')], - description='A short description of the link.') + validators=[ + Length(max=600, message="Descriptions are limited to 600 characters.") + ], + description="A short description of the link.", + ) author = StringField( - validators=[Length(max=255, message='Limit of 255 characters.')], - description='The source or author of the link.') - url = StringField(validators=[Optional(), URL(message='Not a valid URL')]) + validators=[Length(max=255, message="Limit of 255 characters.")], + description="The source or author of the link.", + ) + url = StringField(validators=[Optional(), URL(message="Not a valid URL")]) link_type = SelectField( - 'Link Type', + "Link Type", choices=LINK_CHOICES, - default='', - validators=[AnyOf(allowed_values(LINK_CHOICES))]) - creator_id = HiddenField(validators=[DataRequired(message='Not a valid user ID')]) + default="", + validators=[AnyOf(allowed_values(LINK_CHOICES))], + ) + creator_id = HiddenField(validators=[DataRequired(message="Not a valid user ID")]) def validate(self): success = super(LinkForm, self).validate() if self.url.data and not self.link_type.data: self.url.errors = list(self.url.errors) - self.url.errors.append('Links must have a link type.') + self.url.errors.append("Links must have a link type.") success = False return success class OfficerLinkForm(LinkForm): - officer_id = HiddenField(validators=[DataRequired(message='Not a valid officer ID')]) - submit = SubmitField(label='Submit') + officer_id = HiddenField( + validators=[DataRequired(message="Not a valid officer ID")] + ) + submit = SubmitField(label="Submit") class BaseTextForm(Form): text_contents = TextAreaField() - description = "This information about the officer will be attributed to your username." + description = ( + "This information about the officer will be attributed to your username." + ) class EditTextForm(BaseTextForm): - submit = SubmitField(label='Submit') + submit = SubmitField(label="Submit") class TextForm(EditTextForm): - officer_id = HiddenField(validators=[DataRequired(message='Not a valid officer ID')]) - creator_id = HiddenField(validators=[DataRequired(message='Not a valid user ID')]) + officer_id = HiddenField( + validators=[DataRequired(message="Not a valid officer ID")] + ) + creator_id = HiddenField(validators=[DataRequired(message="Not a valid user ID")]) class AddOfficerForm(Form): - department = QuerySelectField('Department', validators=[DataRequired()], - query_factory=dept_choices, get_label='name') - first_name = StringField('First name', default='', validators=[ - Regexp(r'\w*'), Length(max=50), Optional()]) - last_name = StringField('Last name', default='', validators=[ - Regexp(r'\w*'), Length(max=50), DataRequired()]) - middle_initial = StringField('Middle initial', default='', validators=[ - Regexp(r'\w*'), Length(max=50), Optional()]) - suffix = SelectField('Suffix', default='', choices=SUFFIX_CHOICES, - validators=[AnyOf(allowed_values(SUFFIX_CHOICES))]) - race = SelectField('Race', default='WHITE', choices=RACE_CHOICES, - validators=[AnyOf(allowed_values(RACE_CHOICES))]) + department = QuerySelectField( + "Department", + validators=[DataRequired()], + query_factory=dept_choices, + get_label="name", + ) + first_name = StringField( + "First name", + default="", + validators=[Regexp(r"\w*"), Length(max=50), Optional()], + ) + last_name = StringField( + "Last name", + default="", + validators=[Regexp(r"\w*"), Length(max=50), DataRequired()], + ) + middle_initial = StringField( + "Middle initial", + default="", + validators=[Regexp(r"\w*"), Length(max=50), Optional()], + ) + suffix = SelectField( + "Suffix", + default="", + choices=SUFFIX_CHOICES, + validators=[AnyOf(allowed_values(SUFFIX_CHOICES))], + ) + race = SelectField( + "Race", + default="WHITE", + choices=RACE_CHOICES, + validators=[AnyOf(allowed_values(RACE_CHOICES))], + ) gender = SelectField( - 'Gender', + "Gender", choices=GENDER_CHOICES, - coerce=lambda x: None if x == 'Not Sure' else x, - validators=[AnyOf(allowed_values(db_genders))] - ) - star_no = StringField('Badge Number', default='', validators=[ - Regexp(r'\w*'), Length(max=50)]) - unique_internal_identifier = StringField('Unique Internal Identifier', default='', validators=[Regexp(r'\w*'), Length(max=50)]) - job_id = StringField('Job ID') # Gets rewritten by Javascript - unit = QuerySelectField('Unit', validators=[Optional()], - query_factory=unit_choices, get_label='descrip', - allow_blank=True, blank_text=u'None') - employment_date = DateField('Employment Date', validators=[Optional()]) - birth_year = IntegerField('Birth Year', validators=[Optional()]) - links = FieldList(FormField( - LinkForm, - widget=FormFieldWidget()), - description='Links to articles about or videos of the incident.', + coerce=lambda x: None if x == "Not Sure" else x, + validators=[AnyOf(allowed_values(db_genders))], + ) + star_no = StringField( + "Badge Number", default="", validators=[Regexp(r"\w*"), Length(max=50)] + ) + unique_internal_identifier = StringField( + "Unique Internal Identifier", + default="", + validators=[Regexp(r"\w*"), Length(max=50)], + ) + job_id = StringField("Job ID") # Gets rewritten by Javascript + unit = QuerySelectField( + "Unit", + validators=[Optional()], + query_factory=unit_choices, + get_label="descrip", + allow_blank=True, + blank_text="None", + ) + employment_date = DateField("Employment Date", validators=[Optional()]) + birth_year = IntegerField("Birth Year", validators=[Optional()]) + links = FieldList( + FormField(LinkForm, widget=FormFieldWidget()), + description="Links to articles about or videos of the incident.", min_entries=1, - widget=BootstrapListWidget()) - notes = FieldList(FormField( - BaseTextForm, - widget=FormFieldWidget()), - description='This note about the officer will be attributed to your username.', + widget=BootstrapListWidget(), + ) + notes = FieldList( + FormField(BaseTextForm, widget=FormFieldWidget()), + description="This note about the officer will be attributed to your username.", min_entries=1, - widget=BootstrapListWidget()) - descriptions = FieldList(FormField( - BaseTextForm, - widget=FormFieldWidget()), - description='This description of the officer will be attributed to your username.', + widget=BootstrapListWidget(), + ) + descriptions = FieldList( + FormField(BaseTextForm, widget=FormFieldWidget()), + description="This description of the officer will be attributed to your username.", min_entries=1, - widget=BootstrapListWidget()) - salaries = FieldList(FormField( - SalaryForm, - widget=FormFieldWidget()), - description='Officer salaries', + widget=BootstrapListWidget(), + ) + salaries = FieldList( + FormField(SalaryForm, widget=FormFieldWidget()), + description="Officer salaries", min_entries=1, - widget=BootstrapListWidget()) + widget=BootstrapListWidget(), + ) - submit = SubmitField(label='Add') + submit = SubmitField(label="Add") class EditOfficerForm(Form): - first_name = StringField('First name', - validators=[Regexp(r'\w*'), Length(max=50), - Optional()]) - last_name = StringField('Last name', - validators=[Regexp(r'\w*'), Length(max=50), - DataRequired()]) - middle_initial = StringField('Middle initial', - validators=[Regexp(r'\w*'), Length(max=50), - Optional()]) - suffix = SelectField('Suffix', choices=SUFFIX_CHOICES, default='', - validators=[AnyOf(allowed_values(SUFFIX_CHOICES))]) - race = SelectField('Race', choices=RACE_CHOICES, coerce=lambda x: x or None, - validators=[AnyOf(allowed_values(RACE_CHOICES))]) + first_name = StringField( + "First name", validators=[Regexp(r"\w*"), Length(max=50), Optional()] + ) + last_name = StringField( + "Last name", validators=[Regexp(r"\w*"), Length(max=50), DataRequired()] + ) + middle_initial = StringField( + "Middle initial", validators=[Regexp(r"\w*"), Length(max=50), Optional()] + ) + suffix = SelectField( + "Suffix", + choices=SUFFIX_CHOICES, + default="", + validators=[AnyOf(allowed_values(SUFFIX_CHOICES))], + ) + race = SelectField( + "Race", + choices=RACE_CHOICES, + coerce=lambda x: x or None, + validators=[AnyOf(allowed_values(RACE_CHOICES))], + ) gender = SelectField( - 'Gender', + "Gender", choices=GENDER_CHOICES, - coerce=lambda x: None if x == 'Not Sure' else x, - validators=[AnyOf(allowed_values(db_genders))] - ) - employment_date = DateField('Employment Date', validators=[Optional()]) - birth_year = IntegerField('Birth Year', validators=[Optional()]) - unique_internal_identifier = StringField('Unique Internal Identifier', - default='', - validators=[Regexp(r'\w*'), Length(max=50)], - filters=[lambda x: x or None]) + coerce=lambda x: None if x == "Not Sure" else x, + validators=[AnyOf(allowed_values(db_genders))], + ) + employment_date = DateField("Employment Date", validators=[Optional()]) + birth_year = IntegerField("Birth Year", validators=[Optional()]) + unique_internal_identifier = StringField( + "Unique Internal Identifier", + default="", + validators=[Regexp(r"\w*"), Length(max=50)], + filters=[lambda x: x or None], + ) department = QuerySelectField( - 'Department', + "Department", validators=[Optional()], query_factory=dept_choices, - get_label='name') - submit = SubmitField(label='Update') + get_label="name", + ) + submit = SubmitField(label="Update") class AddUnitForm(Form): - descrip = StringField('Unit name or description', default='', validators=[ - Regexp(r'\w*'), Length(max=120), DataRequired()]) + descrip = StringField( + "Unit name or description", + default="", + validators=[Regexp(r"\w*"), Length(max=120), DataRequired()], + ) department = QuerySelectField( - 'Department', + "Department", validators=[DataRequired()], query_factory=dept_choices, - get_label='name') - submit = SubmitField(label='Add') + get_label="name", + ) + submit = SubmitField(label="Add") class AddImageForm(Form): department = QuerySelectField( - 'Department', + "Department", validators=[DataRequired()], query_factory=dept_choices, - get_label='name') + get_label="name", + ) class DateFieldForm(Form): - date_field = DateField('Date*', validators=[DataRequired()]) - time_field = TimeField('Time', validators=[Optional()]) + date_field = DateField("Date*", validators=[DataRequired()]) + time_field = TimeField("Time", validators=[Optional()]) def validate_time_field(self, field): if not type(field.data) == datetime.time: - raise ValidationError('Not a valid time.') + raise ValidationError("Not a valid time.") def validate_date_field(self, field): if field.data.year < 1900: - raise ValidationError('Incidents prior to 1900 not allowed.') + raise ValidationError("Incidents prior to 1900 not allowed.") class LocationForm(Form): - street_name = StringField(validators=[Optional()], description='Street on which incident occurred. For privacy reasons, please DO NOT INCLUDE street number.') - cross_street1 = StringField(validators=[Optional()], description='Closest cross street to where incident occurred.') + street_name = StringField( + validators=[Optional()], + description="Street on which incident occurred. For privacy reasons, please DO NOT INCLUDE street number.", + ) + cross_street1 = StringField( + validators=[Optional()], + description="Closest cross street to where incident occurred.", + ) cross_street2 = StringField(validators=[Optional()]) - city = StringField('City*', validators=[DataRequired()]) - state = SelectField('State*', choices=STATE_CHOICES, - validators=[AnyOf(allowed_values(STATE_CHOICES, False), message='Must select a state.')]) - zip_code = StringField('Zip Code', - validators=[Optional(), - Regexp(r'^\d{5}$', message='Zip codes must have 5 digits.')]) + city = StringField("City*", validators=[DataRequired()]) + state = SelectField( + "State*", + choices=STATE_CHOICES, + validators=[ + AnyOf(allowed_values(STATE_CHOICES, False), message="Must select a state.") + ], + ) + zip_code = StringField( + "Zip Code", + validators=[ + Optional(), + Regexp(r"^\d{5}$", message="Zip codes must have 5 digits."), + ], + ) class LicensePlateForm(Form): - number = StringField('Plate Number', validators=[]) - state = SelectField('State', choices=STATE_CHOICES, - validators=[AnyOf(allowed_values(STATE_CHOICES))]) + number = StringField("Plate Number", validators=[]) + state = SelectField( + "State", + choices=STATE_CHOICES, + validators=[AnyOf(allowed_values(STATE_CHOICES))], + ) def validate_state(self, field): - if self.number.data != '' and field.data == '': - raise ValidationError('Must also select a state.') + if self.number.data != "" and field.data == "": + raise ValidationError("Must also select a state.") class OfficerIdField(StringField): @@ -360,62 +499,95 @@ def validate_oo_id(self, field): # Sometimes we get a string in field.data with py.test, this parses it except ValueError: - officer_id = field.data.split("value=\"")[1][:-2] + officer_id = field.data.split('value="')[1][:-2] officer = Officer.query.get(officer_id) if not officer: - raise ValidationError('Not a valid officer id') + raise ValidationError("Not a valid officer id") class OOIdForm(Form): - oo_id = StringField('OO Officer ID', validators=[validate_oo_id]) + oo_id = StringField("OO Officer ID", validators=[validate_oo_id]) class IncidentForm(DateFieldForm): report_number = StringField( - validators=[Regexp(r'^[a-zA-Z0-9-]*$', message="Report numbers can contain letters, numbers, and dashes")], - description='Incident number for the organization tracking incidents') + validators=[ + Regexp( + r"^[a-zA-Z0-9-]*$", + message="Report numbers can contain letters, numbers, and dashes", + ) + ], + description="Incident number for the organization tracking incidents", + ) description = TextAreaField(validators=[Optional()]) department = QuerySelectField( - 'Department*', + "Department*", validators=[DataRequired()], query_factory=dept_choices, - get_label='name') + get_label="name", + ) address = FormField(LocationForm) - officers = FieldList(FormField( - OOIdForm, widget=FormFieldWidget()), - description='Officers present at the incident.', + officers = FieldList( + FormField(OOIdForm, widget=FormFieldWidget()), + description="Officers present at the incident.", min_entries=1, - widget=BootstrapListWidget()) - license_plates = FieldList(FormField( - LicensePlateForm, widget=FormFieldWidget()), - description='License plates of police vehicles at the incident.', + widget=BootstrapListWidget(), + ) + license_plates = FieldList( + FormField(LicensePlateForm, widget=FormFieldWidget()), + description="License plates of police vehicles at the incident.", min_entries=1, - widget=BootstrapListWidget()) - links = FieldList(FormField( - LinkForm, - widget=FormFieldWidget()), - description='Links to articles about or videos of the incident.', + widget=BootstrapListWidget(), + ) + links = FieldList( + FormField(LinkForm, widget=FormFieldWidget()), + description="Links to articles about or videos of the incident.", min_entries=1, - widget=BootstrapListWidget()) - creator_id = HiddenField(validators=[DataRequired(message='Incidents must have a creator id.')]) - last_updated_id = HiddenField(validators=[DataRequired(message='Incidents must have a user id for editing.')]) + widget=BootstrapListWidget(), + ) + creator_id = HiddenField( + validators=[DataRequired(message="Incidents must have a creator id.")] + ) + last_updated_id = HiddenField( + validators=[DataRequired(message="Incidents must have a user id for editing.")] + ) - submit = SubmitField(label='Submit') + submit = SubmitField(label="Submit") class BrowseForm(Form): - rank = QuerySelectField('rank', validators=[Optional()], get_label='job_title', - get_pk=lambda job: job.job_title) # query set in view function - name = StringField('Last name') - badge = StringField('Badge number') - unique_internal_identifier = StringField('Unique ID') - race = SelectField('race', default='Not Sure', choices=RACE_CHOICES, - validators=[AnyOf(allowed_values(RACE_CHOICES))]) - gender = SelectField('gender', default='Not Sure', choices=GENDER_CHOICES, - validators=[AnyOf(allowed_values(GENDER_CHOICES))]) - min_age = SelectField('minimum age', default=16, choices=AGE_CHOICES, - validators=[AnyOf(allowed_values(AGE_CHOICES))]) - max_age = SelectField('maximum age', default=100, choices=AGE_CHOICES, - validators=[AnyOf(allowed_values(AGE_CHOICES))]) - submit = SubmitField(label='Submit') + rank = QuerySelectField( + "rank", + validators=[Optional()], + get_label="job_title", + get_pk=lambda job: job.job_title, + ) # query set in view function + name = StringField("Last name") + badge = StringField("Badge number") + unique_internal_identifier = StringField("Unique ID") + race = SelectField( + "race", + default="Not Sure", + choices=RACE_CHOICES, + validators=[AnyOf(allowed_values(RACE_CHOICES))], + ) + gender = SelectField( + "gender", + default="Not Sure", + choices=GENDER_CHOICES, + validators=[AnyOf(allowed_values(GENDER_CHOICES))], + ) + min_age = SelectField( + "minimum age", + default=16, + choices=AGE_CHOICES, + validators=[AnyOf(allowed_values(AGE_CHOICES))], + ) + max_age = SelectField( + "maximum age", + default=100, + choices=AGE_CHOICES, + validators=[AnyOf(allowed_values(AGE_CHOICES))], + ) + submit = SubmitField(label="Submit") diff --git a/OpenOversight/app/main/model_view.py b/OpenOversight/app/main/model_view.py index afc68249b..06f2473f6 100644 --- a/OpenOversight/app/main/model_view.py +++ b/OpenOversight/app/main/model_view.py @@ -1,10 +1,12 @@ import datetime -from flask_sqlalchemy.model import DefaultMeta -from flask_wtf import FlaskForm as Form from typing import Callable, Union -from flask import render_template, redirect, request, url_for, flash, abort, current_app + +from flask import abort, current_app, flash, redirect, render_template, request, url_for from flask.views import MethodView -from flask_login import login_required, current_user +from flask_login import current_user, login_required +from flask_sqlalchemy.model import DefaultMeta +from flask_wtf import FlaskForm as Form + from ..auth.utils import ac_or_admin_required from ..models import db from ..utils import add_department_query, set_dynamic_default @@ -12,103 +14,127 @@ class ModelView(MethodView): model = None # type: DefaultMeta - model_name = '' + model_name = "" per_page = 20 - order_by = '' # this should be a field on the model + order_by = "" # this should be a field on the model descending = False # used for order_by - form = '' # type: Form - create_function = '' # type: Union[str, Callable] + form = "" # type: Form + create_function = "" # type: Union[str, Callable] department_check = False def get(self, obj_id): if obj_id is None: - if request.args.get('page'): - page = int(request.args.get('page')) + if request.args.get("page"): + page = int(request.args.get("page")) else: page = 1 if self.order_by: if not self.descending: - objects = self.model.query.order_by(getattr(self.model, self.order_by)).paginate(page, self.per_page, False) - objects = self.model.query.order_by(getattr(self.model, self.order_by).desc()).paginate(page, self.per_page, False) + objects = self.model.query.order_by( + getattr(self.model, self.order_by) + ).paginate(page, self.per_page, False) + objects = self.model.query.order_by( + getattr(self.model, self.order_by).desc() + ).paginate(page, self.per_page, False) else: objects = self.model.query.paginate(page, self.per_page, False) - return render_template('{}_list.html'.format(self.model_name), objects=objects, url='main.{}_api'.format(self.model_name)) + return render_template( + "{}_list.html".format(self.model_name), + objects=objects, + url="main.{}_api".format(self.model_name), + ) else: obj = self.model.query.get_or_404(obj_id) - return render_template('{}_detail.html'.format(self.model_name), obj=obj, current_user=current_user) + return render_template( + "{}_detail.html".format(self.model_name), + obj=obj, + current_user=current_user, + ) @login_required @ac_or_admin_required def new(self, form=None): if not form: form = self.get_new_form() - if hasattr(form, 'department'): + if hasattr(form, "department"): add_department_query(form, current_user) - if getattr(current_user, 'dept_pref_rel', None): + if getattr(current_user, "dept_pref_rel", None): set_dynamic_default(form.department, current_user.dept_pref_rel) - if hasattr(form, 'creator_id') and not form.creator_id.data: + if hasattr(form, "creator_id") and not form.creator_id.data: form.creator_id.data = current_user.get_id() - if hasattr(form, 'last_updated_id'): + if hasattr(form, "last_updated_id"): form.last_updated_id.data = current_user.get_id() if form.validate_on_submit(): new_obj = self.create_function(form) db.session.add(new_obj) db.session.commit() - flash('{} created!'.format(self.model_name)) + flash("{} created!".format(self.model_name)) return self.get_redirect_url(obj_id=new_obj.id) else: current_app.logger.info(form.errors) - return render_template('{}_new.html'.format(self.model_name), form=form) + return render_template("{}_new.html".format(self.model_name), form=form) @login_required @ac_or_admin_required def edit(self, obj_id, form=None): obj = self.model.query.get_or_404(obj_id) if self.department_check: - if not current_user.is_administrator and current_user.ac_department_id != self.get_department_id(obj): + if ( + not current_user.is_administrator + and current_user.ac_department_id != self.get_department_id(obj) + ): abort(403) if not form: form = self.get_edit_form(obj) # if the object doesn't have a creator id set, st it to current user - if hasattr(obj, 'creator_id') and hasattr(form, 'creator_id') and getattr(obj, 'creator_id'): + if ( + hasattr(obj, "creator_id") + and hasattr(form, "creator_id") + and getattr(obj, "creator_id") + ): form.creator_id.data = obj.creator_id - elif hasattr(form, 'creator_id'): + elif hasattr(form, "creator_id"): form.creator_id.data = current_user.get_id() # if the object keeps track of who updated it last, set to current user - if hasattr(form, 'last_updated_id'): + if hasattr(form, "last_updated_id"): form.last_updated_id.data = current_user.get_id() - if hasattr(form, 'department'): + if hasattr(form, "department"): add_department_query(form, current_user) if form.validate_on_submit(): self.populate_obj(form, obj) - flash('{} successfully updated!'.format(self.model_name)) + flash("{} successfully updated!".format(self.model_name)) return self.get_redirect_url(obj_id=obj_id) - return render_template('{}_edit.html'.format(self.model_name), obj=obj, form=form) + return render_template( + "{}_edit.html".format(self.model_name), obj=obj, form=form + ) @login_required @ac_or_admin_required def delete(self, obj_id): obj = self.model.query.get_or_404(obj_id) if self.department_check: - if not current_user.is_administrator and current_user.ac_department_id != self.get_department_id(obj): + if ( + not current_user.is_administrator + and current_user.ac_department_id != self.get_department_id(obj) + ): abort(403) - if request.method == 'POST': + if request.method == "POST": db.session.delete(obj) db.session.commit() - flash('{} successfully deleted!'.format(self.model_name)) + flash("{} successfully deleted!".format(self.model_name)) return self.get_post_delete_url() - return render_template('{}_delete.html'.format(self.model_name), obj=obj) + return render_template("{}_delete.html".format(self.model_name), obj=obj) def get_edit_form(self, obj): form = self.form(obj=obj) @@ -119,18 +145,24 @@ def get_new_form(self): def get_redirect_url(self, *args, **kwargs): # returns user to the show view - return redirect(url_for('main.{}_api'.format(self.model_name), obj_id=kwargs['obj_id'], _method='GET')) + return redirect( + url_for( + "main.{}_api".format(self.model_name), + obj_id=kwargs["obj_id"], + _method="GET", + ) + ) def get_post_delete_url(self, *args, **kwargs): # returns user to the list view - return redirect(url_for('main.{}_api'.format(self.model_name))) + return redirect(url_for("main.{}_api".format(self.model_name))) def get_department_id(self, obj): return obj.department_id def populate_obj(self, form, obj): form.populate_obj(obj) - if hasattr(obj, 'date_updated'): + if hasattr(obj, "date_updated"): obj.date_updated = datetime.datetime.now() db.session.add(obj) db.session.commit() @@ -140,15 +172,15 @@ def create_obj(self, form): def dispatch_request(self, *args, **kwargs): # isolate the method at the end of the url - end_of_url = request.url.split('/')[-1].split('?')[0] - endings = ['edit', 'new', 'delete'] + end_of_url = request.url.split("/")[-1].split("?")[0] + endings = ["edit", "new", "delete"] meth = None for ending in endings: if end_of_url == ending: meth = getattr(self, ending, None) if not meth: - if request.method == 'GET': - meth = getattr(self, 'get', None) + if request.method == "GET": + meth = getattr(self, "get", None) else: - assert meth is not None, 'Unimplemented method %r' % request.method + assert meth is not None, "Unimplemented method %r" % request.method return meth(*args, **kwargs) diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 871c9da94..33c549cff 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -1,39 +1,90 @@ import os import re -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy.orm import selectinload import sys from traceback import format_exc -from flask import (abort, render_template, request, redirect, url_for, - flash, current_app, jsonify) +from flask import ( + abort, + current_app, + flash, + jsonify, + redirect, + render_template, + request, + url_for, +) from flask_login import current_user, login_required, login_user +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import contains_eager, joinedload, selectinload +from sqlalchemy.orm.exc import NoResultFound -from . import main, downloads from .. import limiter, sitemap -from ..utils import (serve_image, compute_leaderboard_stats, get_random_image, - allowed_file, add_new_assignment, edit_existing_assignment, - add_officer_profile, edit_officer_profile, - ac_can_edit_officer, add_department_query, add_unit_query, - replace_list, create_note, set_dynamic_default, roster_lookup, - create_description, filter_by_form, - crop_image, create_incident, get_or_create, dept_choices, - upload_image_to_s3_and_store_in_db) - -from .forms import (FindOfficerForm, FindOfficerIDForm, AddUnitForm, - FaceTag, AssignmentForm, DepartmentForm, AddOfficerForm, - EditOfficerForm, IncidentForm, TextForm, EditTextForm, - AddImageForm, EditDepartmentForm, BrowseForm, SalaryForm, OfficerLinkForm) +from ..auth.forms import LoginForm +from ..auth.utils import ac_or_admin_required, admin_required +from ..models import ( + Assignment, + Department, + Description, + Face, + Image, + Incident, + Job, + LicensePlate, + Link, + Location, + Note, + Officer, + Salary, + Unit, + User, + db, +) +from ..utils import ( + ac_can_edit_officer, + add_department_query, + add_new_assignment, + add_officer_profile, + add_unit_query, + allowed_file, + compute_leaderboard_stats, + create_description, + create_incident, + create_note, + crop_image, + dept_choices, + edit_existing_assignment, + edit_officer_profile, + filter_by_form, + get_or_create, + get_random_image, + replace_list, + roster_lookup, + serve_image, + set_dynamic_default, + upload_image_to_s3_and_store_in_db, +) +from . import downloads, main +from .choices import AGE_CHOICES, GENDER_CHOICES, RACE_CHOICES +from .forms import ( + AddImageForm, + AddOfficerForm, + AddUnitForm, + AssignmentForm, + BrowseForm, + DepartmentForm, + EditDepartmentForm, + EditOfficerForm, + EditTextForm, + FaceTag, + FindOfficerForm, + FindOfficerIDForm, + IncidentForm, + OfficerLinkForm, + SalaryForm, + TextForm, +) from .model_view import ModelView -from .choices import GENDER_CHOICES, RACE_CHOICES, AGE_CHOICES -from ..models import (db, Image, User, Face, Officer, Assignment, Department, - Unit, Incident, Location, LicensePlate, Link, Note, - Description, Salary, Job) -from ..auth.forms import LoginForm -from ..auth.utils import admin_required, ac_or_admin_required -from sqlalchemy.orm import contains_eager, joinedload # Ensure the file is read/write by the creator only SAVED_UMASK = os.umask(0o077) @@ -49,108 +100,117 @@ def sitemap_include(view): @sitemap.register_generator def static_routes(): for endpoint in sitemap_endpoints: - yield 'main.' + endpoint, {} + yield "main." + endpoint, {} -def redirect_url(default='index'): - return request.args.get('next') or request.referrer or url_for(default) +def redirect_url(default="index"): + return request.args.get("next") or request.referrer or url_for(default) @sitemap_include -@main.route('/') -@main.route('/index') +@main.route("/") +@main.route("/index") def index(): - return render_template('index.html') + return render_template("index.html") @sitemap_include -@main.route('/browse', methods=['GET']) +@main.route("/browse", methods=["GET"]) def browse(): departments = Department.query.filter(Department.officers.any()) - return render_template('browse.html', departments=departments) + return render_template("browse.html", departments=departments) @sitemap_include -@main.route('/find', methods=['GET', 'POST']) +@main.route("/find", methods=["GET", "POST"]) def get_officer(): - jsloads = ['js/find_officer.js'] + jsloads = ["js/find_officer.js"] form = FindOfficerForm() depts_dict = [dept_choice.toCustomDict() for dept_choice in dept_choices()] - if getattr(current_user, 'dept_pref_rel', None): + if getattr(current_user, "dept_pref_rel", None): set_dynamic_default(form.dept, current_user.dept_pref_rel) if form.validate_on_submit(): - return redirect(url_for( - 'main.list_officer', - department_id=form.data['dept'].id, - race=form.data['race'] if form.data['race'] != 'Not Sure' else None, - gender=form.data['gender'] if form.data['gender'] != 'Not Sure' else None, - rank=form.data['rank'] if form.data['rank'] != 'Not Sure' else None, - unit=form.data['unit'] if form.data['unit'] != 'Not Sure' else None, - min_age=form.data['min_age'], - max_age=form.data['max_age'], - name=form.data['name'], - badge=form.data['badge'], - unique_internal_identifier=form.data['unique_internal_identifier']), - code=302) + return redirect( + url_for( + "main.list_officer", + department_id=form.data["dept"].id, + race=form.data["race"] if form.data["race"] != "Not Sure" else None, + gender=form.data["gender"] + if form.data["gender"] != "Not Sure" + else None, + rank=form.data["rank"] if form.data["rank"] != "Not Sure" else None, + unit=form.data["unit"] if form.data["unit"] != "Not Sure" else None, + min_age=form.data["min_age"], + max_age=form.data["max_age"], + name=form.data["name"], + badge=form.data["badge"], + unique_internal_identifier=form.data["unique_internal_identifier"], + ), + code=302, + ) else: current_app.logger.info(form.errors) - return render_template('input_find_officer.html', form=form, depts_dict=depts_dict, jsloads=jsloads) + return render_template( + "input_find_officer.html", form=form, depts_dict=depts_dict, jsloads=jsloads + ) -@main.route('/tagger_find', methods=['GET', 'POST']) +@main.route("/tagger_find", methods=["GET", "POST"]) def get_ooid(): form = FindOfficerIDForm() if form.validate_on_submit(): - return redirect(url_for('main.get_tagger_gallery'), code=307) + return redirect(url_for("main.get_tagger_gallery"), code=307) else: current_app.logger.info(form.errors) - return render_template('input_find_ooid.html', form=form) + return render_template("input_find_ooid.html", form=form) @sitemap_include -@main.route('/label', methods=['GET', 'POST']) +@main.route("/label", methods=["GET", "POST"]) def get_started_labeling(): form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() if user is not None and user.verify_password(form.password.data): login_user(user, form.remember_me.data) - return redirect(request.args.get('next') or url_for('main.index')) - flash('Invalid username or password.') + return redirect(request.args.get("next") or url_for("main.index")) + flash("Invalid username or password.") else: current_app.logger.info(form.errors) departments = Department.query.all() - return render_template('label_data.html', departments=departments, form=form) + return render_template("label_data.html", departments=departments, form=form) -@main.route('/sort/department/', methods=['GET', 'POST']) +@main.route("/sort/department/", methods=["GET", "POST"]) @login_required def sort_images(department_id): # Select a random unsorted image from the database - image_query = Image.query.filter_by(contains_cops=None) \ - .filter_by(department_id=department_id) + image_query = Image.query.filter_by(contains_cops=None).filter_by( + department_id=department_id + ) image = get_random_image(image_query) if image: proper_path = serve_image(image.filepath) else: proper_path = None - return render_template('sort.html', image=image, path=proper_path, - department_id=department_id) + return render_template( + "sort.html", image=image, path=proper_path, department_id=department_id + ) @sitemap_include -@main.route('/tutorial') +@main.route("/tutorial") def get_tutorial(): - return render_template('tutorial.html') + return render_template("tutorial.html") -@main.route('/user/') +@main.route("/user/") def profile(username): - if re.search('^[A-Za-z][A-Za-z0-9_.]*$', username): + if re.search("^[A-Za-z][A-Za-z0-9_.]*$", username): user = User.query.filter_by(username=username).one() else: abort(404) @@ -159,90 +219,115 @@ def profile(username): department = Department.query.filter_by(id=pref).one().name except NoResultFound: department = None - return render_template('profile.html', user=user, department=department) + return render_template("profile.html", user=user, department=department) -@main.route('/officer/', methods=['GET', 'POST']) +@main.route("/officer/", methods=["GET", "POST"]) def officer_profile(officer_id): form = AssignmentForm() try: officer = Officer.query.filter_by(id=officer_id).one() except NoResultFound: abort(404) - except: # noqa + except: # noqa: E722 exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error finding officer: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) - form.job_title.query = Job.query\ - .filter_by(department_id=officer.department_id)\ - .order_by(Job.order.asc())\ - .all() + current_app.logger.error( + "Error finding officer: {}".format( + " ".join([str(exception_type), str(value), format_exc()]) + ) + ) + form.job_title.query = ( + Job.query.filter_by(department_id=officer.department_id) + .order_by(Job.order.asc()) + .all() + ) try: - faces = Face.query.filter_by(officer_id=officer_id).order_by(Face.featured.desc()).all() + faces = ( + Face.query.filter_by(officer_id=officer_id) + .order_by(Face.featured.desc()) + .all() + ) assignments = Assignment.query.filter_by(officer_id=officer_id).all() face_paths = [] for face in faces: face_paths.append(serve_image(face.image.filepath)) - except: # noqa + except: # noqa: E722 exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error loading officer profile: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) + current_app.logger.error( + "Error loading officer profile: {}".format( + " ".join([str(exception_type), str(value), format_exc()]) + ) + ) if faces: officer.image_url = faces[0].image.filepath - if not officer.image_url.startswith('http'): - officer.image_url = url_for('static', filename=faces[0].image.filepath.replace('/static/', ''), _external=True) + if not officer.image_url.startswith("http"): + officer.image_url = url_for( + "static", + filename=faces[0].image.filepath.replace("/static/", ""), + _external=True, + ) if faces[0].face_width and faces[0].face_height: officer.image_width = faces[0].face_width officer.image_height = faces[0].face_height - return render_template('officer.html', officer=officer, paths=face_paths, - faces=faces, assignments=assignments, form=form) + return render_template( + "officer.html", + officer=officer, + paths=face_paths, + faces=faces, + assignments=assignments, + form=form, + ) @sitemap.register_generator def sitemap_officers(): for officer in Officer.query.all(): - yield 'main.officer_profile', {'officer_id': officer.id} + yield "main.officer_profile", {"officer_id": officer.id} -@main.route('/officer//assignment/new', methods=['POST']) +@main.route("/officer//assignment/new", methods=["POST"]) @ac_or_admin_required def add_assignment(officer_id): form = AssignmentForm() officer = Officer.query.filter_by(id=officer_id).first() - form.job_title.query = Job.query\ - .filter_by(department_id=officer.department_id)\ - .order_by(Job.order.asc())\ - .all() + form.job_title.query = ( + Job.query.filter_by(department_id=officer.department_id) + .order_by(Job.order.asc()) + .all() + ) if not officer: - flash('Officer not found') + flash("Officer not found") abort(404) if form.validate_on_submit(): - if (current_user.is_administrator - or (current_user.is_area_coordinator and officer.department_id == current_user.ac_department_id)): + if current_user.is_administrator or ( + current_user.is_area_coordinator + and officer.department_id == current_user.ac_department_id + ): try: add_new_assignment(officer_id, form) - flash('Added new assignment!') + flash("Added new assignment!") except IntegrityError: - flash('Assignment already exists') - return redirect(url_for('main.officer_profile', - officer_id=officer_id), code=302) - elif current_user.is_area_coordinator and not officer.department_id == current_user.ac_department_id: + flash("Assignment already exists") + return redirect( + url_for("main.officer_profile", officer_id=officer_id), code=302 + ) + elif ( + current_user.is_area_coordinator + and not officer.department_id == current_user.ac_department_id + ): abort(403) else: current_app.logger.info(form.errors) flash("Error: " + str(form.errors)) - return redirect(url_for('main.officer_profile', officer_id=officer_id)) + return redirect(url_for("main.officer_profile", officer_id=officer_id)) -@main.route('/officer//assignment/', - methods=['GET', 'POST']) +@main.route( + "/officer//assignment/", methods=["GET", "POST"] +) @login_required @ac_or_admin_required def edit_assignment(officer_id, assignment_id): @@ -254,59 +339,69 @@ def edit_assignment(officer_id, assignment_id): assignment = Assignment.query.filter_by(id=assignment_id).one() form = AssignmentForm(obj=assignment) - form.job_title.query = Job.query\ - .filter_by(department_id=officer.department_id)\ - .order_by(Job.order.asc())\ - .all() + form.job_title.query = ( + Job.query.filter_by(department_id=officer.department_id) + .order_by(Job.order.asc()) + .all() + ) form.job_title.data = Job.query.filter_by(id=assignment.job_id).one() if form.unit.data and type(form.unit.data) == int: form.unit.data = Unit.query.filter_by(id=form.unit.data).one() if form.validate_on_submit(): - form.job_title.data = Job.query.filter_by(id=int(form.job_title.raw_data[0])).one() + form.job_title.data = Job.query.filter_by( + id=int(form.job_title.raw_data[0]) + ).one() assignment = edit_existing_assignment(assignment, form) - flash('Edited officer assignment ID {}'.format(assignment.id)) - return redirect(url_for('main.officer_profile', officer_id=officer_id)) + flash("Edited officer assignment ID {}".format(assignment.id)) + return redirect(url_for("main.officer_profile", officer_id=officer_id)) else: current_app.logger.info(form.errors) - return render_template('edit_assignment.html', form=form) + return render_template("edit_assignment.html", form=form) -@main.route('/officer//salary/new', methods=['GET', 'POST']) +@main.route("/officer//salary/new", methods=["GET", "POST"]) @ac_or_admin_required def add_salary(officer_id): form = SalaryForm() officer = Officer.query.filter_by(id=officer_id).first() if not officer: - flash('Officer not found') + flash("Officer not found") abort(404) - if form.validate_on_submit() and (current_user.is_administrator or - (current_user.is_area_coordinator and - officer.department_id == current_user.ac_department_id)): + if form.validate_on_submit() and ( + current_user.is_administrator + or ( + current_user.is_area_coordinator + and officer.department_id == current_user.ac_department_id + ) + ): try: new_salary = Salary( officer_id=officer_id, salary=form.salary.data, overtime_pay=form.overtime_pay.data, year=form.year.data, - is_fiscal_year=form.is_fiscal_year.data + is_fiscal_year=form.is_fiscal_year.data, ) db.session.add(new_salary) db.session.commit() - flash('Added new salary!') + flash("Added new salary!") except IntegrityError as e: db.session.rollback() - flash('Error adding new salary: {}'.format(e)) - return redirect(url_for('main.officer_profile', - officer_id=officer_id), code=302) - elif current_user.is_area_coordinator and not officer.department_id == current_user.ac_department_id: + flash("Error adding new salary: {}".format(e)) + return redirect( + url_for("main.officer_profile", officer_id=officer_id), code=302 + ) + elif ( + current_user.is_area_coordinator + and not officer.department_id == current_user.ac_department_id + ): abort(403) else: - return render_template('add_edit_salary.html', form=form) + return render_template("add_edit_salary.html", form=form) -@main.route('/officer//salary/', - methods=['GET', 'POST']) +@main.route("/officer//salary/", methods=["GET", "POST"]) @login_required @ac_or_admin_required def edit_salary(officer_id, salary_id): @@ -321,14 +416,14 @@ def edit_salary(officer_id, salary_id): form.populate_obj(salary) db.session.add(salary) db.session.commit() - flash('Edited officer salary ID {}'.format(salary.id)) - return redirect(url_for('main.officer_profile', officer_id=officer_id)) + flash("Edited officer salary ID {}".format(salary.id)) + return redirect(url_for("main.officer_profile", officer_id=officer_id)) else: current_app.logger.info(form.errors) - return render_template('add_edit_salary.html', form=form, update=True) + return render_template("add_edit_salary.html", form=form, update=True) -@main.route('/image/') +@main.route("/image/") @login_required def display_submission(image_id): try: @@ -336,10 +431,10 @@ def display_submission(image_id): proper_path = serve_image(image.filepath) except NoResultFound: abort(404) - return render_template('image.html', image=image, path=proper_path) + return render_template("image.html", image=image, path=proper_path) -@main.route('/tag/') +@main.route("/tag/") @login_required def display_tag(tag_id): try: @@ -347,17 +442,16 @@ def display_tag(tag_id): proper_path = serve_image(tag.image.filepath) except NoResultFound: abort(404) - return render_template('tag.html', face=tag, path=proper_path) + return render_template("tag.html", face=tag, path=proper_path) -@main.route('/image/classify//', - methods=['POST']) +@main.route("/image/classify//", methods=["POST"]) @login_required def classify_submission(image_id, contains_cops): try: image = Image.query.filter_by(id=image_id).one() if image.contains_cops is not None and not current_user.is_administrator: - flash('Only administrator can re-classify image') + flash("Only administrator can re-classify image") return redirect(redirect_url()) image.user_id = current_user.get_id() if contains_cops == 1: @@ -365,64 +459,66 @@ def classify_submission(image_id, contains_cops): elif contains_cops == 0: image.contains_cops = False db.session.commit() - flash('Updated image classification') - except: # noqa - flash('Unknown error occurred') + flash("Updated image classification") + except: # noqa: E722 + flash("Unknown error occurred") exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error classifying image: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) + current_app.logger.error( + "Error classifying image: {}".format( + " ".join([str(exception_type), str(value), format_exc()]) + ) + ) return redirect(redirect_url()) # return redirect(url_for('main.display_submission', image_id=image_id)) -@main.route('/department/new', methods=['GET', 'POST']) +@main.route("/department/new", methods=["GET", "POST"]) @login_required @admin_required def add_department(): - jsloads = ['js/jquery-ui.min.js', 'js/deptRanks.js'] + jsloads = ["js/jquery-ui.min.js", "js/deptRanks.js"] form = DepartmentForm() if form.validate_on_submit(): departments = [x[0] for x in db.session.query(Department.name).all()] if form.name.data not in departments: - department = Department(name=form.name.data, - short_name=form.short_name.data) + department = Department( + name=form.name.data, short_name=form.short_name.data + ) db.session.add(department) db.session.flush() - db.session.add(Job( - job_title='Not Sure', - order=0, - department_id=department.id - )) + db.session.add( + Job(job_title="Not Sure", order=0, department_id=department.id) + ) db.session.flush() if form.jobs.data: order = 1 - for job in form.data['jobs']: + for job in form.data["jobs"]: if job: - db.session.add(Job( - job_title=job, - order=order, - is_sworn_officer=True, - department_id=department.id - )) + db.session.add( + Job( + job_title=job, + order=order, + is_sworn_officer=True, + department_id=department.id, + ) + ) order += 1 db.session.commit() - flash('New department {} added to OpenOversight'.format(department.name)) + flash("New department {} added to OpenOversight".format(department.name)) else: - flash('Department {} already exists'.format(form.name.data)) - return redirect(url_for('main.get_started_labeling')) + flash("Department {} already exists".format(form.name.data)) + return redirect(url_for("main.get_started_labeling")) else: current_app.logger.info(form.errors) - return render_template('add_edit_department.html', form=form, jsloads=jsloads) + return render_template("add_edit_department.html", form=form, jsloads=jsloads) -@main.route('/department//edit', methods=['GET', 'POST']) +@main.route("/department//edit", methods=["GET", "POST"]) @login_required @admin_required def edit_department(department_id): - jsloads = ['js/jquery-ui.min.js', 'js/deptRanks.js'] + jsloads = ["js/jquery-ui.min.js", "js/deptRanks.js"] department = Department.query.get_or_404(department_id) previous_name = department.name form = EditDepartmentForm(obj=department) @@ -431,104 +527,170 @@ def edit_department(department_id): new_name = form.name.data if new_name != previous_name: if Department.query.filter_by(name=new_name).count() > 0: - flash('Department {} already exists'.format(new_name)) - return redirect(url_for('main.edit_department', - department_id=department_id)) + flash("Department {} already exists".format(new_name)) + return redirect( + url_for("main.edit_department", department_id=department_id) + ) department.name = new_name department.short_name = form.short_name.data db.session.flush() if form.jobs.data: new_ranks = [] order = 1 - for rank in form.data['jobs']: + for rank in form.data["jobs"]: if rank: new_ranks.append((rank, order)) order += 1 updated_ranks = form.jobs.data if len(updated_ranks) < len(original_ranks): - deleted_ranks = [rank for rank in original_ranks if rank.job_title not in updated_ranks] - if Assignment.query.filter(Assignment.job_id.in_([rank.id for rank in deleted_ranks])).count() == 0: + deleted_ranks = [ + rank + for rank in original_ranks + if rank.job_title not in updated_ranks + ] + if ( + Assignment.query.filter( + Assignment.job_id.in_([rank.id for rank in deleted_ranks]) + ).count() + == 0 + ): for rank in deleted_ranks: db.session.delete(rank) else: failed_deletions = [] for rank in deleted_ranks: - if Assignment.query.filter(Assignment.job_id.in_([rank.id])).count() != 0: + if ( + Assignment.query.filter( + Assignment.job_id.in_([rank.id]) + ).count() + != 0 + ): failed_deletions.append(rank) for rank in failed_deletions: - flash('You attempted to delete a rank, {}, that is still in use'.format(rank)) - return redirect(url_for('main.edit_department', department_id=department_id)) + flash( + "You attempted to delete a rank, {}, that is still in use".format( + rank + ) + ) + return redirect( + url_for("main.edit_department", department_id=department_id) + ) for (new_rank, order) in new_ranks: - existing_rank = Job.query.filter_by(department_id=department_id, job_title=new_rank).one_or_none() + existing_rank = Job.query.filter_by( + department_id=department_id, job_title=new_rank + ).one_or_none() if existing_rank: existing_rank.is_sworn_officer = True existing_rank.order = order else: - db.session.add(Job( - job_title=new_rank, - order=order, - is_sworn_officer=True, - department_id=department_id - )) + db.session.add( + Job( + job_title=new_rank, + order=order, + is_sworn_officer=True, + department_id=department_id, + ) + ) db.session.commit() - flash('Department {} edited'.format(department.name)) - return redirect(url_for('main.list_officer', department_id=department.id)) + flash("Department {} edited".format(department.name)) + return redirect(url_for("main.list_officer", department_id=department.id)) else: current_app.logger.info(form.errors) - return render_template('add_edit_department.html', form=form, update=True, jsloads=jsloads) - - -@main.route('/department/') -def list_officer(department_id, page=1, race=[], gender=[], rank=[], min_age='16', max_age='100', name=None, - badge=None, unique_internal_identifier=None, unit=None): + return render_template( + "add_edit_department.html", form=form, update=True, jsloads=jsloads + ) + + +@main.route("/department/") +def list_officer( + department_id, + page=1, + race=[], + gender=[], + rank=[], + min_age="16", + max_age="100", + name=None, + badge=None, + unique_internal_identifier=None, + unit=None, +): form = BrowseForm() - form.rank.query = Job.query.filter_by(department_id=department_id, is_sworn_officer=True).order_by(Job.order.asc()).all() + form.rank.query = ( + Job.query.filter_by(department_id=department_id, is_sworn_officer=True) + .order_by(Job.order.asc()) + .all() + ) form_data = form.data - form_data['race'] = race - form_data['gender'] = gender - form_data['rank'] = rank - form_data['min_age'] = min_age - form_data['max_age'] = max_age - form_data['name'] = name - form_data['badge'] = badge - form_data['unit'] = unit - form_data['unique_internal_identifier'] = unique_internal_identifier - - OFFICERS_PER_PAGE = int(current_app.config['OFFICERS_PER_PAGE']) + form_data["race"] = race + form_data["gender"] = gender + form_data["rank"] = rank + form_data["min_age"] = min_age + form_data["max_age"] = max_age + form_data["name"] = name + form_data["badge"] = badge + form_data["unit"] = unit + form_data["unique_internal_identifier"] = unique_internal_identifier + + OFFICERS_PER_PAGE = int(current_app.config["OFFICERS_PER_PAGE"]) department = Department.query.filter_by(id=department_id).first() if not department: abort(404) # Set form data based on URL - if request.args.get('min_age') and request.args.get('min_age') in [ac[0] for ac in AGE_CHOICES]: - form_data['min_age'] = request.args.get('min_age') - if request.args.get('max_age') and request.args.get('max_age') in [ac[0] for ac in AGE_CHOICES]: - form_data['max_age'] = request.args.get('max_age') - if request.args.get('page'): - page = int(request.args.get('page')) - if request.args.get('name'): - form_data['name'] = request.args.get('name') - if request.args.get('badge'): - form_data['badge'] = request.args.get('badge') - if request.args.get('unit') and request.args.get('unit') != 'Not Sure': - form_data['unit'] = int(request.args.get('unit')) - if request.args.get('unique_internal_identifier'): - form_data['unique_internal_identifier'] = request.args.get('unique_internal_identifier') - if request.args.get('race') and all(race in [rc[0] for rc in RACE_CHOICES] for race in request.args.getlist('race')): - form_data['race'] = request.args.getlist('race') - if request.args.get('gender') and all(gender in [gc[0] for gc in GENDER_CHOICES] for gender in request.args.getlist('gender')): - form_data['gender'] = request.args.getlist('gender') - - unit_choices = [(unit.id, unit.descrip) for unit in Unit.query.filter_by(department_id=department_id).order_by(Unit.descrip.asc()).all()] - rank_choices = [jc[0] for jc in db.session.query(Job.job_title, Job.order).filter_by(department_id=department_id).order_by(Job.order).all()] - if request.args.get('rank') and all(rank in rank_choices for rank in request.args.getlist('rank')): - form_data['rank'] = request.args.getlist('rank') - - officers = filter_by_form( - form_data, Officer.query, department_id - ).filter(Officer.department_id == department_id) + if request.args.get("min_age") and request.args.get("min_age") in [ + ac[0] for ac in AGE_CHOICES + ]: + form_data["min_age"] = request.args.get("min_age") + if request.args.get("max_age") and request.args.get("max_age") in [ + ac[0] for ac in AGE_CHOICES + ]: + form_data["max_age"] = request.args.get("max_age") + if request.args.get("page"): + page = int(request.args.get("page")) + if request.args.get("name"): + form_data["name"] = request.args.get("name") + if request.args.get("badge"): + form_data["badge"] = request.args.get("badge") + if request.args.get("unit") and request.args.get("unit") != "Not Sure": + form_data["unit"] = int(request.args.get("unit")) + if request.args.get("unique_internal_identifier"): + form_data["unique_internal_identifier"] = request.args.get( + "unique_internal_identifier" + ) + if request.args.get("race") and all( + race in [rc[0] for rc in RACE_CHOICES] for race in request.args.getlist("race") + ): + form_data["race"] = request.args.getlist("race") + if request.args.get("gender") and all( + gender in [gc[0] for gc in GENDER_CHOICES] + for gender in request.args.getlist("gender") + ): + form_data["gender"] = request.args.getlist("gender") + + unit_choices = [ + (unit.id, unit.descrip) + for unit in Unit.query.filter_by(department_id=department_id) + .order_by(Unit.descrip.asc()) + .all() + ] + rank_choices = [ + jc[0] + for jc in db.session.query(Job.job_title, Job.order) + .filter_by(department_id=department_id) + .order_by(Job.order) + .all() + ] + if request.args.get("rank") and all( + rank in rank_choices for rank in request.args.getlist("rank") + ): + form_data["rank"] = request.args.getlist("rank") + + officers = filter_by_form(form_data, Officer.query, department_id).filter( + Officer.department_id == department_id + ) officers = officers.options(selectinload(Officer.face)) officers = officers.order_by(Officer.last_name, Officer.first_name, Officer.id) officers = officers.paginate(page, OFFICERS_PER_PAGE, False) @@ -541,39 +703,60 @@ def list_officer(department_id, page=1, race=[], gender=[], rank=[], min_age='16 officer.image = officer_face[0].image.filepath choices = { - 'race': RACE_CHOICES, - 'gender': GENDER_CHOICES, - 'rank': [(rc, rc) for rc in rank_choices], - 'unit': [('Not Sure', 'Not Sure')] + unit_choices + "race": RACE_CHOICES, + "gender": GENDER_CHOICES, + "rank": [(rc, rc) for rc in rank_choices], + "unit": [("Not Sure", "Not Sure")] + unit_choices, } - next_url = url_for('main.list_officer', department_id=department.id, - page=officers.next_num, race=form_data['race'], gender=form_data['gender'], rank=form_data['rank'], - min_age=form_data['min_age'], max_age=form_data['max_age'], name=form_data['name'], badge=form_data['badge'], - unique_internal_identifier=form_data['unique_internal_identifier'], unit=form_data['unit']) - prev_url = url_for('main.list_officer', department_id=department.id, - page=officers.prev_num, race=form_data['race'], gender=form_data['gender'], rank=form_data['rank'], - min_age=form_data['min_age'], max_age=form_data['max_age'], name=form_data['name'], badge=form_data['badge'], - unique_internal_identifier=form_data['unique_internal_identifier'], unit=form_data['unit']) + next_url = url_for( + "main.list_officer", + department_id=department.id, + page=officers.next_num, + race=form_data["race"], + gender=form_data["gender"], + rank=form_data["rank"], + min_age=form_data["min_age"], + max_age=form_data["max_age"], + name=form_data["name"], + badge=form_data["badge"], + unique_internal_identifier=form_data["unique_internal_identifier"], + unit=form_data["unit"], + ) + prev_url = url_for( + "main.list_officer", + department_id=department.id, + page=officers.prev_num, + race=form_data["race"], + gender=form_data["gender"], + rank=form_data["rank"], + min_age=form_data["min_age"], + max_age=form_data["max_age"], + name=form_data["name"], + badge=form_data["badge"], + unique_internal_identifier=form_data["unique_internal_identifier"], + unit=form_data["unit"], + ) return render_template( - 'list_officer.html', + "list_officer.html", form=form, department=department, officers=officers, form_data=form_data, choices=choices, next_url=next_url, - prev_url=prev_url) + prev_url=prev_url, + ) -@main.route('/department//ranks') -@main.route('/ranks') +@main.route("/department//ranks") +@main.route("/ranks") def get_dept_ranks(department_id=None, is_sworn_officer=None): if not department_id: - department_id = request.args.get('department_id') - if request.args.get('is_sworn_officer'): - is_sworn_officer = request.args.get('is_sworn_officer') + department_id = request.args.get("department_id") + if request.args.get("is_sworn_officer"): + is_sworn_officer = request.args.get("is_sworn_officer") if department_id: ranks = Job.query.filter_by(department_id=department_id) @@ -583,16 +766,18 @@ def get_dept_ranks(department_id=None, is_sworn_officer=None): rank_list = [(rank.id, rank.job_title) for rank in ranks] else: ranks = Job.query.all() # Not filtering by is_sworn_officer - rank_list = list(set([(rank.id, rank.job_title) for rank in ranks])) # Prevent duplicate ranks + rank_list = list( + set([(rank.id, rank.job_title) for rank in ranks]) + ) # Prevent duplicate ranks return jsonify(rank_list) -@main.route('/officer/new', methods=['GET', 'POST']) +@main.route("/officer/new", methods=["GET", "POST"]) @login_required @ac_or_admin_required def add_officer(): - jsloads = ['js/dynamic_lists.js', 'js/add_officer.js'] + jsloads = ["js/dynamic_lists.js", "js/add_officer.js"] form = AddOfficerForm() for link in form.links: link.creator_id.data = current_user.id @@ -600,36 +785,40 @@ def add_officer(): add_department_query(form, current_user) set_dynamic_default(form.department, current_user.dept_pref_rel) - if form.validate_on_submit() and not current_user.is_administrator and form.department.data.id != current_user.ac_department_id: + if ( + form.validate_on_submit() + and not current_user.is_administrator + and form.department.data.id != current_user.ac_department_id + ): abort(403) if form.validate_on_submit(): # Work around for WTForms limitation with boolean fields in FieldList new_formdata = request.form.copy() for key in new_formdata.keys(): - if re.fullmatch(r'salaries-\d+-is_fiscal_year', key): - new_formdata[key] = 'y' + if re.fullmatch(r"salaries-\d+-is_fiscal_year", key): + new_formdata[key] = "y" form = AddOfficerForm(new_formdata) officer = add_officer_profile(form, current_user) - flash('New Officer {} added to OpenOversight'.format(officer.last_name)) - return redirect(url_for('main.submit_officer_images', officer_id=officer.id)) + flash("New Officer {} added to OpenOversight".format(officer.last_name)) + return redirect(url_for("main.submit_officer_images", officer_id=officer.id)) else: current_app.logger.info(form.errors) - return render_template('add_officer.html', form=form, jsloads=jsloads) + return render_template("add_officer.html", form=form, jsloads=jsloads) -@main.route('/officer//edit', methods=['GET', 'POST']) +@main.route("/officer//edit", methods=["GET", "POST"]) @login_required @ac_or_admin_required def edit_officer(officer_id): - jsloads = ['js/dynamic_lists.js'] + jsloads = ["js/dynamic_lists.js"] officer = Officer.query.filter_by(id=officer_id).one() form = EditOfficerForm(obj=officer) - if request.method == 'GET': + if request.method == "GET": if officer.race is None: - form.race.data = 'Not Sure' + form.race.data = "Not Sure" if officer.gender is None: - form.gender.data = 'Not Sure' + form.gender.data = "Not Sure" if current_user.is_area_coordinator and not current_user.is_administrator: if not ac_can_edit_officer(officer, current_user): @@ -639,14 +828,14 @@ def edit_officer(officer_id): if form.validate_on_submit(): officer = edit_officer_profile(officer, form) - flash('Officer {} edited'.format(officer.last_name)) - return redirect(url_for('main.officer_profile', officer_id=officer.id)) + flash("Officer {} edited".format(officer.last_name)) + return redirect(url_for("main.officer_profile", officer_id=officer.id)) else: current_app.logger.info(form.errors) - return render_template('edit_officer.html', form=form, jsloads=jsloads) + return render_template("edit_officer.html", form=form, jsloads=jsloads) -@main.route('/unit/new', methods=['GET', 'POST']) +@main.route("/unit/new", methods=["GET", "POST"]) @login_required @ac_or_admin_required def add_unit(): @@ -655,25 +844,24 @@ def add_unit(): set_dynamic_default(form.department, current_user.dept_pref_rel) if form.validate_on_submit(): - unit = Unit(descrip=form.descrip.data, - department_id=form.department.data.id) + unit = Unit(descrip=form.descrip.data, department_id=form.department.data.id) db.session.add(unit) db.session.commit() - flash('New unit {} added to OpenOversight'.format(unit.descrip)) - return redirect(url_for('main.get_started_labeling')) + flash("New unit {} added to OpenOversight".format(unit.descrip)) + return redirect(url_for("main.get_started_labeling")) else: current_app.logger.info(form.errors) - return render_template('add_unit.html', form=form) + return render_template("add_unit.html", form=form) -@main.route('/tag/delete/', methods=['POST']) +@main.route("/tag/delete/", methods=["POST"]) @login_required @ac_or_admin_required def delete_tag(tag_id): tag = Face.query.filter_by(id=tag_id).first() if not tag: - flash('Tag not found') + flash("Tag not found") abort(404) if not current_user.is_administrator and current_user.is_area_coordinator: @@ -683,25 +871,26 @@ def delete_tag(tag_id): try: db.session.delete(tag) db.session.commit() - flash('Deleted this tag') - except: # noqa - flash('Unknown error occurred') + flash("Deleted this tag") + except: # noqa: E722 + flash("Unknown error occurred") exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error classifying image: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) - return redirect(url_for('main.index')) + current_app.logger.error( + "Error classifying image: {}".format( + " ".join([str(exception_type), str(value), format_exc()]) + ) + ) + return redirect(url_for("main.index")) -@main.route('/tag/set_featured/', methods=['POST']) +@main.route("/tag/set_featured/", methods=["POST"]) @login_required @ac_or_admin_required def set_featured_tag(tag_id): tag = Face.query.filter_by(id=tag_id).first() if not tag: - flash('Tag not found') + flash("Tag not found") abort(404) if not current_user.is_administrator and current_user.is_area_coordinator: @@ -716,56 +905,66 @@ def set_featured_tag(tag_id): try: db.session.commit() - flash('Successfully set this tag as featured') - except: # noqa - flash('Unknown error occurred') + flash("Successfully set this tag as featured") + except: # noqa: E722 + flash("Unknown error occurred") exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error setting featured tag: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) - return redirect(url_for('main.officer_profile', officer_id=tag.officer_id)) + current_app.logger.error( + "Error setting featured tag: {}".format( + " ".join([str(exception_type), str(value), format_exc()]) + ) + ) + return redirect(url_for("main.officer_profile", officer_id=tag.officer_id)) -@main.route('/leaderboard') +@main.route("/leaderboard") @login_required def leaderboard(): top_sorters, top_taggers = compute_leaderboard_stats() - return render_template('leaderboard.html', top_sorters=top_sorters, - top_taggers=top_taggers) + return render_template( + "leaderboard.html", top_sorters=top_sorters, top_taggers=top_taggers + ) -@main.route('/cop_face/department//image/', - methods=['GET', 'POST']) -@main.route('/cop_face/image/', methods=['GET', 'POST']) -@main.route('/cop_face/department/', methods=['GET', 'POST']) -@main.route('/cop_face/', methods=['GET', 'POST']) +@main.route( + "/cop_face/department//image/", + methods=["GET", "POST"], +) +@main.route("/cop_face/image/", methods=["GET", "POST"]) +@main.route("/cop_face/department/", methods=["GET", "POST"]) +@main.route("/cop_face/", methods=["GET", "POST"]) @login_required def label_data(department_id=None, image_id=None): - jsloads = ['js/cropper.js', 'js/tagger.js'] + jsloads = ["js/cropper.js", "js/tagger.js"] if department_id: department = Department.query.filter_by(id=department_id).one() if image_id: - image = Image.query.filter_by(id=image_id) \ - .filter_by(department_id=department_id).first() + image = ( + Image.query.filter_by(id=image_id) + .filter_by(department_id=department_id) + .first() + ) else: # Get a random image from that department - image_query = Image.query.filter_by(contains_cops=True) \ - .filter_by(department_id=department_id) \ - .filter_by(is_tagged=False) + image_query = ( + Image.query.filter_by(contains_cops=True) + .filter_by(department_id=department_id) + .filter_by(is_tagged=False) + ) image = get_random_image(image_query) else: department = None if image_id: image = Image.query.filter_by(id=image_id).one() else: # Select a random untagged image from the entire database - image_query = Image.query.filter_by(contains_cops=True) \ - .filter_by(is_tagged=False) + image_query = Image.query.filter_by(contains_cops=True).filter_by( + is_tagged=False + ) image = get_random_image(image_query) if image: if image.is_tagged and not current_user.is_administrator: - flash('This image cannot be tagged anymore') - return redirect(url_for('main.label_data')) + flash("This image cannot be tagged anymore") + return redirect(url_for("main.label_data")) proper_path = serve_image(image.filepath) else: proper_path = None @@ -773,48 +972,66 @@ def label_data(department_id=None, image_id=None): form = FaceTag() if form.validate_on_submit(): officer_exists = Officer.query.filter_by(id=form.officer_id.data).first() - existing_tag = db.session.query(Face) \ - .filter(Face.officer_id == form.officer_id.data) \ - .filter(Face.original_image_id == form.image_id.data).first() + existing_tag = ( + db.session.query(Face) + .filter(Face.officer_id == form.officer_id.data) + .filter(Face.original_image_id == form.image_id.data) + .first() + ) if not officer_exists: - flash('Invalid officer ID. Please select a valid OpenOversight ID!') + flash("Invalid officer ID. Please select a valid OpenOversight ID!") elif department and officer_exists.department_id != department_id: - flash('The officer is not in {}. Are you sure that is the correct OpenOversight ID?'.format(department.name)) + flash( + "The officer is not in {}. Are you sure that is the correct OpenOversight ID?".format( + department.name + ) + ) elif not existing_tag: left = form.dataX.data upper = form.dataY.data right = left + form.dataWidth.data lower = upper + form.dataHeight.data - cropped_image = crop_image(image, crop_data=(left, upper, right, lower), department_id=department_id) + cropped_image = crop_image( + image, + crop_data=(left, upper, right, lower), + department_id=department_id, + ) cropped_image.contains_cops = True cropped_image.is_tagged = True if cropped_image: - new_tag = Face(officer_id=form.officer_id.data, - img_id=cropped_image.id, - original_image_id=image.id, - face_position_x=left, - face_position_y=upper, - face_width=form.dataWidth.data, - face_height=form.dataHeight.data, - user_id=current_user.get_id()) + new_tag = Face( + officer_id=form.officer_id.data, + img_id=cropped_image.id, + original_image_id=image.id, + face_position_x=left, + face_position_y=upper, + face_width=form.dataWidth.data, + face_height=form.dataHeight.data, + user_id=current_user.get_id(), + ) db.session.add(new_tag) db.session.commit() - flash('Tag added to database') + flash("Tag added to database") else: - flash('There was a problem saving this tag. Please try again later.') + flash("There was a problem saving this tag. Please try again later.") else: - flash('Tag already exists between this officer and image! Tag not added.') + flash("Tag already exists between this officer and image! Tag not added.") else: current_app.logger.info(form.errors) - return render_template('cop_face.html', form=form, - image=image, path=proper_path, - department=department, jsloads=jsloads) + return render_template( + "cop_face.html", + form=form, + image=image, + path=proper_path, + department=department, + jsloads=jsloads, + ) -@main.route('/image/tagged/') +@main.route("/image/tagged/") @login_required def complete_tagging(image_id): # Select a random untagged image from the database @@ -823,236 +1040,349 @@ def complete_tagging(image_id): abort(404) image.is_tagged = True db.session.commit() - flash('Marked image as completed.') - department_id = request.args.get('department_id') + flash("Marked image as completed.") + department_id = request.args.get("department_id") if department_id: - return redirect(url_for('main.label_data', department_id=department_id)) + return redirect(url_for("main.label_data", department_id=department_id)) else: - return redirect(url_for('main.label_data')) + return redirect(url_for("main.label_data")) -@main.route('/tagger_gallery/', methods=['GET', 'POST']) -@main.route('/tagger_gallery', methods=['GET', 'POST']) +@main.route("/tagger_gallery/", methods=["GET", "POST"]) +@main.route("/tagger_gallery", methods=["GET", "POST"]) def get_tagger_gallery(page=1): form = FindOfficerIDForm() if form.validate_on_submit(): - OFFICERS_PER_PAGE = int(current_app.config['OFFICERS_PER_PAGE']) + OFFICERS_PER_PAGE = int(current_app.config["OFFICERS_PER_PAGE"]) form_data = form.data officers = roster_lookup(form_data).paginate(page, OFFICERS_PER_PAGE, False) - return render_template('tagger_gallery.html', - officers=officers, - form=form, - form_data=form_data) + return render_template( + "tagger_gallery.html", officers=officers, form=form, form_data=form_data + ) else: current_app.logger.info(form.errors) - return redirect(url_for('main.get_ooid'), code=307) + return redirect(url_for("main.get_ooid"), code=307) -@main.route('/complaint', methods=['GET', 'POST']) +@main.route("/complaint", methods=["GET", "POST"]) def submit_complaint(): - return render_template('complaint.html', - officer_first_name=request.args.get('officer_first_name'), - officer_last_name=request.args.get('officer_last_name'), - officer_middle_initial=request.args.get('officer_middle_name'), - officer_star=request.args.get('officer_star'), - officer_image=request.args.get('officer_image')) + return render_template( + "complaint.html", + officer_first_name=request.args.get("officer_first_name"), + officer_last_name=request.args.get("officer_last_name"), + officer_middle_initial=request.args.get("officer_middle_name"), + officer_star=request.args.get("officer_star"), + officer_image=request.args.get("officer_image"), + ) @sitemap_include -@main.route('/submit', methods=['GET', 'POST']) -@limiter.limit('5/minute') +@main.route("/submit", methods=["GET", "POST"]) +@limiter.limit("5/minute") def submit_data(): preferred_dept_id = Department.query.first().id # try to use preferred department if available try: if User.query.filter_by(id=current_user.get_id()).one().dept_pref: - preferred_dept_id = User.query.filter_by(id=current_user.get_id()).one().dept_pref + preferred_dept_id = ( + User.query.filter_by(id=current_user.get_id()).one().dept_pref + ) form = AddImageForm() else: form = AddImageForm() - return render_template('submit_image.html', form=form, preferred_dept_id=preferred_dept_id) + return render_template( + "submit_image.html", form=form, preferred_dept_id=preferred_dept_id + ) # that is, an anonymous user has no id attribute except (AttributeError, NoResultFound): preferred_dept_id = Department.query.first().id form = AddImageForm() - return render_template('submit_image.html', form=form, preferred_dept_id=preferred_dept_id) + return render_template( + "submit_image.html", form=form, preferred_dept_id=preferred_dept_id + ) -@main.route('/download/department//officers', methods=['GET']) -@limiter.limit('5/minute') +@main.route("/download/department//officers", methods=["GET"]) +@limiter.limit("5/minute") def download_dept_officers_csv(department_id): - officers = (db.session.query(Officer) - .options(joinedload(Officer.assignments_lazy) - .joinedload(Assignment.job) - ) - .options(joinedload(Officer.salaries)) - .filter_by(department_id=department_id) - ) - - field_names = ["id", "unique identifier", "last name", "first name", "middle initial", "suffix", "gender", - "race", "birth year", "employment date", "badge number", "job title", "most recent salary"] - return downloads.make_downloadable_csv(officers, department_id, "Officers", field_names, downloads.officer_record_maker) - - -@main.route('/download/department//assignments', methods=['GET']) -@limiter.limit('5/minute') + officers = ( + db.session.query(Officer) + .options(joinedload(Officer.assignments_lazy).joinedload(Assignment.job)) + .options(joinedload(Officer.salaries)) + .filter_by(department_id=department_id) + ) + + field_names = [ + "id", + "unique identifier", + "last name", + "first name", + "middle initial", + "suffix", + "gender", + "race", + "birth year", + "employment date", + "badge number", + "job title", + "most recent salary", + ] + return downloads.make_downloadable_csv( + officers, department_id, "Officers", field_names, downloads.officer_record_maker + ) + + +@main.route("/download/department//assignments", methods=["GET"]) +@limiter.limit("5/minute") def download_dept_assignments_csv(department_id): - assignments = (db.session.query(Assignment) - .join(Assignment.baseofficer) - .filter(Officer.department_id == department_id) - .options(contains_eager(Assignment.baseofficer)) - .options(joinedload(Assignment.unit)) - .options(joinedload(Assignment.job)) - ) - - field_names = ["id", "officer id", "officer unique identifier", "badge number", "job title", "start date", "end date", "unit id", "unit description"] - return downloads.make_downloadable_csv(assignments, department_id, "Assignments", field_names, downloads.assignment_record_maker) - - -@main.route('/download/department//incidents', methods=['GET']) -@limiter.limit('5/minute') + assignments = ( + db.session.query(Assignment) + .join(Assignment.baseofficer) + .filter(Officer.department_id == department_id) + .options(contains_eager(Assignment.baseofficer)) + .options(joinedload(Assignment.unit)) + .options(joinedload(Assignment.job)) + ) + + field_names = [ + "id", + "officer id", + "officer unique identifier", + "badge number", + "job title", + "start date", + "end date", + "unit id", + "unit description", + ] + return downloads.make_downloadable_csv( + assignments, + department_id, + "Assignments", + field_names, + downloads.assignment_record_maker, + ) + + +@main.route("/download/department//incidents", methods=["GET"]) +@limiter.limit("5/minute") def download_incidents_csv(department_id): incidents = Incident.query.filter_by(department_id=department_id).all() - field_names = ["id", "report_num", "date", "time", "description", "location", "licenses", "links", "officers"] - return downloads.make_downloadable_csv(incidents, department_id, "Incidents", field_names, downloads.incidents_record_maker) - - -@main.route('/download/department//salaries', methods=['GET']) -@limiter.limit('5/minute') + field_names = [ + "id", + "report_num", + "date", + "time", + "description", + "location", + "licenses", + "links", + "officers", + ] + return downloads.make_downloadable_csv( + incidents, + department_id, + "Incidents", + field_names, + downloads.incidents_record_maker, + ) + + +@main.route("/download/department//salaries", methods=["GET"]) +@limiter.limit("5/minute") def download_dept_salaries_csv(department_id): - salaries = (db.session.query(Salary) - .join(Salary.officer) - .filter(Officer.department_id == department_id) - .options(contains_eager(Salary.officer)) - ) - - field_names = ["id", "officer id", "first name", "last name", "salary", "overtime_pay", "year", "is_fiscal_year"] - return downloads.make_downloadable_csv(salaries, department_id, "Salaries", field_names, downloads.salary_record_maker) - - -@main.route('/download/department//links', methods=['GET']) -@limiter.limit('5/minute') + salaries = ( + db.session.query(Salary) + .join(Salary.officer) + .filter(Officer.department_id == department_id) + .options(contains_eager(Salary.officer)) + ) + + field_names = [ + "id", + "officer id", + "first name", + "last name", + "salary", + "overtime_pay", + "year", + "is_fiscal_year", + ] + return downloads.make_downloadable_csv( + salaries, department_id, "Salaries", field_names, downloads.salary_record_maker + ) + + +@main.route("/download/department//links", methods=["GET"]) +@limiter.limit("5/minute") def download_dept_links_csv(department_id): - links = (db.session.query(Link) - .join(Link.officers) - .filter(Officer.department_id == department_id) - .options(contains_eager(Link.officers)) - ) - - field_names = ["id", "title", "url", "link_type", "description", "author", "officers", "incidents"] - return downloads.make_downloadable_csv(links, department_id, "Links", field_names, downloads.links_record_maker) - - -@main.route('/download/department//descriptions', methods=['GET']) -@limiter.limit('5/minute') + links = ( + db.session.query(Link) + .join(Link.officers) + .filter(Officer.department_id == department_id) + .options(contains_eager(Link.officers)) + ) + + field_names = [ + "id", + "title", + "url", + "link_type", + "description", + "author", + "officers", + "incidents", + ] + return downloads.make_downloadable_csv( + links, department_id, "Links", field_names, downloads.links_record_maker + ) + + +@main.route("/download/department//descriptions", methods=["GET"]) +@limiter.limit("5/minute") def download_dept_descriptions_csv(department_id): - notes = (db.session.query(Description) - .join(Description.officer) - .filter(Officer.department_id == department_id) - .options(contains_eager(Description.officer)) - ) - - field_names = ["id", "text_contents", "creator_id", "officer_id", "date_created", "date_updated"] - return downloads.make_downloadable_csv(notes, department_id, "Notes", field_names, downloads.descriptions_record_maker) + notes = ( + db.session.query(Description) + .join(Description.officer) + .filter(Officer.department_id == department_id) + .options(contains_eager(Description.officer)) + ) + + field_names = [ + "id", + "text_contents", + "creator_id", + "officer_id", + "date_created", + "date_updated", + ] + return downloads.make_downloadable_csv( + notes, department_id, "Notes", field_names, downloads.descriptions_record_maker + ) @sitemap_include -@main.route('/download/all', methods=['GET']) +@main.route("/download/all", methods=["GET"]) def all_data(): departments = Department.query.filter(Department.officers.any()) - return render_template('all_depts.html', departments=departments) + return render_template("all_depts.html", departments=departments) -@main.route('/submit_officer_images/officer/', methods=['GET', 'POST']) +@main.route("/submit_officer_images/officer/", methods=["GET", "POST"]) @login_required @ac_or_admin_required def submit_officer_images(officer_id): officer = Officer.query.get_or_404(officer_id) - return render_template('submit_officer_image.html', officer=officer) + return render_template("submit_officer_image.html", officer=officer) -@main.route('/upload/department/', methods=['POST']) -@main.route('/upload/department//officer/', methods=['POST']) -@limiter.limit('250/minute') +@main.route("/upload/department/", methods=["POST"]) +@main.route( + "/upload/department//officer/", methods=["POST"] +) +@limiter.limit("250/minute") def upload(department_id, officer_id=None): if officer_id: officer = Officer.query.filter_by(id=officer_id).first() if not officer: - return jsonify(error='This officer does not exist.'), 404 - if not (current_user.is_administrator or - (current_user.is_area_coordinator and officer.department_id == current_user.ac_department_id)): - return jsonify(error='You are not authorized to upload photos of this officer.'), 403 - file_to_upload = request.files['file'] + return jsonify(error="This officer does not exist."), 404 + if not ( + current_user.is_administrator + or ( + current_user.is_area_coordinator + and officer.department_id == current_user.ac_department_id + ) + ): + return ( + jsonify( + error="You are not authorized to upload photos of this officer." + ), + 403, + ) + file_to_upload = request.files["file"] if not allowed_file(file_to_upload.filename): return jsonify(error="File type not allowed!"), 415 - image = upload_image_to_s3_and_store_in_db(file_to_upload, current_user.get_id(), department_id=department_id) + image = upload_image_to_s3_and_store_in_db( + file_to_upload, current_user.get_id(), department_id=department_id + ) if image: db.session.add(image) if officer_id: image.is_tagged = True image.contains_cops = True - face = Face(officer_id=officer_id, - # Assuming photos uploaded with an officer ID are already cropped, so we set both images to the uploaded one - img_id=image.id, - original_image_id=image.id, - user_id=current_user.get_id()) + face = Face( + officer_id=officer_id, + # Assuming photos uploaded with an officer ID are already cropped, so we set both images to the uploaded one + img_id=image.id, + original_image_id=image.id, + user_id=current_user.get_id(), + ) db.session.add(face) db.session.commit() - return jsonify(success='Success!'), 200 + return jsonify(success="Success!"), 200 else: return jsonify(error="Server error encountered. Try again later."), 500 @sitemap_include -@main.route('/about') +@main.route("/about") def about_oo(): - return render_template('about.html') + return render_template("about.html") @sitemap_include -@main.route('/privacy') +@main.route("/privacy") def privacy_oo(): - return render_template('privacy.html') + return render_template("privacy.html") -@main.route('/shutdown') # pragma: no cover -def server_shutdown(): # pragma: no cover +@main.route("/shutdown") # pragma: no cover +def server_shutdown(): # pragma: no cover if not current_app.testing: abort(404) - shutdown = request.environ.get('werkzeug.server.shutdown') + shutdown = request.environ.get("werkzeug.server.shutdown") if not shutdown: abort(500) shutdown() - return 'Shutting down...' + return "Shutting down..." class IncidentApi(ModelView): model = Incident - model_name = 'incident' - order_by = 'date' + model_name = "incident" + order_by = "date" descending = True form = IncidentForm create_function = create_incident department_check = True def get(self, obj_id): - if request.args.get('page'): - page = int(request.args.get('page')) + if request.args.get("page"): + page = int(request.args.get("page")) else: page = 1 - if request.args.get('department_id'): - department_id = request.args.get('department_id') + if request.args.get("department_id"): + department_id = request.args.get("department_id") dept = Department.query.get_or_404(department_id) - obj = self.model.query.filter_by(department_id=department_id).order_by(getattr(self.model, self.order_by).desc()).paginate(page, self.per_page, False) - return render_template('{}_list.html'.format(self.model_name), objects=obj, url='main.{}_api'.format(self.model_name), department=dept) + obj = ( + self.model.query.filter_by(department_id=department_id) + .order_by(getattr(self.model, self.order_by).desc()) + .paginate(page, self.per_page, False) + ) + return render_template( + "{}_list.html".format(self.model_name), + objects=obj, + url="main.{}_api".format(self.model_name), + department=dept, + ) else: return super(IncidentApi, self).get(obj_id) def get_new_form(self): form = self.form() - if request.args.get('officer_id'): - form.officers[0].oo_id.data = request.args.get('officer_id') + if request.args.get("officer_id"): + form.officers[0].oo_id.data = request.args.get("officer_id") for link in form.links: link.creator_id.data = current_user.id @@ -1086,20 +1416,20 @@ def get_edit_form(self, obj): def populate_obj(self, form, obj): # remove all fields not directly on the Incident model # use utils to add them to the current object - address = form.data.pop('address') + address = form.data.pop("address") del form.address - if address['city']: + if address["city"]: new_address, _ = get_or_create(db.session, Location, **address) obj.address = new_address else: obj.address = None - links = form.data.pop('links') + links = form.data.pop("links") del form.links - if links and links[0]['url']: - replace_list(links, obj, 'links', Link, db) + if links and links[0]["url"]: + replace_list(links, obj, "links", Link, db) - officers = form.data.pop('officers') + officers = form.data.pop("officers") del form.officers if officers: for officer in officers: @@ -1108,56 +1438,46 @@ def populate_obj(self, form, obj): of = Officer.query.filter_by(id=int(officer["oo_id"])).first() # Sometimes we get a string in officer["oo_id"], this parses it except ValueError: - our_id = officer["oo_id"].split("value=\"")[1][:-2] + our_id = officer["oo_id"].split('value="')[1][:-2] of = Officer.query.filter_by(id=int(our_id)).first() if of: obj.officers.append(of) - license_plates = form.data.pop('license_plates') + license_plates = form.data.pop("license_plates") del form.license_plates - if license_plates and license_plates[0]['number']: - replace_list(license_plates, obj, 'license_plates', LicensePlate, db) + if license_plates and license_plates[0]["number"]: + replace_list(license_plates, obj, "license_plates", LicensePlate, db) obj.date = form.date_field.data - if form.time_field.raw_data and form.time_field.raw_data != ['']: + if form.time_field.raw_data and form.time_field.raw_data != [""]: obj.time = form.time_field.data else: obj.time = None super(IncidentApi, self).populate_obj(form, obj) -incident_view = IncidentApi.as_view('incident_api') -main.add_url_rule( - '/incidents/', - defaults={'obj_id': None}, - view_func=incident_view, - methods=['GET']) -main.add_url_rule( - '/incidents/new', - view_func=incident_view, - methods=['GET', 'POST']) +incident_view = IncidentApi.as_view("incident_api") main.add_url_rule( - '/incidents/', - view_func=incident_view, - methods=['GET']) + "/incidents/", defaults={"obj_id": None}, view_func=incident_view, methods=["GET"] +) +main.add_url_rule("/incidents/new", view_func=incident_view, methods=["GET", "POST"]) +main.add_url_rule("/incidents/", view_func=incident_view, methods=["GET"]) main.add_url_rule( - '/incidents//edit', - view_func=incident_view, - methods=['GET', 'POST']) + "/incidents//edit", view_func=incident_view, methods=["GET", "POST"] +) main.add_url_rule( - '/incidents//delete', - view_func=incident_view, - methods=['GET', 'POST']) + "/incidents//delete", view_func=incident_view, methods=["GET", "POST"] +) @sitemap.register_generator def sitemap_incidents(): for incident in Incident.query.all(): - yield 'main.incident_api', {'obj_id': incident.id} + yield "main.incident_api", {"obj_id": incident.id} class TextApi(ModelView): - order_by = 'date_created' + order_by = "date_created" descending = True department_check = True form = TextForm @@ -1168,7 +1488,7 @@ def get_new_form(self): return form def get_redirect_url(self, *args, **kwargs): - return redirect(url_for('main.officer_profile', officer_id=self.officer_id)) + return redirect(url_for("main.officer_profile", officer_id=self.officer_id)) def get_post_delete_url(self, *args, **kwargs): return self.get_redirect_url() @@ -1181,16 +1501,16 @@ def get_edit_form(self, obj): return form def dispatch_request(self, *args, **kwargs): - if 'officer_id' in kwargs: - officer = Officer.query.get_or_404(kwargs['officer_id']) - self.officer_id = kwargs.pop('officer_id') + if "officer_id" in kwargs: + officer = Officer.query.get_or_404(kwargs["officer_id"]) + self.officer_id = kwargs.pop("officer_id") self.department_id = officer.department_id return super(TextApi, self).dispatch_request(*args, **kwargs) class NoteApi(TextApi): model = Note - model_name = 'note' + model_name = "note" form = TextForm create_function = create_note @@ -1200,7 +1520,7 @@ def dispatch_request(self, *args, **kwargs): class DescriptionApi(TextApi): model = Description - model_name = 'description' + model_name = "description" form = TextForm create_function = create_description @@ -1208,65 +1528,74 @@ def dispatch_request(self, *args, **kwargs): return super(DescriptionApi, self).dispatch_request(*args, **kwargs) -note_view = NoteApi.as_view('note_api') +note_view = NoteApi.as_view("note_api") main.add_url_rule( - '/officer//note/new', - view_func=note_view, - methods=['GET', 'POST']) + "/officer//note/new", view_func=note_view, methods=["GET", "POST"] +) main.add_url_rule( - '/officer//note/', - view_func=note_view, - methods=['GET']) + "/officer//note/", view_func=note_view, methods=["GET"] +) main.add_url_rule( - '/officer//note//edit', + "/officer//note//edit", view_func=note_view, - methods=['GET', 'POST']) + methods=["GET", "POST"], +) main.add_url_rule( - '/officer//note//delete', + "/officer//note//delete", view_func=note_view, - methods=['GET', 'POST']) + methods=["GET", "POST"], +) -description_view = DescriptionApi.as_view('description_api') +description_view = DescriptionApi.as_view("description_api") main.add_url_rule( - '/officer//description/new', + "/officer//description/new", view_func=description_view, - methods=['GET', 'POST']) + methods=["GET", "POST"], +) main.add_url_rule( - '/officer//description/', + "/officer//description/", view_func=description_view, - methods=['GET']) + methods=["GET"], +) main.add_url_rule( - '/officer//description//edit', + "/officer//description//edit", view_func=description_view, - methods=['GET', 'POST']) + methods=["GET", "POST"], +) main.add_url_rule( - '/officer//description//delete', + "/officer//description//delete", view_func=description_view, - methods=['GET', 'POST']) + methods=["GET", "POST"], +) class OfficerLinkApi(ModelView): - '''This API only applies to links attached to officer profiles, not links attached to incidents''' + """This API only applies to links attached to officer profiles, not links attached to incidents""" model = Link - model_name = 'link' + model_name = "link" form = OfficerLinkForm department_check = True @property def officer(self): - if not hasattr(self, '_officer'): - self._officer = db.session.query(Officer).filter_by(id=self.officer_id).one() + if not hasattr(self, "_officer"): + self._officer = ( + db.session.query(Officer).filter_by(id=self.officer_id).one() + ) return self._officer @login_required @ac_or_admin_required def new(self, form=None): - if not current_user.is_administrator and current_user.ac_department_id != self.officer.department_id: + if ( + not current_user.is_administrator + and current_user.ac_department_id != self.officer.department_id + ): abort(403) if not form: form = self.get_new_form() - if hasattr(form, 'creator_id') and not form.creator_id.data: + if hasattr(form, "creator_id") and not form.creator_id.data: form.creator_id.data = current_user.get_id() if form.validate_on_submit(): @@ -1276,29 +1605,37 @@ def new(self, form=None): link_type=form.link_type.data, description=form.description.data, author=form.author.data, - creator_id=form.creator_id.data) + creator_id=form.creator_id.data, + ) self.officer.links.append(link) db.session.add(link) db.session.commit() - flash('{} created!'.format(self.model_name)) + flash("{} created!".format(self.model_name)) return self.get_redirect_url(obj_id=link.id) - return render_template('{}_new.html'.format(self.model_name), form=form) + return render_template("{}_new.html".format(self.model_name), form=form) @login_required @ac_or_admin_required def delete(self, obj_id): obj = self.model.query.get_or_404(obj_id) - if not current_user.is_administrator and current_user.ac_department_id != self.get_department_id(obj): + if ( + not current_user.is_administrator + and current_user.ac_department_id != self.get_department_id(obj) + ): abort(403) - if request.method == 'POST': + if request.method == "POST": db.session.delete(obj) db.session.commit() - flash('{} successfully deleted!'.format(self.model_name)) + flash("{} successfully deleted!".format(self.model_name)) return self.get_post_delete_url() - return render_template('{}_delete.html'.format(self.model_name), obj=obj, officer_id=self.officer_id) + return render_template( + "{}_delete.html".format(self.model_name), + obj=obj, + officer_id=self.officer_id, + ) def get_new_form(self): form = self.form() @@ -1311,7 +1648,7 @@ def get_edit_form(self, obj): return form def get_redirect_url(self, *args, **kwargs): - return redirect(url_for('main.officer_profile', officer_id=self.officer_id)) + return redirect(url_for("main.officer_profile", officer_id=self.officer_id)) def get_post_delete_url(self, *args, **kwargs): return self.get_redirect_url() @@ -1320,22 +1657,25 @@ def get_department_id(self, obj): return self.officer.department_id def dispatch_request(self, *args, **kwargs): - if 'officer_id' in kwargs: - officer = Officer.query.get_or_404(kwargs['officer_id']) - self.officer_id = kwargs.pop('officer_id') + if "officer_id" in kwargs: + officer = Officer.query.get_or_404(kwargs["officer_id"]) + self.officer_id = kwargs.pop("officer_id") self.department_id = officer.department_id return super(OfficerLinkApi, self).dispatch_request(*args, **kwargs) main.add_url_rule( - '/officer//link/new', - view_func=OfficerLinkApi.as_view('link_api_new'), - methods=['GET', 'POST']) + "/officer//link/new", + view_func=OfficerLinkApi.as_view("link_api_new"), + methods=["GET", "POST"], +) main.add_url_rule( - '/officer//link//edit', - view_func=OfficerLinkApi.as_view('link_api_edit'), - methods=['GET', 'POST']) + "/officer//link//edit", + view_func=OfficerLinkApi.as_view("link_api_edit"), + methods=["GET", "POST"], +) main.add_url_rule( - '/officer//link//delete', - view_func=OfficerLinkApi.as_view('link_api_delete'), - methods=['GET', 'POST']) + "/officer//link//delete", + view_func=OfficerLinkApi.as_view("link_api_delete"), + methods=["GET", "POST"], +) diff --git a/OpenOversight/app/model_imports.py b/OpenOversight/app/model_imports.py index 1f50c4d4b..27e6eec8d 100644 --- a/OpenOversight/app/model_imports.py +++ b/OpenOversight/app/model_imports.py @@ -16,8 +16,9 @@ from .utils import get_or_create, str_is_true from .validators import state_validator, url_validator + if TYPE_CHECKING: - import datetime # noqa + import datetime def validate_choice( @@ -238,7 +239,12 @@ def get_or_create_license_plate_from_dict( number = data["number"] state = parse_str(data.get("state"), None) state_validator(state) - return get_or_create(db.session, LicensePlate, number=number, state=state,) + return get_or_create( + db.session, + LicensePlate, + number=number, + state=state, + ) def get_or_create_location_from_dict( diff --git a/OpenOversight/app/models.py b/OpenOversight/app/models.py index 4f81adc3e..e0d63017d 100644 --- a/OpenOversight/app/models.py +++ b/OpenOversight/app/models.py @@ -1,97 +1,111 @@ import re from datetime import date +from flask import current_app +from flask_login import UserMixin from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy.model import DefaultMeta -from sqlalchemy.orm import validates -from sqlalchemy import UniqueConstraint, CheckConstraint -from werkzeug.security import generate_password_hash, check_password_hash +from itsdangerous import BadData, BadSignature from itsdangerous import TimedJSONWebSignatureSerializer as Serializer -from itsdangerous import BadSignature, BadData -from flask_login import UserMixin -from flask import current_app -from .validators import state_validator, url_validator +from sqlalchemy import CheckConstraint, UniqueConstraint +from sqlalchemy.orm import validates +from werkzeug.security import check_password_hash, generate_password_hash + from . import login_manager +from .validators import state_validator, url_validator + db = SQLAlchemy() BaseModel = db.Model # type: DefaultMeta -officer_links = db.Table('officer_links', - db.Column('officer_id', db.Integer, db.ForeignKey('officers.id'), primary_key=True), - db.Column('link_id', db.Integer, db.ForeignKey('links.id'), primary_key=True)) +officer_links = db.Table( + "officer_links", + db.Column("officer_id", db.Integer, db.ForeignKey("officers.id"), primary_key=True), + db.Column("link_id", db.Integer, db.ForeignKey("links.id"), primary_key=True), +) -officer_incidents = db.Table('officer_incidents', - db.Column('officer_id', db.Integer, db.ForeignKey('officers.id'), primary_key=True), - db.Column('incident_id', db.Integer, db.ForeignKey('incidents.id'), primary_key=True)) +officer_incidents = db.Table( + "officer_incidents", + db.Column("officer_id", db.Integer, db.ForeignKey("officers.id"), primary_key=True), + db.Column( + "incident_id", db.Integer, db.ForeignKey("incidents.id"), primary_key=True + ), +) class Department(BaseModel): - __tablename__ = 'departments' + __tablename__ = "departments" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(255), index=True, unique=True, nullable=False) short_name = db.Column(db.String(100), unique=False, nullable=False) - unique_internal_identifier_label = db.Column(db.String(100), unique=False, nullable=True) + unique_internal_identifier_label = db.Column( + db.String(100), unique=False, nullable=True + ) def __repr__(self): - return ''.format(self.id, self.name) + return "".format(self.id, self.name) def toCustomDict(self): - return {'id': self.id, - 'name': self.name, - 'short_name': self.short_name, - 'unique_internal_identifier_label': self.unique_internal_identifier_label - } + return { + "id": self.id, + "name": self.name, + "short_name": self.short_name, + "unique_internal_identifier_label": self.unique_internal_identifier_label, + } class Job(BaseModel): - __tablename__ = 'jobs' + __tablename__ = "jobs" id = db.Column(db.Integer, primary_key=True) job_title = db.Column(db.String(255), index=True, unique=False, nullable=False) is_sworn_officer = db.Column(db.Boolean, index=True, default=True) order = db.Column(db.Integer, index=True, unique=False, nullable=False) - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='jobs') + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship("Department", backref="jobs") - __table_args__ = (UniqueConstraint('job_title', 'department_id', - name='unique_department_job_titles'), ) + __table_args__ = ( + UniqueConstraint( + "job_title", "department_id", name="unique_department_job_titles" + ), + ) def __repr__(self): - return ''.format(self.id, self.job_title) + return "".format(self.id, self.job_title) def __str__(self): return self.job_title class Note(BaseModel): - __tablename__ = 'notes' + __tablename__ = "notes" id = db.Column(db.Integer, primary_key=True) text_contents = db.Column(db.Text()) - creator_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL')) - creator = db.relationship('User', backref='notes') - officer_id = db.Column(db.Integer, db.ForeignKey('officers.id', ondelete='CASCADE')) - officer = db.relationship('Officer', back_populates='notes') + creator_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) + creator = db.relationship("User", backref="notes") + officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) + officer = db.relationship("Officer", back_populates="notes") date_created = db.Column(db.DateTime) date_updated = db.Column(db.DateTime) class Description(BaseModel): - __tablename__ = 'descriptions' + __tablename__ = "descriptions" - creator = db.relationship('User', backref='descriptions') - officer = db.relationship('Officer', back_populates='descriptions') + creator = db.relationship("User", backref="descriptions") + officer = db.relationship("Officer", back_populates="descriptions") id = db.Column(db.Integer, primary_key=True) text_contents = db.Column(db.Text()) - creator_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL')) - officer_id = db.Column(db.Integer, db.ForeignKey('officers.id', ondelete='CASCADE')) + creator_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) + officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) date_created = db.Column(db.DateTime) date_updated = db.Column(db.DateTime) class Officer(BaseModel): - __tablename__ = 'officers' + __tablename__ = "officers" id = db.Column(db.Integer, primary_key=True) last_name = db.Column(db.String(120), index=True, unique=False) @@ -102,207 +116,249 @@ class Officer(BaseModel): gender = db.Column(db.String(5), index=True, unique=False, nullable=True) employment_date = db.Column(db.Date, index=True, unique=False, nullable=True) birth_year = db.Column(db.Integer, index=True, unique=False, nullable=True) - assignments = db.relationship('Assignment', backref='officer', lazy='dynamic') - assignments_lazy = db.relationship('Assignment') - face = db.relationship('Face', backref='officer') - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='officers') - unique_internal_identifier = db.Column(db.String(50), index=True, unique=True, nullable=True) + assignments = db.relationship("Assignment", backref="officer", lazy="dynamic") + assignments_lazy = db.relationship("Assignment") + face = db.relationship("Face", backref="officer") + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship("Department", backref="officers") + unique_internal_identifier = db.Column( + db.String(50), index=True, unique=True, nullable=True + ) links = db.relationship( - 'Link', - secondary=officer_links, - backref=db.backref('officers', lazy=True)) - notes = db.relationship('Note', back_populates='officer', order_by='Note.date_created') - descriptions = db.relationship('Description', back_populates='officer', order_by='Description.date_created') - salaries = db.relationship('Salary', back_populates='officer', order_by='Salary.year.desc()') + "Link", secondary=officer_links, backref=db.backref("officers", lazy=True) + ) + notes = db.relationship( + "Note", back_populates="officer", order_by="Note.date_created" + ) + descriptions = db.relationship( + "Description", back_populates="officer", order_by="Description.date_created" + ) + salaries = db.relationship( + "Salary", back_populates="officer", order_by="Salary.year.desc()" + ) __table_args__ = ( - CheckConstraint("gender in ('M', 'F', 'Other')", name='gender_options'), + CheckConstraint("gender in ('M', 'F', 'Other')", name="gender_options"), ) def full_name(self): if self.middle_initial: - middle_initial = self.middle_initial + '.' if len(self.middle_initial) == 1 else self.middle_initial + middle_initial = ( + self.middle_initial + "." + if len(self.middle_initial) == 1 + else self.middle_initial + ) if self.suffix: - return '{} {} {} {}'.format(self.first_name, middle_initial, self.last_name, self.suffix) + return "{} {} {} {}".format( + self.first_name, middle_initial, self.last_name, self.suffix + ) else: - return '{} {} {}'.format(self.first_name, middle_initial, self.last_name) + return "{} {} {}".format( + self.first_name, middle_initial, self.last_name + ) if self.suffix: - return '{} {} {}'.format(self.first_name, self.last_name, self.suffix) - return '{} {}'.format(self.first_name, self.last_name) + return "{} {} {}".format(self.first_name, self.last_name, self.suffix) + return "{} {}".format(self.first_name, self.last_name) def race_label(self): if self.race is None: - return 'Data Missing' + return "Data Missing" from .main.choices import RACE_CHOICES + for race, label in RACE_CHOICES: if self.race == race: return label def gender_label(self): if self.gender is None: - return 'Data Missing' + return "Data Missing" from .main.choices import GENDER_CHOICES + for gender, label in GENDER_CHOICES: if self.gender == gender: return label def job_title(self): if self.assignments_lazy: - return max(self.assignments_lazy, key=lambda x: x.star_date or date.min).job.job_title + return max( + self.assignments_lazy, key=lambda x: x.star_date or date.min + ).job.job_title def badge_number(self): if self.assignments_lazy: - return max(self.assignments_lazy, key=lambda x: x.star_date or date.min).star_no + return max( + self.assignments_lazy, key=lambda x: x.star_date or date.min + ).star_no def __repr__(self): if self.unique_internal_identifier: - return ''.format(self.id, - self.first_name, - self.middle_initial, - self.last_name, - self.suffix, - self.unique_internal_identifier) - return ''.format(self.id, - self.first_name, - self.middle_initial, - self.last_name, - self.suffix) + return "".format( + self.id, + self.first_name, + self.middle_initial, + self.last_name, + self.suffix, + self.unique_internal_identifier, + ) + return "".format( + self.id, self.first_name, self.middle_initial, self.last_name, self.suffix + ) class Salary(BaseModel): - __tablename__ = 'salaries' + __tablename__ = "salaries" id = db.Column(db.Integer, primary_key=True) - officer_id = db.Column(db.Integer, db.ForeignKey('officers.id', ondelete='CASCADE')) - officer = db.relationship('Officer', back_populates='salaries') + officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) + officer = db.relationship("Officer", back_populates="salaries") salary = db.Column(db.Numeric, index=True, unique=False, nullable=False) overtime_pay = db.Column(db.Numeric, index=True, unique=False, nullable=True) year = db.Column(db.Integer, index=True, unique=False, nullable=False) is_fiscal_year = db.Column(db.Boolean, index=False, unique=False, nullable=False) def __repr__(self): - return ''.format(self.officer_id, - self.star_no) + return "".format(self.officer_id, self.star_no) class Unit(BaseModel): - __tablename__ = 'unit_types' + __tablename__ = "unit_types" id = db.Column(db.Integer, primary_key=True) descrip = db.Column(db.String(120), index=True, unique=False) - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='unit_types', order_by='Unit.descrip.asc()') + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship( + "Department", backref="unit_types", order_by="Unit.descrip.asc()" + ) def __repr__(self): - return 'Unit: {}'.format(self.descrip) + return "Unit: {}".format(self.descrip) class Face(BaseModel): - __tablename__ = 'faces' + __tablename__ = "faces" id = db.Column(db.Integer, primary_key=True) - officer_id = db.Column(db.Integer, db.ForeignKey('officers.id')) + officer_id = db.Column(db.Integer, db.ForeignKey("officers.id")) img_id = db.Column( db.Integer, db.ForeignKey( - 'raw_images.id', - ondelete='CASCADE', - onupdate='CASCADE', - name='fk_face_image_id', - use_alter=True), + "raw_images.id", + ondelete="CASCADE", + onupdate="CASCADE", + name="fk_face_image_id", + use_alter=True, + ), ) original_image_id = db.Column( db.Integer, db.ForeignKey( - 'raw_images.id', - ondelete='SET NULL', - onupdate='CASCADE', + "raw_images.id", + ondelete="SET NULL", + onupdate="CASCADE", use_alter=True, - name='fk_face_original_image_id') + name="fk_face_original_image_id", + ), ) face_position_x = db.Column(db.Integer, unique=False) face_position_y = db.Column(db.Integer, unique=False) face_width = db.Column(db.Integer, unique=False) face_height = db.Column(db.Integer, unique=False) - image = db.relationship('Image', backref='faces', foreign_keys=[img_id]) - original_image = db.relationship('Image', backref='tags', foreign_keys=[original_image_id], lazy=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - user = db.relationship('User', backref='faces') - featured = db.Column(db.Boolean, nullable=False, default=False, server_default='false') + image = db.relationship("Image", backref="faces", foreign_keys=[img_id]) + original_image = db.relationship( + "Image", backref="tags", foreign_keys=[original_image_id], lazy=True + ) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + user = db.relationship("User", backref="faces") + featured = db.Column( + db.Boolean, nullable=False, default=False, server_default="false" + ) - __table_args__ = (UniqueConstraint('officer_id', 'img_id', - name='unique_faces'), ) + __table_args__ = (UniqueConstraint("officer_id", "img_id", name="unique_faces"),) def __repr__(self): - return ''.format(self.id, self.officer_id, self.img_id) + return "".format(self.id, self.officer_id, self.img_id) class Image(BaseModel): - __tablename__ = 'raw_images' + __tablename__ = "raw_images" id = db.Column(db.Integer, primary_key=True) filepath = db.Column(db.String(255), unique=False) hash_img = db.Column(db.String(120), unique=False, nullable=True) # Track when the image was put into our database - date_image_inserted = db.Column(db.DateTime, index=True, unique=False, nullable=True) + date_image_inserted = db.Column( + db.DateTime, index=True, unique=False, nullable=True + ) # We might know when the image was taken e.g. through EXIF data date_image_taken = db.Column(db.DateTime, index=True, unique=False, nullable=True) contains_cops = db.Column(db.Boolean, nullable=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) - user = db.relationship('User', backref='raw_images') + user = db.relationship("User", backref="raw_images") is_tagged = db.Column(db.Boolean, default=False, unique=False, nullable=True) - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='raw_images') + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship("Department", backref="raw_images") def __repr__(self): - return ''.format(self.id, self.filepath) + return "".format(self.id, self.filepath) incident_links = db.Table( - 'incident_links', - db.Column('incident_id', db.Integer, db.ForeignKey('incidents.id'), primary_key=True), - db.Column('link_id', db.Integer, db.ForeignKey('links.id'), primary_key=True) + "incident_links", + db.Column( + "incident_id", db.Integer, db.ForeignKey("incidents.id"), primary_key=True + ), + db.Column("link_id", db.Integer, db.ForeignKey("links.id"), primary_key=True), ) incident_license_plates = db.Table( - 'incident_license_plates', - db.Column('incident_id', db.Integer, db.ForeignKey('incidents.id'), primary_key=True), - db.Column('license_plate_id', db.Integer, db.ForeignKey('license_plates.id'), primary_key=True) + "incident_license_plates", + db.Column( + "incident_id", db.Integer, db.ForeignKey("incidents.id"), primary_key=True + ), + db.Column( + "license_plate_id", + db.Integer, + db.ForeignKey("license_plates.id"), + primary_key=True, + ), ) incident_officers = db.Table( - 'incident_officers', - db.Column('incident_id', db.Integer, db.ForeignKey('incidents.id'), primary_key=True), - db.Column('officers_id', db.Integer, db.ForeignKey('officers.id'), primary_key=True) + "incident_officers", + db.Column( + "incident_id", db.Integer, db.ForeignKey("incidents.id"), primary_key=True + ), + db.Column( + "officers_id", db.Integer, db.ForeignKey("officers.id"), primary_key=True + ), ) class Location(BaseModel): - __tablename__ = 'locations' + __tablename__ = "locations" id = db.Column(db.Integer, primary_key=True) street_name = db.Column(db.String(100), index=True) @@ -312,35 +368,41 @@ class Location(BaseModel): state = db.Column(db.String(2), unique=False, index=True) zip_code = db.Column(db.String(5), unique=False, index=True) - @validates('zip_code') + @validates("zip_code") def validate_zip_code(self, key, zip_code): if zip_code: - zip_re = r'^\d{5}$' + zip_re = r"^\d{5}$" if not re.match(zip_re, zip_code): - raise ValueError('Not a valid zip code') + raise ValueError("Not a valid zip code") return zip_code - @validates('state') + @validates("state") def validate_state(self, key, state): return state_validator(state) def __repr__(self): if self.street_name and self.cross_street2: - return 'Intersection of {} and {}, {} {}'.format( - self.street_name, self.cross_street2, self.city, self.state) + return "Intersection of {} and {}, {} {}".format( + self.street_name, self.cross_street2, self.city, self.state + ) elif self.street_name and self.cross_street1: - return 'Intersection of {} and {}, {} {}'.format( - self.street_name, self.cross_street1, self.city, self.state) + return "Intersection of {} and {}, {} {}".format( + self.street_name, self.cross_street1, self.city, self.state + ) elif self.street_name and self.cross_street1 and self.cross_street2: - return 'Intersection of {} between {} and {}, {} {}'.format( - self.street_name, self.cross_street1, self.cross_street2, - self.city, self.state) + return "Intersection of {} between {} and {}, {} {}".format( + self.street_name, + self.cross_street1, + self.cross_street2, + self.city, + self.state, + ) else: - return '{} {}'.format(self.city, self.state) + return "{} {}".format(self.city, self.state) class LicensePlate(BaseModel): - __tablename__ = 'license_plates' + __tablename__ = "license_plates" id = db.Column(db.Integer, primary_key=True) number = db.Column(db.String(8), nullable=False, index=True) @@ -348,13 +410,13 @@ class LicensePlate(BaseModel): # for use if car is federal, diplomat, or other non-state # non_state_identifier = db.Column(db.String(20), index=True) - @validates('state') + @validates("state") def validate_state(self, key, state): return state_validator(state) class Link(BaseModel): - __tablename__ = 'links' + __tablename__ = "links" id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(100), index=True) @@ -362,41 +424,56 @@ class Link(BaseModel): link_type = db.Column(db.String(100), index=True) description = db.Column(db.Text(), nullable=True) author = db.Column(db.String(255), nullable=True) - creator_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL')) - creator = db.relationship('User', backref='links', lazy=True) + creator_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) + creator = db.relationship("User", backref="links", lazy=True) - @validates('url') + @validates("url") def validate_url(self, key, url): return url_validator(url) class Incident(BaseModel): - __tablename__ = 'incidents' + __tablename__ = "incidents" id = db.Column(db.Integer, primary_key=True) date = db.Column(db.Date, unique=False, index=True) time = db.Column(db.Time, unique=False, index=True) report_number = db.Column(db.String(50), index=True) description = db.Column(db.Text(), nullable=True) - address_id = db.Column(db.Integer, db.ForeignKey('locations.id')) - address = db.relationship('Location', backref='incidents') - license_plates = db.relationship('LicensePlate', secondary=incident_license_plates, lazy='subquery', backref=db.backref('incidents', lazy=True)) - links = db.relationship('Link', secondary=incident_links, lazy='subquery', backref=db.backref('incidents', lazy=True)) + address_id = db.Column(db.Integer, db.ForeignKey("locations.id")) + address = db.relationship("Location", backref="incidents") + license_plates = db.relationship( + "LicensePlate", + secondary=incident_license_plates, + lazy="subquery", + backref=db.backref("incidents", lazy=True), + ) + links = db.relationship( + "Link", + secondary=incident_links, + lazy="subquery", + backref=db.backref("incidents", lazy=True), + ) officers = db.relationship( - 'Officer', + "Officer", secondary=officer_incidents, - lazy='subquery', - backref=db.backref('incidents')) - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='incidents', lazy=True) - creator_id = db.Column(db.Integer, db.ForeignKey('users.id')) - creator = db.relationship('User', backref='incidents_created', lazy=True, foreign_keys=[creator_id]) - last_updated_id = db.Column(db.Integer, db.ForeignKey('users.id')) - last_updated_by = db.relationship('User', backref='incidents_updated', lazy=True, foreign_keys=[last_updated_id]) + lazy="subquery", + backref=db.backref("incidents"), + ) + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship("Department", backref="incidents", lazy=True) + creator_id = db.Column(db.Integer, db.ForeignKey("users.id")) + creator = db.relationship( + "User", backref="incidents_created", lazy=True, foreign_keys=[creator_id] + ) + last_updated_id = db.Column(db.Integer, db.ForeignKey("users.id")) + last_updated_by = db.relationship( + "User", backref="incidents_updated", lazy=True, foreign_keys=[last_updated_id] + ) class User(UserMixin, BaseModel): - __tablename__ = 'users' + __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(64), unique=True, index=True) username = db.Column(db.String(64), unique=True, index=True) @@ -404,40 +481,43 @@ class User(UserMixin, BaseModel): confirmed = db.Column(db.Boolean, default=False) approved = db.Column(db.Boolean, default=False) is_area_coordinator = db.Column(db.Boolean, default=False) - ac_department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - ac_department = db.relationship('Department', backref='coordinators', foreign_keys=[ac_department_id]) + ac_department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + ac_department = db.relationship( + "Department", backref="coordinators", foreign_keys=[ac_department_id] + ) is_administrator = db.Column(db.Boolean, default=False) is_disabled = db.Column(db.Boolean, default=False) - dept_pref = db.Column(db.Integer, db.ForeignKey('departments.id')) - dept_pref_rel = db.relationship('Department', foreign_keys=[dept_pref]) - classifications = db.relationship('Image', backref='users') - tags = db.relationship('Face', backref='users') + dept_pref = db.Column(db.Integer, db.ForeignKey("departments.id")) + dept_pref_rel = db.relationship("Department", foreign_keys=[dept_pref]) + classifications = db.relationship("Image", backref="users") + tags = db.relationship("Face", backref="users") @property def password(self): - raise AttributeError('password is not a readable attribute') + raise AttributeError("password is not a readable attribute") @password.setter def password(self, password): - self.password_hash = generate_password_hash(password, - method='pbkdf2:sha256') + self.password_hash = generate_password_hash(password, method="pbkdf2:sha256") def verify_password(self, password): return check_password_hash(self.password_hash, password) def generate_confirmation_token(self, expiration=3600): - s = Serializer(current_app.config['SECRET_KEY'], expiration) - return s.dumps({'confirm': self.id}).decode('utf-8') + s = Serializer(current_app.config["SECRET_KEY"], expiration) + return s.dumps({"confirm": self.id}).decode("utf-8") def confirm(self, token): - s = Serializer(current_app.config['SECRET_KEY']) + s = Serializer(current_app.config["SECRET_KEY"]) try: data = s.loads(token) except (BadSignature, BadData) as e: current_app.logger.warning("failed to decrypt token: %s", e) return False - if data.get('confirm') != self.id: - current_app.logger.warning("incorrect id here, expected %s, got %s", data.get('confirm'), self.id) + if data.get("confirm") != self.id: + current_app.logger.warning( + "incorrect id here, expected %s, got %s", data.get("confirm"), self.id + ) return False self.confirmed = True db.session.add(self) @@ -445,34 +525,36 @@ def confirm(self, token): return True def generate_reset_token(self, expiration=3600): - s = Serializer(current_app.config['SECRET_KEY'], expiration) - return s.dumps({'reset': self.id}).decode('utf-8') + s = Serializer(current_app.config["SECRET_KEY"], expiration) + return s.dumps({"reset": self.id}).decode("utf-8") def reset_password(self, token, new_password): - s = Serializer(current_app.config['SECRET_KEY']) + s = Serializer(current_app.config["SECRET_KEY"]) try: data = s.loads(token) except (BadSignature, BadData): return False - if data.get('reset') != self.id: + if data.get("reset") != self.id: return False self.password = new_password db.session.add(self) return True def generate_email_change_token(self, new_email, expiration=3600): - s = Serializer(current_app.config['SECRET_KEY'], expiration) - return s.dumps({'change_email': self.id, 'new_email': new_email}).decode('utf-8') + s = Serializer(current_app.config["SECRET_KEY"], expiration) + return s.dumps({"change_email": self.id, "new_email": new_email}).decode( + "utf-8" + ) def change_email(self, token): - s = Serializer(current_app.config['SECRET_KEY']) + s = Serializer(current_app.config["SECRET_KEY"]) try: data = s.loads(token) except (BadSignature, BadData): return False - if data.get('change_email') != self.id: + if data.get("change_email") != self.id: return False - new_email = data.get('new_email') + new_email = data.get("new_email") if new_email is None: return False if self.query.filter_by(email=new_email).first() is not None: @@ -482,7 +564,7 @@ def change_email(self, token): return True def __repr__(self): - return '' % self.username + return "" % self.username @login_manager.user_loader diff --git a/OpenOversight/app/static/css/cropper.css b/OpenOversight/app/static/css/cropper.css old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/css/cropper.min.css b/OpenOversight/app/static/css/cropper.min.css old mode 100755 new mode 100644 index 9426dacce..9f0118bb4 --- a/OpenOversight/app/static/css/cropper.min.css +++ b/OpenOversight/app/static/css/cropper.min.css @@ -6,4 +6,4 @@ * Released under the MIT license * * Date: 2016-09-03T05:50:45.412Z - */.cropper-container{font-size:0;line-height:0;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;direction:ltr!important}.cropper-container img{display:block;width:100%;min-width:0!important;max-width:none!important;height:100%;min-height:0!important;max-height:none!important;image-orientation:0deg!important}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{position:absolute;top:0;right:0;bottom:0;left:0}.cropper-wrap-box{overflow:hidden}.cropper-drag-box{opacity:0;background-color:#fff;filter:alpha(opacity=0)}.cropper-dashed,.cropper-modal{opacity:.5;filter:alpha(opacity=50)}.cropper-modal{background-color:#000}.cropper-view-box{display:block;overflow:hidden;width:100%;height:100%;outline:#39f solid 1px;outline-color:rgba(51,153,255,.75)}.cropper-dashed{position:absolute;display:block;border:0 dashed #eee}.cropper-dashed.dashed-h{top:33.33333%;left:0;width:100%;height:33.33333%;border-top-width:1px;border-bottom-width:1px}.cropper-dashed.dashed-v{top:0;left:33.33333%;width:33.33333%;height:100%;border-right-width:1px;border-left-width:1px}.cropper-center{position:absolute;top:50%;left:50%;display:block;width:0;height:0;opacity:.75;filter:alpha(opacity=75)}.cropper-center:after,.cropper-center:before{position:absolute;display:block;content:' ';background-color:#eee}.cropper-center:before{top:0;left:-3px;width:7px;height:1px}.cropper-center:after{top:-3px;left:0;width:1px;height:7px}.cropper-face,.cropper-line,.cropper-point{position:absolute;display:block;width:100%;height:100%;opacity:.1;filter:alpha(opacity=10)}.cropper-face{top:0;left:0;background-color:#fff}.cropper-line,.cropper-point{background-color:#39f}.cropper-line.line-e{top:0;right:-3px;width:5px;cursor:e-resize}.cropper-line.line-n{top:-3px;left:0;height:5px;cursor:n-resize}.cropper-line.line-w{top:0;left:-3px;width:5px;cursor:w-resize}.cropper-line.line-s{bottom:-3px;left:0;height:5px;cursor:s-resize}.cropper-point{width:5px;height:5px;opacity:.75;filter:alpha(opacity=75)}.cropper-point.point-e{top:50%;right:-3px;margin-top:-3px;cursor:e-resize}.cropper-point.point-n{top:-3px;left:50%;margin-left:-3px;cursor:n-resize}.cropper-point.point-w{top:50%;left:-3px;margin-top:-3px;cursor:w-resize}.cropper-point.point-s{bottom:-3px;left:50%;margin-left:-3px;cursor:s-resize}.cropper-point.point-ne{top:-3px;right:-3px;cursor:ne-resize}.cropper-point.point-nw{top:-3px;left:-3px;cursor:nw-resize}.cropper-point.point-sw{bottom:-3px;left:-3px;cursor:sw-resize}.cropper-point.point-se{right:-3px;bottom:-3px;width:20px;height:20px;cursor:se-resize;opacity:1;filter:alpha(opacity=100)}.cropper-point.point-se:before{position:absolute;right:-50%;bottom:-50%;display:block;width:200%;height:200%;content:' ';opacity:0;background-color:#39f;filter:alpha(opacity=0)}@media (min-width:768px){.cropper-point.point-se{width:15px;height:15px}}@media (min-width:992px){.cropper-point.point-se{width:10px;height:10px}}@media (min-width:1200px){.cropper-point.point-se{width:5px;height:5px;opacity:.75;filter:alpha(opacity=75)}}.cropper-invisible{opacity:0;filter:alpha(opacity=0)}.cropper-bg{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC)}.cropper-hide{position:absolute;display:block;width:0;height:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} \ No newline at end of file + */.cropper-container{font-size:0;line-height:0;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;direction:ltr!important}.cropper-container img{display:block;width:100%;min-width:0!important;max-width:none!important;height:100%;min-height:0!important;max-height:none!important;image-orientation:0deg!important}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{position:absolute;top:0;right:0;bottom:0;left:0}.cropper-wrap-box{overflow:hidden}.cropper-drag-box{opacity:0;background-color:#fff;filter:alpha(opacity=0)}.cropper-dashed,.cropper-modal{opacity:.5;filter:alpha(opacity=50)}.cropper-modal{background-color:#000}.cropper-view-box{display:block;overflow:hidden;width:100%;height:100%;outline:#39f solid 1px;outline-color:rgba(51,153,255,.75)}.cropper-dashed{position:absolute;display:block;border:0 dashed #eee}.cropper-dashed.dashed-h{top:33.33333%;left:0;width:100%;height:33.33333%;border-top-width:1px;border-bottom-width:1px}.cropper-dashed.dashed-v{top:0;left:33.33333%;width:33.33333%;height:100%;border-right-width:1px;border-left-width:1px}.cropper-center{position:absolute;top:50%;left:50%;display:block;width:0;height:0;opacity:.75;filter:alpha(opacity=75)}.cropper-center:after,.cropper-center:before{position:absolute;display:block;content:' ';background-color:#eee}.cropper-center:before{top:0;left:-3px;width:7px;height:1px}.cropper-center:after{top:-3px;left:0;width:1px;height:7px}.cropper-face,.cropper-line,.cropper-point{position:absolute;display:block;width:100%;height:100%;opacity:.1;filter:alpha(opacity=10)}.cropper-face{top:0;left:0;background-color:#fff}.cropper-line,.cropper-point{background-color:#39f}.cropper-line.line-e{top:0;right:-3px;width:5px;cursor:e-resize}.cropper-line.line-n{top:-3px;left:0;height:5px;cursor:n-resize}.cropper-line.line-w{top:0;left:-3px;width:5px;cursor:w-resize}.cropper-line.line-s{bottom:-3px;left:0;height:5px;cursor:s-resize}.cropper-point{width:5px;height:5px;opacity:.75;filter:alpha(opacity=75)}.cropper-point.point-e{top:50%;right:-3px;margin-top:-3px;cursor:e-resize}.cropper-point.point-n{top:-3px;left:50%;margin-left:-3px;cursor:n-resize}.cropper-point.point-w{top:50%;left:-3px;margin-top:-3px;cursor:w-resize}.cropper-point.point-s{bottom:-3px;left:50%;margin-left:-3px;cursor:s-resize}.cropper-point.point-ne{top:-3px;right:-3px;cursor:ne-resize}.cropper-point.point-nw{top:-3px;left:-3px;cursor:nw-resize}.cropper-point.point-sw{bottom:-3px;left:-3px;cursor:sw-resize}.cropper-point.point-se{right:-3px;bottom:-3px;width:20px;height:20px;cursor:se-resize;opacity:1;filter:alpha(opacity=100)}.cropper-point.point-se:before{position:absolute;right:-50%;bottom:-50%;display:block;width:200%;height:200%;content:' ';opacity:0;background-color:#39f;filter:alpha(opacity=0)}@media (min-width:768px){.cropper-point.point-se{width:15px;height:15px}}@media (min-width:992px){.cropper-point.point-se{width:10px;height:10px}}@media (min-width:1200px){.cropper-point.point-se{width:5px;height:5px;opacity:.75;filter:alpha(opacity=75)}}.cropper-invisible{opacity:0;filter:alpha(opacity=0)}.cropper-bg{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC)}.cropper-hide{position:absolute;display:block;width:0;height:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} diff --git a/OpenOversight/app/static/css/font-awesome.min.css b/OpenOversight/app/static/css/font-awesome.min.css old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/css/jquery-ui.min.css b/OpenOversight/app/static/css/jquery-ui.min.css index c635d4f4d..d11ab7806 100644 --- a/OpenOversight/app/static/css/jquery-ui.min.css +++ b/OpenOversight/app/static/css/jquery-ui.min.css @@ -3,4 +3,4 @@ * Includes: draggable.css, sortable.css * Copyright jQuery Foundation and other contributors; Licensed MIT */ -.ui-draggable-handle{-ms-touch-action:none;touch-action:none}.ui-sortable-handle{-ms-touch-action:none;touch-action:none} \ No newline at end of file +.ui-draggable-handle{-ms-touch-action:none;touch-action:none}.ui-sortable-handle{-ms-touch-action:none;touch-action:none} diff --git a/OpenOversight/app/static/css/openoversight.css b/OpenOversight/app/static/css/openoversight.css index 2115b20ae..d4354b4da 100644 --- a/OpenOversight/app/static/css/openoversight.css +++ b/OpenOversight/app/static/css/openoversight.css @@ -527,10 +527,10 @@ tr:hover .row-actions { .console .button-explanation { height:70px; } - + } - + .console .button-explanation .text { display:none; } diff --git a/OpenOversight/app/static/css/qunit.css b/OpenOversight/app/static/css/qunit.css old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/FontAwesome.otf b/OpenOversight/app/static/fonts/FontAwesome.otf old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/bootstrap/glyphicons-halflings-regular.svg b/OpenOversight/app/static/fonts/bootstrap/glyphicons-halflings-regular.svg index 94fb5490a..187805af6 100644 --- a/OpenOversight/app/static/fonts/bootstrap/glyphicons-halflings-regular.svg +++ b/OpenOversight/app/static/fonts/bootstrap/glyphicons-halflings-regular.svg @@ -285,4 +285,4 @@ - \ No newline at end of file + diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.eot b/OpenOversight/app/static/fonts/fontawesome-webfont.eot old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.svg b/OpenOversight/app/static/fonts/fontawesome-webfont.svg old mode 100755 new mode 100644 index 8b66187fe..efee1983f --- a/OpenOversight/app/static/fonts/fontawesome-webfont.svg +++ b/OpenOversight/app/static/fonts/fontawesome-webfont.svg @@ -682,4 +682,4 @@ - \ No newline at end of file + diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.ttf b/OpenOversight/app/static/fonts/fontawesome-webfont.ttf old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.woff b/OpenOversight/app/static/fonts/fontawesome-webfont.woff old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.woff2 b/OpenOversight/app/static/fonts/fontawesome-webfont.woff2 old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/js/bootstrap.js b/OpenOversight/app/static/js/bootstrap.js old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/js/bootstrap.min.js b/OpenOversight/app/static/js/bootstrap.min.js old mode 100755 new mode 100644 index 9bcd2fcca..be9574d70 --- a/OpenOversight/app/static/js/bootstrap.min.js +++ b/OpenOversight/app/static/js/bootstrap.min.js @@ -4,4 +4,4 @@ * Licensed under the MIT license */ if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1||b[0]>3)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){if(a(b.target).is(this))return b.handleObj.handler.apply(this,arguments)}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.7",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a("#"===f?[]:f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.7",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c).prop(c,!0)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c).prop(c,!1))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")?(c.prop("checked")&&(a=!1),b.find(".active").removeClass("active"),this.$element.addClass("active")):"checkbox"==c.prop("type")&&(c.prop("checked")!==this.$element.hasClass("active")&&(a=!1),this.$element.toggleClass("active")),c.prop("checked",this.$element.hasClass("active")),a&&c.trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active")),this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target).closest(".btn");b.call(d,"toggle"),a(c.target).is('input[type="radio"], input[type="checkbox"]')||(c.preventDefault(),d.is("input,button")?d.trigger("focus"):d.find("input:visible,button:visible").first().trigger("focus"))}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.7",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));if(!(a>this.$items.length-1||a<0))return this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){if(!this.sliding)return this.slide("next")},c.prototype.prev=function(){if(!this.sliding)return this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.7",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function c(c){c&&3===c.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=b(d),f={relatedTarget:this};e.hasClass("open")&&(c&&"click"==c.type&&/input|textarea/i.test(c.target.tagName)&&a.contains(e[0],c.target)||(e.trigger(c=a.Event("hide.bs.dropdown",f)),c.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger(a.Event("hidden.bs.dropdown",f)))))}))}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.7",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=b(e),g=f.hasClass("open");if(c(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(document.createElement("div")).addClass("dropdown-backdrop").insertAfter(a(this)).on("click",c);var h={relatedTarget:this};if(f.trigger(d=a.Event("show.bs.dropdown",h)),d.isDefaultPrevented())return;e.trigger("focus").attr("aria-expanded","true"),f.toggleClass("open").trigger(a.Event("shown.bs.dropdown",h))}return!1}},g.prototype.keydown=function(c){if(/(38|40|27|32)/.test(c.which)&&!/input|textarea/i.test(c.target.tagName)){var d=a(this);if(c.preventDefault(),c.stopPropagation(),!d.is(".disabled, :disabled")){var e=b(d),g=e.hasClass("open");if(!g&&27!=c.which||g&&27==c.which)return 27==c.which&&e.find(f).trigger("focus"),d.trigger("click");var h=" li:not(.disabled):visible a",i=e.find(".dropdown-menu"+h);if(i.length){var j=i.index(c.target);38==c.which&&j>0&&j--,40==c.which&&jdocument.documentElement.clientHeight;this.$element.css({paddingLeft:!this.bodyIsOverflowing&&a?this.scrollbarWidth:"",paddingRight:this.bodyIsOverflowing&&!a?this.scrollbarWidth:""})},c.prototype.resetAdjustments=function(){this.$element.css({paddingLeft:"",paddingRight:""})},c.prototype.checkScrollbar=function(){var a=window.innerWidth;if(!a){var b=document.documentElement.getBoundingClientRect();a=b.right-Math.abs(b.left)}this.bodyIsOverflowing=document.body.clientWidth
',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(a.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusin"==b.type?"focus":"hover"]=!0),c.tip().hasClass("in")||"in"==c.hoverState?void(c.hoverState="in"):(clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.isInStateTrue=function(){for(var a in this.inState)if(this.inState[a])return!0;return!1},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);if(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusout"==b.type?"focus":"hover"]=!1),!c.isInStateTrue())return clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.getPosition(this.$viewport);h="bottom"==h&&k.bottom+m>o.bottom?"top":"top"==h&&k.top-mo.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.right&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){if(!this.$tip&&(this.$tip=a(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),b?(c.inState.click=!c.inState.click,c.isInStateTrue()?c.enter(c):c.leave(c)):c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type),a.$tip&&a.$tip.detach(),a.$tip=null,a.$arrow=null,a.$viewport=null,a.$element=null})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;!e&&/destroy|hide/.test(b)||(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.7",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.7",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.7",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return e=a-d&&"bottom"},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); \ No newline at end of file +this.activeTarget=b,this.clear();var c=this.selector+'[data-target="'+b+'"],'+this.selector+'[href="'+b+'"]',d=a(c).parents("li").addClass("active");d.parent(".dropdown-menu").length&&(d=d.closest("li.dropdown").addClass("active")),d.trigger("activate.bs.scrollspy")},b.prototype.clear=function(){a(this.selector).parentsUntil(this.options.target,".active").removeClass("active")};var d=a.fn.scrollspy;a.fn.scrollspy=c,a.fn.scrollspy.Constructor=b,a.fn.scrollspy.noConflict=function(){return a.fn.scrollspy=d,this},a(window).on("load.bs.scrollspy.data-api",function(){a('[data-spy="scroll"]').each(function(){var b=a(this);c.call(b,b.data())})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.tab");e||d.data("bs.tab",e=new c(this)),"string"==typeof b&&e[b]()})}var c=function(b){this.element=a(b)};c.VERSION="3.3.7",c.TRANSITION_DURATION=150,c.prototype.show=function(){var b=this.element,c=b.closest("ul:not(.dropdown-menu)"),d=b.data("target");if(d||(d=b.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),!b.parent("li").hasClass("active")){var e=c.find(".active:last a"),f=a.Event("hide.bs.tab",{relatedTarget:b[0]}),g=a.Event("show.bs.tab",{relatedTarget:e[0]});if(e.trigger(f),b.trigger(g),!g.isDefaultPrevented()&&!f.isDefaultPrevented()){var h=a(d);this.activate(b.closest("li"),c),this.activate(h,h.parent(),function(){e.trigger({type:"hidden.bs.tab",relatedTarget:b[0]}),b.trigger({type:"shown.bs.tab",relatedTarget:e[0]})})}}},c.prototype.activate=function(b,d,e){function f(){g.removeClass("active").find("> .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.7",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return e=a-d&&"bottom"},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); diff --git a/OpenOversight/app/static/js/cropper.js b/OpenOversight/app/static/js/cropper.js old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/js/cropper.min.js b/OpenOversight/app/static/js/cropper.min.js old mode 100755 new mode 100644 index d285e2c2b..62c740264 --- a/OpenOversight/app/static/js/cropper.min.js +++ b/OpenOversight/app/static/js/cropper.min.js @@ -7,4 +7,4 @@ * * Date: 2016-09-03T05:50:45.412Z */ -!function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t("object"==typeof exports?require("jquery"):jQuery)}(function(t){"use strict";function i(t){return"number"==typeof t&&!isNaN(t)}function e(t){return"undefined"==typeof t}function s(t,e){var s=[];return i(e)&&s.push(e),s.slice.apply(t,s)}function a(t,i){var e=s(arguments,2);return function(){return t.apply(i,e.concat(s(arguments)))}}function o(t){var i=t.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i);return i&&(i[1]!==C.protocol||i[2]!==C.hostname||i[3]!==C.port)}function h(t){var i="timestamp="+(new Date).getTime();return t+(t.indexOf("?")===-1?"?":"&")+i}function n(t){return t?' crossOrigin="'+t+'"':""}function r(t,i){var e;return t.naturalWidth&&!mt?i(t.naturalWidth,t.naturalHeight):(e=document.createElement("img"),e.onload=function(){i(this.width,this.height)},void(e.src=t.src))}function p(t){var e=[],s=t.rotate,a=t.scaleX,o=t.scaleY;return i(s)&&0!==s&&e.push("rotate("+s+"deg)"),i(a)&&1!==a&&e.push("scaleX("+a+")"),i(o)&&1!==o&&e.push("scaleY("+o+")"),e.length?e.join(" "):"none"}function l(t,i){var e,s,a=Ct(t.degree)%180,o=(a>90?180-a:a)*Math.PI/180,h=bt(o),n=Bt(o),r=t.width,p=t.height,l=t.aspectRatio;return i?(e=r/(n+h/l),s=e/l):(e=r*n+p*h,s=r*h+p*n),{width:e,height:s}}function c(e,s){var a,o,h,n=t("")[0],r=n.getContext("2d"),p=0,c=0,d=s.naturalWidth,g=s.naturalHeight,u=s.rotate,f=s.scaleX,m=s.scaleY,v=i(f)&&i(m)&&(1!==f||1!==m),w=i(u)&&0!==u,x=w||v,C=d*Ct(f||1),b=g*Ct(m||1);return v&&(a=C/2,o=b/2),w&&(h=l({width:C,height:b,degree:u}),C=h.width,b=h.height,a=C/2,o=b/2),n.width=C,n.height=b,x&&(p=-d/2,c=-g/2,r.save(),r.translate(a,o)),w&&r.rotate(u*Math.PI/180),v&&r.scale(f,m),r.drawImage(e,$t(p),$t(c),$t(d),$t(g)),x&&r.restore(),n}function d(i){var e=i.length,s=0,a=0;return e&&(t.each(i,function(t,i){s+=i.pageX,a+=i.pageY}),s/=e,a/=e),{pageX:s,pageY:a}}function g(t,i,e){var s,a="";for(s=i,e+=i;s=8&&(r=s+a)))),r)for(d=c.getUint16(r,o),l=0;l")[0].getContext),mt=b&&/(Macintosh|iPhone|iPod|iPad).*AppleWebKit/i.test(b.userAgent),vt=Number,wt=Math.min,xt=Math.max,Ct=Math.abs,bt=Math.sin,Bt=Math.cos,yt=Math.sqrt,Dt=Math.round,$t=Math.floor,Lt=String.fromCharCode;v.prototype={constructor:v,init:function(){var t,i=this.$element;if(i.is("img")){if(this.isImg=!0,this.originalUrl=t=i.attr("src"),!t)return;t=i.prop("src")}else i.is("canvas")&&ft&&(t=i[0].toDataURL());this.load(t)},trigger:function(i,e){var s=t.Event(i,e);return this.$element.trigger(s),s},load:function(i){var e,s,a=this.options,n=this.$element;if(i&&(n.one(A,a.build),!this.trigger(A).isDefaultPrevented())){if(this.url=i,this.image={},!a.checkOrientation||!B)return this.clone();if(e=t.proxy(this.read,this),V.test(i))return J.test(i)?e(f(i)):this.clone();s=new XMLHttpRequest,s.onerror=s.onabort=t.proxy(function(){this.clone()},this),s.onload=function(){e(this.response)},a.checkCrossOrigin&&o(i)&&n.prop("crossOrigin")&&(i=h(i)),s.open("get",i),s.responseType="arraybuffer",s.send()}},read:function(t){var i=this.options,e=u(t),s=this.image,a=0,o=1,h=1;if(e>1)switch(this.url=m(t),e){case 2:o=-1;break;case 3:a=-180;break;case 4:h=-1;break;case 5:a=90,h=-1;break;case 6:a=90;break;case 7:a=90,o=-1;break;case 8:a=-90}i.rotatable&&(s.rotate=a),i.scalable&&(s.scaleX=o,s.scaleY=h),this.clone()},clone:function(){var i,e,s=this.options,a=this.$element,r=this.url,p="";s.checkCrossOrigin&&o(r)&&(p=a.prop("crossOrigin"),p?i=r:(p="anonymous",i=h(r))),this.crossOrigin=p,this.crossOriginUrl=i,this.$clone=e=t("'),this.isImg?a[0].complete?this.start():a.one(I,t.proxy(this.start,this)):e.one(I,t.proxy(this.start,this)).one(F,t.proxy(this.stop,this)).addClass(X).insertAfter(a)},start:function(){var i=this.$element,e=this.$clone;this.isImg||(e.off(F,this.stop),i=e),r(i[0],t.proxy(function(i,e){t.extend(this.image,{naturalWidth:i,naturalHeight:e,aspectRatio:i/e}),this.isLoaded=!0,this.build()},this))},stop:function(){this.$clone.remove(),this.$clone=null},build:function(){var i,e,s,a=this.options,o=this.$element,h=this.$clone;this.isLoaded&&(this.isBuilt&&this.unbuild(),this.$container=o.parent(),this.$cropper=i=t(v.TEMPLATE),this.$canvas=i.find(".cropper-canvas").append(h),this.$dragBox=i.find(".cropper-drag-box"),this.$cropBox=e=i.find(".cropper-crop-box"),this.$viewBox=i.find(".cropper-view-box"),this.$face=s=e.find(".cropper-face"),o.addClass(Y).after(i),this.isImg||h.removeClass(X),this.initPreview(),this.bind(),a.aspectRatio=xt(0,a.aspectRatio)||NaN,a.viewMode=xt(0,wt(3,Dt(a.viewMode)))||0,a.autoCrop?(this.isCropped=!0,a.modal&&this.$dragBox.addClass(T)):e.addClass(Y),a.guides||e.find(".cropper-dashed").addClass(Y),a.center||e.find(".cropper-center").addClass(Y),a.cropBoxMovable&&s.addClass(M).data(it,lt),a.highlight||s.addClass(k),a.background&&i.addClass(R),a.cropBoxResizable||e.find(".cropper-line, .cropper-point").addClass(Y),this.setDragMode(a.dragMode),this.render(),this.isBuilt=!0,this.setData(a.data),o.one(S,a.built),this.completing=setTimeout(t.proxy(function(){this.trigger(S),this.trigger(K,this.getData()),this.isCompleted=!0},this),0))},unbuild:function(){this.isBuilt&&(this.isCompleted||clearTimeout(this.completing),this.isBuilt=!1,this.isCompleted=!1,this.initialImage=null,this.initialCanvas=null,this.initialCropBox=null,this.container=null,this.canvas=null,this.cropBox=null,this.unbind(),this.resetPreview(),this.$preview=null,this.$viewBox=null,this.$cropBox=null,this.$dragBox=null,this.$canvas=null,this.$container=null,this.$cropper.remove(),this.$cropper=null)},render:function(){this.initContainer(),this.initCanvas(),this.initCropBox(),this.renderCanvas(),this.isCropped&&this.renderCropBox()},initContainer:function(){var t=this.options,i=this.$element,e=this.$container,s=this.$cropper;s.addClass(Y),i.removeClass(Y),s.css(this.container={width:xt(e.width(),vt(t.minContainerWidth)||200),height:xt(e.height(),vt(t.minContainerHeight)||100)}),i.addClass(Y),s.removeClass(Y)},initCanvas:function(){var i,e=this.options.viewMode,s=this.container,a=s.width,o=s.height,h=this.image,n=h.naturalWidth,r=h.naturalHeight,p=90===Ct(h.rotate),l=p?r:n,c=p?n:r,d=l/c,g=a,u=o;o*d>a?3===e?g=o*d:u=a/d:3===e?u=a/d:g=o*d,i={naturalWidth:l,naturalHeight:c,aspectRatio:d,width:g,height:u},i.oldLeft=i.left=(a-g)/2,i.oldTop=i.top=(o-u)/2,this.canvas=i,this.isLimited=1===e||2===e,this.limitCanvas(!0,!0),this.initialImage=t.extend({},h),this.initialCanvas=t.extend({},i)},limitCanvas:function(t,i){var e,s,a,o,h=this.options,n=h.viewMode,r=this.container,p=r.width,l=r.height,c=this.canvas,d=c.aspectRatio,g=this.cropBox,u=this.isCropped&&g;t&&(e=vt(h.minCanvasWidth)||0,s=vt(h.minCanvasHeight)||0,n&&(n>1?(e=xt(e,p),s=xt(s,l),3===n&&(s*d>e?e=s*d:s=e/d)):e?e=xt(e,u?g.width:0):s?s=xt(s,u?g.height:0):u&&(e=g.width,s=g.height,s*d>e?e=s*d:s=e/d)),e&&s?s*d>e?s=e/d:e=s*d:e?s=e/d:s&&(e=s*d),c.minWidth=e,c.minHeight=s,c.maxWidth=1/0,c.maxHeight=1/0),i&&(n?(a=p-c.width,o=l-c.height,c.minLeft=wt(0,a),c.minTop=wt(0,o),c.maxLeft=xt(0,a),c.maxTop=xt(0,o),u&&this.isLimited&&(c.minLeft=wt(g.left,g.left+g.width-c.width),c.minTop=wt(g.top,g.top+g.height-c.height),c.maxLeft=g.left,c.maxTop=g.top,2===n&&(c.width>=p&&(c.minLeft=wt(0,a),c.maxLeft=xt(0,a)),c.height>=l&&(c.minTop=wt(0,o),c.maxTop=xt(0,o))))):(c.minLeft=-c.width,c.minTop=-c.height,c.maxLeft=p,c.maxTop=l))},renderCanvas:function(t){var i,e,s=this.canvas,a=this.image,o=a.rotate,h=a.naturalWidth,n=a.naturalHeight;this.isRotated&&(this.isRotated=!1,e=l({width:a.width,height:a.height,degree:o}),i=e.width/e.height,i!==s.aspectRatio&&(s.left-=(e.width-s.width)/2,s.top-=(e.height-s.height)/2,s.width=e.width,s.height=e.height,s.aspectRatio=i,s.naturalWidth=h,s.naturalHeight=n,o%180&&(e=l({width:h,height:n,degree:o}),s.naturalWidth=e.width,s.naturalHeight=e.height),this.limitCanvas(!0,!1))),(s.width>s.maxWidth||s.widths.maxHeight||s.heighte.width?o.height=o.width/s:o.width=o.height*s),this.cropBox=o,this.limitCropBox(!0,!0),o.width=wt(xt(o.width,o.minWidth),o.maxWidth),o.height=wt(xt(o.height,o.minHeight),o.maxHeight),o.width=xt(o.minWidth,o.width*a),o.height=xt(o.minHeight,o.height*a),o.oldLeft=o.left=e.left+(e.width-o.width)/2,o.oldTop=o.top=e.top+(e.height-o.height)/2,this.initialCropBox=t.extend({},o)},limitCropBox:function(t,i){var e,s,a,o,h=this.options,n=h.aspectRatio,r=this.container,p=r.width,l=r.height,c=this.canvas,d=this.cropBox,g=this.isLimited;t&&(e=vt(h.minCropBoxWidth)||0,s=vt(h.minCropBoxHeight)||0,e=wt(e,p),s=wt(s,l),a=wt(p,g?c.width:p),o=wt(l,g?c.height:l),n&&(e&&s?s*n>e?s=e/n:e=s*n:e?s=e/n:s&&(e=s*n),o*n>a?o=a/n:a=o*n),d.minWidth=wt(e,a),d.minHeight=wt(s,o),d.maxWidth=a,d.maxHeight=o),i&&(g?(d.minLeft=xt(0,c.left),d.minTop=xt(0,c.top),d.maxLeft=wt(p,c.left+c.width)-d.width,d.maxTop=wt(l,c.top+c.height)-d.height):(d.minLeft=0,d.minTop=0,d.maxLeft=p-d.width,d.maxTop=l-d.height))},renderCropBox:function(){var t=this.options,i=this.container,e=i.width,s=i.height,a=this.cropBox;(a.width>a.maxWidth||a.widtha.maxHeight||a.height'),this.$viewBox.html(i),this.$preview.each(function(){var i=t(this);i.data(tt,{width:i.width(),height:i.height(),html:i.html()}),i.html("')})},resetPreview:function(){this.$preview.each(function(){var i=t(this),e=i.data(tt);i.css({width:e.width,height:e.height}).html(e.html).removeData(tt)})},preview:function(){var i=this.image,e=this.canvas,s=this.cropBox,a=s.width,o=s.height,h=i.width,n=i.height,r=s.left-e.left-i.left,l=s.top-e.top-i.top;this.isCropped&&!this.isDisabled&&(this.$clone2.css({width:h,height:n,marginLeft:-r,marginTop:-l,transform:p(i)}),this.$preview.each(function(){var e=t(this),s=e.data(tt),c=s.width,d=s.height,g=c,u=d,f=1;a&&(f=c/a,u=o*f),o&&u>d&&(f=d/o,g=a*f,u=d),e.css({width:g,height:u}).find("img").css({width:h*f,height:n*f,marginLeft:-r*f,marginTop:-l*f,transform:p(i)})}))},bind:function(){var i=this.options,e=this.$element,s=this.$cropper;t.isFunction(i.cropstart)&&e.on(N,i.cropstart),t.isFunction(i.cropmove)&&e.on(_,i.cropmove),t.isFunction(i.cropend)&&e.on(q,i.cropend),t.isFunction(i.crop)&&e.on(K,i.crop),t.isFunction(i.zoom)&&e.on(Z,i.zoom),s.on(z,t.proxy(this.cropStart,this)),i.zoomable&&i.zoomOnWheel&&s.on(E,t.proxy(this.wheel,this)),i.toggleDragModeOnDblclick&&s.on(U,t.proxy(this.dblclick,this)),x.on(O,this._cropMove=a(this.cropMove,this)).on(P,this._cropEnd=a(this.cropEnd,this)),i.responsive&&w.on(j,this._resize=a(this.resize,this))},unbind:function(){var i=this.options,e=this.$element,s=this.$cropper;t.isFunction(i.cropstart)&&e.off(N,i.cropstart),t.isFunction(i.cropmove)&&e.off(_,i.cropmove),t.isFunction(i.cropend)&&e.off(q,i.cropend),t.isFunction(i.crop)&&e.off(K,i.crop),t.isFunction(i.zoom)&&e.off(Z,i.zoom),s.off(z,this.cropStart),i.zoomable&&i.zoomOnWheel&&s.off(E,this.wheel),i.toggleDragModeOnDblclick&&s.off(U,this.dblclick),x.off(O,this._cropMove).off(P,this._cropEnd),i.responsive&&w.off(j,this._resize)},resize:function(){var i,e,s,a=this.options.restore,o=this.$container,h=this.container;!this.isDisabled&&h&&(s=o.width()/h.width,1===s&&o.height()===h.height||(a&&(i=this.getCanvasData(),e=this.getCropBoxData()),this.render(),a&&(this.setCanvasData(t.each(i,function(t,e){i[t]=e*s})),this.setCropBoxData(t.each(e,function(t,i){e[t]=i*s})))))},dblclick:function(){this.isDisabled||(this.$dragBox.hasClass(W)?this.setDragMode(dt):this.setDragMode(ct))},wheel:function(i){var e=i.originalEvent||i,s=vt(this.options.wheelZoomRatio)||.1,a=1;this.isDisabled||(i.preventDefault(),this.wheeling||(this.wheeling=!0,setTimeout(t.proxy(function(){this.wheeling=!1},this),50),e.deltaY?a=e.deltaY>0?1:-1:e.wheelDelta?a=-e.wheelDelta/120:e.detail&&(a=e.detail>0?1:-1),this.zoom(-a*s,i)))},cropStart:function(i){var e,s,a=this.options,o=i.originalEvent,h=o&&o.touches,n=i;if(!this.isDisabled){if(h){if(e=h.length,e>1){if(!a.zoomable||!a.zoomOnTouch||2!==e)return;n=h[1],this.startX2=n.pageX,this.startY2=n.pageY,s=gt}n=h[0]}if(s=s||t(n.target).data(it),Q.test(s)){if(this.trigger(N,{originalEvent:o,action:s}).isDefaultPrevented())return;i.preventDefault(),this.action=s,this.cropping=!1,this.startX=n.pageX||o&&o.pageX,this.startY=n.pageY||o&&o.pageY,s===ct&&(this.cropping=!0,this.$dragBox.addClass(T))}}},cropMove:function(t){var i,e=this.options,s=t.originalEvent,a=s&&s.touches,o=t,h=this.action;if(!this.isDisabled){if(a){if(i=a.length,i>1){if(!e.zoomable||!e.zoomOnTouch||2!==i)return;o=a[1],this.endX2=o.pageX,this.endY2=o.pageY}o=a[0]}if(h){if(this.trigger(_,{originalEvent:s,action:h}).isDefaultPrevented())return;t.preventDefault(),this.endX=o.pageX||s&&s.pageX,this.endY=o.pageY||s&&s.pageY,this.change(o.shiftKey,h===gt?t:null)}}},cropEnd:function(t){var i=t.originalEvent,e=this.action;this.isDisabled||e&&(t.preventDefault(),this.cropping&&(this.cropping=!1,this.$dragBox.toggleClass(T,this.isCropped&&this.options.modal)),this.action="",this.trigger(q,{originalEvent:i,action:e}))},change:function(t,i){var e,s,a=this.options,o=a.aspectRatio,h=this.action,n=this.container,r=this.canvas,p=this.cropBox,l=p.width,c=p.height,d=p.left,g=p.top,u=d+l,f=g+c,m=0,v=0,w=n.width,x=n.height,C=!0;switch(!o&&t&&(o=l&&c?l/c:1),this.isLimited&&(m=p.minLeft,v=p.minTop,w=m+wt(n.width,r.width,r.left+r.width),x=v+wt(n.height,r.height,r.top+r.height)),s={x:this.endX-this.startX,y:this.endY-this.startY},o&&(s.X=s.y*o,s.Y=s.x/o),h){case lt:d+=s.x,g+=s.y;break;case et:if(s.x>=0&&(u>=w||o&&(g<=v||f>=x))){C=!1;break}l+=s.x,o&&(c=l/o,g-=s.Y/2),l<0&&(h=st,l=0);break;case ot:if(s.y<=0&&(g<=v||o&&(d<=m||u>=w))){C=!1;break}c-=s.y,g+=s.y,o&&(l=c*o,d+=s.X/2),c<0&&(h=at,c=0);break;case st:if(s.x<=0&&(d<=m||o&&(g<=v||f>=x))){C=!1;break}l-=s.x,d+=s.x,o&&(c=l/o,g+=s.Y/2),l<0&&(h=et,l=0);break;case at:if(s.y>=0&&(f>=x||o&&(d<=m||u>=w))){C=!1;break}c+=s.y,o&&(l=c*o,d-=s.X/2),c<0&&(h=ot,c=0);break;case rt:if(o){if(s.y<=0&&(g<=v||u>=w)){C=!1;break}c-=s.y,g+=s.y,l=c*o}else s.x>=0?uv&&(c-=s.y,g+=s.y):(c-=s.y,g+=s.y);l<0&&c<0?(h=nt,c=0,l=0):l<0?(h=pt,l=0):c<0&&(h=ht,c=0);break;case pt:if(o){if(s.y<=0&&(g<=v||d<=m)){C=!1;break}c-=s.y,g+=s.y,l=c*o,d+=s.X}else s.x<=0?d>m?(l-=s.x,d+=s.x):s.y<=0&&g<=v&&(C=!1):(l-=s.x,d+=s.x),s.y<=0?g>v&&(c-=s.y,g+=s.y):(c-=s.y,g+=s.y);l<0&&c<0?(h=ht,c=0,l=0):l<0?(h=rt,l=0):c<0&&(h=nt,c=0);break;case nt:if(o){if(s.x<=0&&(d<=m||f>=x)){C=!1;break}l-=s.x,d+=s.x,c=l/o}else s.x<=0?d>m?(l-=s.x,d+=s.x):s.y>=0&&f>=x&&(C=!1):(l-=s.x,d+=s.x),s.y>=0?f=0&&(u>=w||f>=x)){C=!1;break}l+=s.x,c=l/o}else s.x>=0?u=0&&f>=x&&(C=!1):l+=s.x,s.y>=0?f0?h=s.y>0?ht:rt:s.x<0&&(d-=l,h=s.y>0?nt:pt),s.y<0&&(g-=c),this.isCropped||(this.$cropBox.removeClass(Y),this.isCropped=!0,this.isLimited&&this.limitCropBox(!0,!0))}C&&(p.width=l,p.height=c,p.left=d,p.top=g,this.action=h,this.renderCropBox()),this.startX=this.endX,this.startY=this.endY},crop:function(){this.isBuilt&&!this.isDisabled&&(this.isCropped||(this.isCropped=!0,this.limitCropBox(!0,!0),this.options.modal&&this.$dragBox.addClass(T),this.$cropBox.removeClass(Y)),this.setCropBoxData(this.initialCropBox))},reset:function(){this.isBuilt&&!this.isDisabled&&(this.image=t.extend({},this.initialImage),this.canvas=t.extend({},this.initialCanvas),this.cropBox=t.extend({},this.initialCropBox),this.renderCanvas(),this.isCropped&&this.renderCropBox())},clear:function(){this.isCropped&&!this.isDisabled&&(t.extend(this.cropBox,{left:0,top:0,width:0,height:0}),this.isCropped=!1,this.renderCropBox(),this.limitCanvas(!0,!0),this.renderCanvas(),this.$dragBox.removeClass(T),this.$cropBox.addClass(Y))},replace:function(t,i){!this.isDisabled&&t&&(this.isImg&&this.$element.attr("src",t),i?(this.url=t,this.$clone.attr("src",t),this.isBuilt&&this.$preview.find("img").add(this.$clone2).attr("src",t)):(this.isImg&&(this.isReplaced=!0),this.options.data=null,this.load(t)))},enable:function(){this.isBuilt&&(this.isDisabled=!1,this.$cropper.removeClass(H))},disable:function(){this.isBuilt&&(this.isDisabled=!0,this.$cropper.addClass(H))},destroy:function(){var t=this.$element;this.isLoaded?(this.isImg&&this.isReplaced&&t.attr("src",this.originalUrl),this.unbuild(),t.removeClass(Y)):this.isImg?t.off(I,this.start):this.$clone&&this.$clone.remove(),t.removeData(L)},move:function(t,i){var s=this.canvas;this.moveTo(e(t)?t:s.left+vt(t),e(i)?i:s.top+vt(i))},moveTo:function(t,s){var a=this.canvas,o=!1;e(s)&&(s=t),t=vt(t),s=vt(s),this.isBuilt&&!this.isDisabled&&this.options.movable&&(i(t)&&(a.left=t,o=!0),i(s)&&(a.top=s,o=!0),o&&this.renderCanvas(!0))},zoom:function(t,i){var e=this.canvas;t=vt(t),t=t<0?1/(1-t):1+t,this.zoomTo(e.width*t/e.naturalWidth,i)},zoomTo:function(t,i){var e,s,a,o,h,n=this.options,r=this.canvas,p=r.width,l=r.height,c=r.naturalWidth,g=r.naturalHeight;if(t=vt(t),t>=0&&this.isBuilt&&!this.isDisabled&&n.zoomable){if(s=c*t,a=g*t,i&&(e=i.originalEvent),this.trigger(Z,{originalEvent:e,oldRatio:p/c,ratio:s/c}).isDefaultPrevented())return;e?(o=this.$cropper.offset(),h=e.touches?d(e.touches):{pageX:i.pageX||e.pageX||0,pageY:i.pageY||e.pageY||0},r.left-=(s-p)*((h.pageX-o.left-r.left)/p),r.top-=(a-l)*((h.pageY-o.top-r.top)/l)):(r.left-=(s-p)/2,r.top-=(a-l)/2),r.width=s,r.height=a,this.renderCanvas(!0)}},rotate:function(t){this.rotateTo((this.image.rotate||0)+vt(t))},rotateTo:function(t){t=vt(t),i(t)&&this.isBuilt&&!this.isDisabled&&this.options.rotatable&&(this.image.rotate=t%360,this.isRotated=!0,this.renderCanvas(!0))},scale:function(t,s){var a=this.image,o=!1;e(s)&&(s=t),t=vt(t),s=vt(s),this.isBuilt&&!this.isDisabled&&this.options.scalable&&(i(t)&&(a.scaleX=t,o=!0),i(s)&&(a.scaleY=s,o=!0),o&&this.renderImage(!0))},scaleX:function(t){var e=this.image.scaleY;this.scale(t,i(e)?e:1)},scaleY:function(t){var e=this.image.scaleX;this.scale(i(e)?e:1,t)},getData:function(i){var e,s,a=this.options,o=this.image,h=this.canvas,n=this.cropBox;return this.isBuilt&&this.isCropped?(s={x:n.left-h.left,y:n.top-h.top,width:n.width,height:n.height},e=o.width/o.naturalWidth,t.each(s,function(t,a){a/=e,s[t]=i?Dt(a):a})):s={x:0,y:0,width:0,height:0},a.rotatable&&(s.rotate=o.rotate||0),a.scalable&&(s.scaleX=o.scaleX||1,s.scaleY=o.scaleY||1),s},setData:function(e){var s,a,o,h=this.options,n=this.image,r=this.canvas,p={};t.isFunction(e)&&(e=e.call(this.element)),this.isBuilt&&!this.isDisabled&&t.isPlainObject(e)&&(h.rotatable&&i(e.rotate)&&e.rotate!==n.rotate&&(n.rotate=e.rotate,this.isRotated=s=!0),h.scalable&&(i(e.scaleX)&&e.scaleX!==n.scaleX&&(n.scaleX=e.scaleX,a=!0),i(e.scaleY)&&e.scaleY!==n.scaleY&&(n.scaleY=e.scaleY,a=!0)),s?this.renderCanvas():a&&this.renderImage(),o=n.width/n.naturalWidth,i(e.x)&&(p.left=e.x*o+r.left),i(e.y)&&(p.top=e.y*o+r.top),i(e.width)&&(p.width=e.width*o),i(e.height)&&(p.height=e.height*o),this.setCropBoxData(p))},getContainerData:function(){return this.isBuilt?this.container:{}},getImageData:function(){return this.isLoaded?this.image:{}},getCanvasData:function(){var i=this.canvas,e={};return this.isBuilt&&t.each(["left","top","width","height","naturalWidth","naturalHeight"],function(t,s){e[s]=i[s]}),e},setCanvasData:function(e){var s=this.canvas,a=s.aspectRatio;t.isFunction(e)&&(e=e.call(this.$element)),this.isBuilt&&!this.isDisabled&&t.isPlainObject(e)&&(i(e.left)&&(s.left=e.left),i(e.top)&&(s.top=e.top),i(e.width)?(s.width=e.width,s.height=e.width/a):i(e.height)&&(s.height=e.height,s.width=e.height*a),this.renderCanvas(!0))},getCropBoxData:function(){var t,i=this.cropBox;return this.isBuilt&&this.isCropped&&(t={left:i.left,top:i.top,width:i.width,height:i.height}),t||{}},setCropBoxData:function(e){var s,a,o=this.cropBox,h=this.options.aspectRatio;t.isFunction(e)&&(e=e.call(this.$element)),this.isBuilt&&this.isCropped&&!this.isDisabled&&t.isPlainObject(e)&&(i(e.left)&&(o.left=e.left),i(e.top)&&(o.top=e.top),i(e.width)&&(s=!0,o.width=e.width),i(e.height)&&(a=!0,o.height=e.height),h&&(s?o.height=o.width/h:a&&(o.width=o.height*h)),this.renderCropBox())},getCroppedCanvas:function(i){var e,s,a,o,h,n,r,p,l,d,g;if(this.isBuilt&&ft)return this.isCropped?(t.isPlainObject(i)||(i={}),g=this.getData(),e=g.width,s=g.height,p=e/s,t.isPlainObject(i)&&(h=i.width,n=i.height,h?(n=h/p,r=h/e):n&&(h=n*p,r=n/s)),a=$t(h||e),o=$t(n||s),l=t("")[0],l.width=a,l.height=o,d=l.getContext("2d"),i.fillColor&&(d.fillStyle=i.fillColor,d.fillRect(0,0,a,o)),d.drawImage.apply(d,function(){var t,i,a,o,h,n,p=c(this.$clone[0],this.image),l=p.width,d=p.height,u=this.canvas,f=[p],m=g.x+u.naturalWidth*(Ct(g.scaleX||1)-1)/2,v=g.y+u.naturalHeight*(Ct(g.scaleY||1)-1)/2;return m<=-e||m>l?m=t=a=h=0:m<=0?(a=-m,m=0,t=h=wt(l,e+m)):m<=l&&(a=0,t=h=wt(e,l-m)),t<=0||v<=-s||v>d?v=i=o=n=0:v<=0?(o=-v,v=0,i=n=wt(d,s+v)):v<=d&&(o=0,i=n=wt(s,d-v)),f.push($t(m),$t(v),$t(t),$t(i)),r&&(a*=r,o*=r,h*=r,n*=r),h>0&&n>0&&f.push($t(a),$t(o),$t(h),$t(n)),f}.call(this)),l):c(this.$clone[0],this.image)},setAspectRatio:function(t){var i=this.options;this.isDisabled||e(t)||(i.aspectRatio=xt(0,t)||NaN,this.isBuilt&&(this.initCropBox(),this.isCropped&&this.renderCropBox()))},setDragMode:function(t){var i,e,s=this.options;this.isLoaded&&!this.isDisabled&&(i=t===ct,e=s.movable&&t===dt,t=i||e?t:ut,this.$dragBox.data(it,t).toggleClass(W,i).toggleClass(M,e),s.cropBoxMovable||this.$face.data(it,t).toggleClass(W,i).toggleClass(M,e))}},v.DEFAULTS={viewMode:0,dragMode:"crop",aspectRatio:NaN,data:null,preview:"",responsive:!0,restore:!0,checkCrossOrigin:!0,checkOrientation:!0,modal:!0,guides:!0,center:!0,highlight:!0,background:!0,autoCrop:!0,autoCropArea:.8,movable:!0,rotatable:!0,scalable:!0,zoomable:!0,zoomOnTouch:!0,zoomOnWheel:!0,wheelZoomRatio:.1,cropBoxMovable:!0,cropBoxResizable:!0,toggleDragModeOnDblclick:!0,minCanvasWidth:0,minCanvasHeight:0,minCropBoxWidth:0,minCropBoxHeight:0,minContainerWidth:200,minContainerHeight:100,build:null,built:null,cropstart:null,cropmove:null,cropend:null,crop:null,zoom:null},v.setDefaults=function(i){t.extend(v.DEFAULTS,i)},v.TEMPLATE='
',v.other=t.fn.cropper,t.fn.cropper=function(i){var a,o=s(arguments,1);return this.each(function(){var e,s,h=t(this),n=h.data(L);if(!n){if(/destroy/.test(i))return;e=t.extend({},h.data(),t.isPlainObject(i)&&i),h.data(L,n=new v(this,e))}"string"==typeof i&&t.isFunction(s=n[i])&&(a=s.apply(n,o))}),e(a)?this:a},t.fn.cropper.Constructor=v,t.fn.cropper.setDefaults=v.setDefaults,t.fn.cropper.noConflict=function(){return t.fn.cropper=v.other,this}}); \ No newline at end of file +!function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t("object"==typeof exports?require("jquery"):jQuery)}(function(t){"use strict";function i(t){return"number"==typeof t&&!isNaN(t)}function e(t){return"undefined"==typeof t}function s(t,e){var s=[];return i(e)&&s.push(e),s.slice.apply(t,s)}function a(t,i){var e=s(arguments,2);return function(){return t.apply(i,e.concat(s(arguments)))}}function o(t){var i=t.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i);return i&&(i[1]!==C.protocol||i[2]!==C.hostname||i[3]!==C.port)}function h(t){var i="timestamp="+(new Date).getTime();return t+(t.indexOf("?")===-1?"?":"&")+i}function n(t){return t?' crossOrigin="'+t+'"':""}function r(t,i){var e;return t.naturalWidth&&!mt?i(t.naturalWidth,t.naturalHeight):(e=document.createElement("img"),e.onload=function(){i(this.width,this.height)},void(e.src=t.src))}function p(t){var e=[],s=t.rotate,a=t.scaleX,o=t.scaleY;return i(s)&&0!==s&&e.push("rotate("+s+"deg)"),i(a)&&1!==a&&e.push("scaleX("+a+")"),i(o)&&1!==o&&e.push("scaleY("+o+")"),e.length?e.join(" "):"none"}function l(t,i){var e,s,a=Ct(t.degree)%180,o=(a>90?180-a:a)*Math.PI/180,h=bt(o),n=Bt(o),r=t.width,p=t.height,l=t.aspectRatio;return i?(e=r/(n+h/l),s=e/l):(e=r*n+p*h,s=r*h+p*n),{width:e,height:s}}function c(e,s){var a,o,h,n=t("")[0],r=n.getContext("2d"),p=0,c=0,d=s.naturalWidth,g=s.naturalHeight,u=s.rotate,f=s.scaleX,m=s.scaleY,v=i(f)&&i(m)&&(1!==f||1!==m),w=i(u)&&0!==u,x=w||v,C=d*Ct(f||1),b=g*Ct(m||1);return v&&(a=C/2,o=b/2),w&&(h=l({width:C,height:b,degree:u}),C=h.width,b=h.height,a=C/2,o=b/2),n.width=C,n.height=b,x&&(p=-d/2,c=-g/2,r.save(),r.translate(a,o)),w&&r.rotate(u*Math.PI/180),v&&r.scale(f,m),r.drawImage(e,$t(p),$t(c),$t(d),$t(g)),x&&r.restore(),n}function d(i){var e=i.length,s=0,a=0;return e&&(t.each(i,function(t,i){s+=i.pageX,a+=i.pageY}),s/=e,a/=e),{pageX:s,pageY:a}}function g(t,i,e){var s,a="";for(s=i,e+=i;s=8&&(r=s+a)))),r)for(d=c.getUint16(r,o),l=0;l")[0].getContext),mt=b&&/(Macintosh|iPhone|iPod|iPad).*AppleWebKit/i.test(b.userAgent),vt=Number,wt=Math.min,xt=Math.max,Ct=Math.abs,bt=Math.sin,Bt=Math.cos,yt=Math.sqrt,Dt=Math.round,$t=Math.floor,Lt=String.fromCharCode;v.prototype={constructor:v,init:function(){var t,i=this.$element;if(i.is("img")){if(this.isImg=!0,this.originalUrl=t=i.attr("src"),!t)return;t=i.prop("src")}else i.is("canvas")&&ft&&(t=i[0].toDataURL());this.load(t)},trigger:function(i,e){var s=t.Event(i,e);return this.$element.trigger(s),s},load:function(i){var e,s,a=this.options,n=this.$element;if(i&&(n.one(A,a.build),!this.trigger(A).isDefaultPrevented())){if(this.url=i,this.image={},!a.checkOrientation||!B)return this.clone();if(e=t.proxy(this.read,this),V.test(i))return J.test(i)?e(f(i)):this.clone();s=new XMLHttpRequest,s.onerror=s.onabort=t.proxy(function(){this.clone()},this),s.onload=function(){e(this.response)},a.checkCrossOrigin&&o(i)&&n.prop("crossOrigin")&&(i=h(i)),s.open("get",i),s.responseType="arraybuffer",s.send()}},read:function(t){var i=this.options,e=u(t),s=this.image,a=0,o=1,h=1;if(e>1)switch(this.url=m(t),e){case 2:o=-1;break;case 3:a=-180;break;case 4:h=-1;break;case 5:a=90,h=-1;break;case 6:a=90;break;case 7:a=90,o=-1;break;case 8:a=-90}i.rotatable&&(s.rotate=a),i.scalable&&(s.scaleX=o,s.scaleY=h),this.clone()},clone:function(){var i,e,s=this.options,a=this.$element,r=this.url,p="";s.checkCrossOrigin&&o(r)&&(p=a.prop("crossOrigin"),p?i=r:(p="anonymous",i=h(r))),this.crossOrigin=p,this.crossOriginUrl=i,this.$clone=e=t("'),this.isImg?a[0].complete?this.start():a.one(I,t.proxy(this.start,this)):e.one(I,t.proxy(this.start,this)).one(F,t.proxy(this.stop,this)).addClass(X).insertAfter(a)},start:function(){var i=this.$element,e=this.$clone;this.isImg||(e.off(F,this.stop),i=e),r(i[0],t.proxy(function(i,e){t.extend(this.image,{naturalWidth:i,naturalHeight:e,aspectRatio:i/e}),this.isLoaded=!0,this.build()},this))},stop:function(){this.$clone.remove(),this.$clone=null},build:function(){var i,e,s,a=this.options,o=this.$element,h=this.$clone;this.isLoaded&&(this.isBuilt&&this.unbuild(),this.$container=o.parent(),this.$cropper=i=t(v.TEMPLATE),this.$canvas=i.find(".cropper-canvas").append(h),this.$dragBox=i.find(".cropper-drag-box"),this.$cropBox=e=i.find(".cropper-crop-box"),this.$viewBox=i.find(".cropper-view-box"),this.$face=s=e.find(".cropper-face"),o.addClass(Y).after(i),this.isImg||h.removeClass(X),this.initPreview(),this.bind(),a.aspectRatio=xt(0,a.aspectRatio)||NaN,a.viewMode=xt(0,wt(3,Dt(a.viewMode)))||0,a.autoCrop?(this.isCropped=!0,a.modal&&this.$dragBox.addClass(T)):e.addClass(Y),a.guides||e.find(".cropper-dashed").addClass(Y),a.center||e.find(".cropper-center").addClass(Y),a.cropBoxMovable&&s.addClass(M).data(it,lt),a.highlight||s.addClass(k),a.background&&i.addClass(R),a.cropBoxResizable||e.find(".cropper-line, .cropper-point").addClass(Y),this.setDragMode(a.dragMode),this.render(),this.isBuilt=!0,this.setData(a.data),o.one(S,a.built),this.completing=setTimeout(t.proxy(function(){this.trigger(S),this.trigger(K,this.getData()),this.isCompleted=!0},this),0))},unbuild:function(){this.isBuilt&&(this.isCompleted||clearTimeout(this.completing),this.isBuilt=!1,this.isCompleted=!1,this.initialImage=null,this.initialCanvas=null,this.initialCropBox=null,this.container=null,this.canvas=null,this.cropBox=null,this.unbind(),this.resetPreview(),this.$preview=null,this.$viewBox=null,this.$cropBox=null,this.$dragBox=null,this.$canvas=null,this.$container=null,this.$cropper.remove(),this.$cropper=null)},render:function(){this.initContainer(),this.initCanvas(),this.initCropBox(),this.renderCanvas(),this.isCropped&&this.renderCropBox()},initContainer:function(){var t=this.options,i=this.$element,e=this.$container,s=this.$cropper;s.addClass(Y),i.removeClass(Y),s.css(this.container={width:xt(e.width(),vt(t.minContainerWidth)||200),height:xt(e.height(),vt(t.minContainerHeight)||100)}),i.addClass(Y),s.removeClass(Y)},initCanvas:function(){var i,e=this.options.viewMode,s=this.container,a=s.width,o=s.height,h=this.image,n=h.naturalWidth,r=h.naturalHeight,p=90===Ct(h.rotate),l=p?r:n,c=p?n:r,d=l/c,g=a,u=o;o*d>a?3===e?g=o*d:u=a/d:3===e?u=a/d:g=o*d,i={naturalWidth:l,naturalHeight:c,aspectRatio:d,width:g,height:u},i.oldLeft=i.left=(a-g)/2,i.oldTop=i.top=(o-u)/2,this.canvas=i,this.isLimited=1===e||2===e,this.limitCanvas(!0,!0),this.initialImage=t.extend({},h),this.initialCanvas=t.extend({},i)},limitCanvas:function(t,i){var e,s,a,o,h=this.options,n=h.viewMode,r=this.container,p=r.width,l=r.height,c=this.canvas,d=c.aspectRatio,g=this.cropBox,u=this.isCropped&&g;t&&(e=vt(h.minCanvasWidth)||0,s=vt(h.minCanvasHeight)||0,n&&(n>1?(e=xt(e,p),s=xt(s,l),3===n&&(s*d>e?e=s*d:s=e/d)):e?e=xt(e,u?g.width:0):s?s=xt(s,u?g.height:0):u&&(e=g.width,s=g.height,s*d>e?e=s*d:s=e/d)),e&&s?s*d>e?s=e/d:e=s*d:e?s=e/d:s&&(e=s*d),c.minWidth=e,c.minHeight=s,c.maxWidth=1/0,c.maxHeight=1/0),i&&(n?(a=p-c.width,o=l-c.height,c.minLeft=wt(0,a),c.minTop=wt(0,o),c.maxLeft=xt(0,a),c.maxTop=xt(0,o),u&&this.isLimited&&(c.minLeft=wt(g.left,g.left+g.width-c.width),c.minTop=wt(g.top,g.top+g.height-c.height),c.maxLeft=g.left,c.maxTop=g.top,2===n&&(c.width>=p&&(c.minLeft=wt(0,a),c.maxLeft=xt(0,a)),c.height>=l&&(c.minTop=wt(0,o),c.maxTop=xt(0,o))))):(c.minLeft=-c.width,c.minTop=-c.height,c.maxLeft=p,c.maxTop=l))},renderCanvas:function(t){var i,e,s=this.canvas,a=this.image,o=a.rotate,h=a.naturalWidth,n=a.naturalHeight;this.isRotated&&(this.isRotated=!1,e=l({width:a.width,height:a.height,degree:o}),i=e.width/e.height,i!==s.aspectRatio&&(s.left-=(e.width-s.width)/2,s.top-=(e.height-s.height)/2,s.width=e.width,s.height=e.height,s.aspectRatio=i,s.naturalWidth=h,s.naturalHeight=n,o%180&&(e=l({width:h,height:n,degree:o}),s.naturalWidth=e.width,s.naturalHeight=e.height),this.limitCanvas(!0,!1))),(s.width>s.maxWidth||s.widths.maxHeight||s.heighte.width?o.height=o.width/s:o.width=o.height*s),this.cropBox=o,this.limitCropBox(!0,!0),o.width=wt(xt(o.width,o.minWidth),o.maxWidth),o.height=wt(xt(o.height,o.minHeight),o.maxHeight),o.width=xt(o.minWidth,o.width*a),o.height=xt(o.minHeight,o.height*a),o.oldLeft=o.left=e.left+(e.width-o.width)/2,o.oldTop=o.top=e.top+(e.height-o.height)/2,this.initialCropBox=t.extend({},o)},limitCropBox:function(t,i){var e,s,a,o,h=this.options,n=h.aspectRatio,r=this.container,p=r.width,l=r.height,c=this.canvas,d=this.cropBox,g=this.isLimited;t&&(e=vt(h.minCropBoxWidth)||0,s=vt(h.minCropBoxHeight)||0,e=wt(e,p),s=wt(s,l),a=wt(p,g?c.width:p),o=wt(l,g?c.height:l),n&&(e&&s?s*n>e?s=e/n:e=s*n:e?s=e/n:s&&(e=s*n),o*n>a?o=a/n:a=o*n),d.minWidth=wt(e,a),d.minHeight=wt(s,o),d.maxWidth=a,d.maxHeight=o),i&&(g?(d.minLeft=xt(0,c.left),d.minTop=xt(0,c.top),d.maxLeft=wt(p,c.left+c.width)-d.width,d.maxTop=wt(l,c.top+c.height)-d.height):(d.minLeft=0,d.minTop=0,d.maxLeft=p-d.width,d.maxTop=l-d.height))},renderCropBox:function(){var t=this.options,i=this.container,e=i.width,s=i.height,a=this.cropBox;(a.width>a.maxWidth||a.widtha.maxHeight||a.height'),this.$viewBox.html(i),this.$preview.each(function(){var i=t(this);i.data(tt,{width:i.width(),height:i.height(),html:i.html()}),i.html("')})},resetPreview:function(){this.$preview.each(function(){var i=t(this),e=i.data(tt);i.css({width:e.width,height:e.height}).html(e.html).removeData(tt)})},preview:function(){var i=this.image,e=this.canvas,s=this.cropBox,a=s.width,o=s.height,h=i.width,n=i.height,r=s.left-e.left-i.left,l=s.top-e.top-i.top;this.isCropped&&!this.isDisabled&&(this.$clone2.css({width:h,height:n,marginLeft:-r,marginTop:-l,transform:p(i)}),this.$preview.each(function(){var e=t(this),s=e.data(tt),c=s.width,d=s.height,g=c,u=d,f=1;a&&(f=c/a,u=o*f),o&&u>d&&(f=d/o,g=a*f,u=d),e.css({width:g,height:u}).find("img").css({width:h*f,height:n*f,marginLeft:-r*f,marginTop:-l*f,transform:p(i)})}))},bind:function(){var i=this.options,e=this.$element,s=this.$cropper;t.isFunction(i.cropstart)&&e.on(N,i.cropstart),t.isFunction(i.cropmove)&&e.on(_,i.cropmove),t.isFunction(i.cropend)&&e.on(q,i.cropend),t.isFunction(i.crop)&&e.on(K,i.crop),t.isFunction(i.zoom)&&e.on(Z,i.zoom),s.on(z,t.proxy(this.cropStart,this)),i.zoomable&&i.zoomOnWheel&&s.on(E,t.proxy(this.wheel,this)),i.toggleDragModeOnDblclick&&s.on(U,t.proxy(this.dblclick,this)),x.on(O,this._cropMove=a(this.cropMove,this)).on(P,this._cropEnd=a(this.cropEnd,this)),i.responsive&&w.on(j,this._resize=a(this.resize,this))},unbind:function(){var i=this.options,e=this.$element,s=this.$cropper;t.isFunction(i.cropstart)&&e.off(N,i.cropstart),t.isFunction(i.cropmove)&&e.off(_,i.cropmove),t.isFunction(i.cropend)&&e.off(q,i.cropend),t.isFunction(i.crop)&&e.off(K,i.crop),t.isFunction(i.zoom)&&e.off(Z,i.zoom),s.off(z,this.cropStart),i.zoomable&&i.zoomOnWheel&&s.off(E,this.wheel),i.toggleDragModeOnDblclick&&s.off(U,this.dblclick),x.off(O,this._cropMove).off(P,this._cropEnd),i.responsive&&w.off(j,this._resize)},resize:function(){var i,e,s,a=this.options.restore,o=this.$container,h=this.container;!this.isDisabled&&h&&(s=o.width()/h.width,1===s&&o.height()===h.height||(a&&(i=this.getCanvasData(),e=this.getCropBoxData()),this.render(),a&&(this.setCanvasData(t.each(i,function(t,e){i[t]=e*s})),this.setCropBoxData(t.each(e,function(t,i){e[t]=i*s})))))},dblclick:function(){this.isDisabled||(this.$dragBox.hasClass(W)?this.setDragMode(dt):this.setDragMode(ct))},wheel:function(i){var e=i.originalEvent||i,s=vt(this.options.wheelZoomRatio)||.1,a=1;this.isDisabled||(i.preventDefault(),this.wheeling||(this.wheeling=!0,setTimeout(t.proxy(function(){this.wheeling=!1},this),50),e.deltaY?a=e.deltaY>0?1:-1:e.wheelDelta?a=-e.wheelDelta/120:e.detail&&(a=e.detail>0?1:-1),this.zoom(-a*s,i)))},cropStart:function(i){var e,s,a=this.options,o=i.originalEvent,h=o&&o.touches,n=i;if(!this.isDisabled){if(h){if(e=h.length,e>1){if(!a.zoomable||!a.zoomOnTouch||2!==e)return;n=h[1],this.startX2=n.pageX,this.startY2=n.pageY,s=gt}n=h[0]}if(s=s||t(n.target).data(it),Q.test(s)){if(this.trigger(N,{originalEvent:o,action:s}).isDefaultPrevented())return;i.preventDefault(),this.action=s,this.cropping=!1,this.startX=n.pageX||o&&o.pageX,this.startY=n.pageY||o&&o.pageY,s===ct&&(this.cropping=!0,this.$dragBox.addClass(T))}}},cropMove:function(t){var i,e=this.options,s=t.originalEvent,a=s&&s.touches,o=t,h=this.action;if(!this.isDisabled){if(a){if(i=a.length,i>1){if(!e.zoomable||!e.zoomOnTouch||2!==i)return;o=a[1],this.endX2=o.pageX,this.endY2=o.pageY}o=a[0]}if(h){if(this.trigger(_,{originalEvent:s,action:h}).isDefaultPrevented())return;t.preventDefault(),this.endX=o.pageX||s&&s.pageX,this.endY=o.pageY||s&&s.pageY,this.change(o.shiftKey,h===gt?t:null)}}},cropEnd:function(t){var i=t.originalEvent,e=this.action;this.isDisabled||e&&(t.preventDefault(),this.cropping&&(this.cropping=!1,this.$dragBox.toggleClass(T,this.isCropped&&this.options.modal)),this.action="",this.trigger(q,{originalEvent:i,action:e}))},change:function(t,i){var e,s,a=this.options,o=a.aspectRatio,h=this.action,n=this.container,r=this.canvas,p=this.cropBox,l=p.width,c=p.height,d=p.left,g=p.top,u=d+l,f=g+c,m=0,v=0,w=n.width,x=n.height,C=!0;switch(!o&&t&&(o=l&&c?l/c:1),this.isLimited&&(m=p.minLeft,v=p.minTop,w=m+wt(n.width,r.width,r.left+r.width),x=v+wt(n.height,r.height,r.top+r.height)),s={x:this.endX-this.startX,y:this.endY-this.startY},o&&(s.X=s.y*o,s.Y=s.x/o),h){case lt:d+=s.x,g+=s.y;break;case et:if(s.x>=0&&(u>=w||o&&(g<=v||f>=x))){C=!1;break}l+=s.x,o&&(c=l/o,g-=s.Y/2),l<0&&(h=st,l=0);break;case ot:if(s.y<=0&&(g<=v||o&&(d<=m||u>=w))){C=!1;break}c-=s.y,g+=s.y,o&&(l=c*o,d+=s.X/2),c<0&&(h=at,c=0);break;case st:if(s.x<=0&&(d<=m||o&&(g<=v||f>=x))){C=!1;break}l-=s.x,d+=s.x,o&&(c=l/o,g+=s.Y/2),l<0&&(h=et,l=0);break;case at:if(s.y>=0&&(f>=x||o&&(d<=m||u>=w))){C=!1;break}c+=s.y,o&&(l=c*o,d-=s.X/2),c<0&&(h=ot,c=0);break;case rt:if(o){if(s.y<=0&&(g<=v||u>=w)){C=!1;break}c-=s.y,g+=s.y,l=c*o}else s.x>=0?uv&&(c-=s.y,g+=s.y):(c-=s.y,g+=s.y);l<0&&c<0?(h=nt,c=0,l=0):l<0?(h=pt,l=0):c<0&&(h=ht,c=0);break;case pt:if(o){if(s.y<=0&&(g<=v||d<=m)){C=!1;break}c-=s.y,g+=s.y,l=c*o,d+=s.X}else s.x<=0?d>m?(l-=s.x,d+=s.x):s.y<=0&&g<=v&&(C=!1):(l-=s.x,d+=s.x),s.y<=0?g>v&&(c-=s.y,g+=s.y):(c-=s.y,g+=s.y);l<0&&c<0?(h=ht,c=0,l=0):l<0?(h=rt,l=0):c<0&&(h=nt,c=0);break;case nt:if(o){if(s.x<=0&&(d<=m||f>=x)){C=!1;break}l-=s.x,d+=s.x,c=l/o}else s.x<=0?d>m?(l-=s.x,d+=s.x):s.y>=0&&f>=x&&(C=!1):(l-=s.x,d+=s.x),s.y>=0?f=0&&(u>=w||f>=x)){C=!1;break}l+=s.x,c=l/o}else s.x>=0?u=0&&f>=x&&(C=!1):l+=s.x,s.y>=0?f0?h=s.y>0?ht:rt:s.x<0&&(d-=l,h=s.y>0?nt:pt),s.y<0&&(g-=c),this.isCropped||(this.$cropBox.removeClass(Y),this.isCropped=!0,this.isLimited&&this.limitCropBox(!0,!0))}C&&(p.width=l,p.height=c,p.left=d,p.top=g,this.action=h,this.renderCropBox()),this.startX=this.endX,this.startY=this.endY},crop:function(){this.isBuilt&&!this.isDisabled&&(this.isCropped||(this.isCropped=!0,this.limitCropBox(!0,!0),this.options.modal&&this.$dragBox.addClass(T),this.$cropBox.removeClass(Y)),this.setCropBoxData(this.initialCropBox))},reset:function(){this.isBuilt&&!this.isDisabled&&(this.image=t.extend({},this.initialImage),this.canvas=t.extend({},this.initialCanvas),this.cropBox=t.extend({},this.initialCropBox),this.renderCanvas(),this.isCropped&&this.renderCropBox())},clear:function(){this.isCropped&&!this.isDisabled&&(t.extend(this.cropBox,{left:0,top:0,width:0,height:0}),this.isCropped=!1,this.renderCropBox(),this.limitCanvas(!0,!0),this.renderCanvas(),this.$dragBox.removeClass(T),this.$cropBox.addClass(Y))},replace:function(t,i){!this.isDisabled&&t&&(this.isImg&&this.$element.attr("src",t),i?(this.url=t,this.$clone.attr("src",t),this.isBuilt&&this.$preview.find("img").add(this.$clone2).attr("src",t)):(this.isImg&&(this.isReplaced=!0),this.options.data=null,this.load(t)))},enable:function(){this.isBuilt&&(this.isDisabled=!1,this.$cropper.removeClass(H))},disable:function(){this.isBuilt&&(this.isDisabled=!0,this.$cropper.addClass(H))},destroy:function(){var t=this.$element;this.isLoaded?(this.isImg&&this.isReplaced&&t.attr("src",this.originalUrl),this.unbuild(),t.removeClass(Y)):this.isImg?t.off(I,this.start):this.$clone&&this.$clone.remove(),t.removeData(L)},move:function(t,i){var s=this.canvas;this.moveTo(e(t)?t:s.left+vt(t),e(i)?i:s.top+vt(i))},moveTo:function(t,s){var a=this.canvas,o=!1;e(s)&&(s=t),t=vt(t),s=vt(s),this.isBuilt&&!this.isDisabled&&this.options.movable&&(i(t)&&(a.left=t,o=!0),i(s)&&(a.top=s,o=!0),o&&this.renderCanvas(!0))},zoom:function(t,i){var e=this.canvas;t=vt(t),t=t<0?1/(1-t):1+t,this.zoomTo(e.width*t/e.naturalWidth,i)},zoomTo:function(t,i){var e,s,a,o,h,n=this.options,r=this.canvas,p=r.width,l=r.height,c=r.naturalWidth,g=r.naturalHeight;if(t=vt(t),t>=0&&this.isBuilt&&!this.isDisabled&&n.zoomable){if(s=c*t,a=g*t,i&&(e=i.originalEvent),this.trigger(Z,{originalEvent:e,oldRatio:p/c,ratio:s/c}).isDefaultPrevented())return;e?(o=this.$cropper.offset(),h=e.touches?d(e.touches):{pageX:i.pageX||e.pageX||0,pageY:i.pageY||e.pageY||0},r.left-=(s-p)*((h.pageX-o.left-r.left)/p),r.top-=(a-l)*((h.pageY-o.top-r.top)/l)):(r.left-=(s-p)/2,r.top-=(a-l)/2),r.width=s,r.height=a,this.renderCanvas(!0)}},rotate:function(t){this.rotateTo((this.image.rotate||0)+vt(t))},rotateTo:function(t){t=vt(t),i(t)&&this.isBuilt&&!this.isDisabled&&this.options.rotatable&&(this.image.rotate=t%360,this.isRotated=!0,this.renderCanvas(!0))},scale:function(t,s){var a=this.image,o=!1;e(s)&&(s=t),t=vt(t),s=vt(s),this.isBuilt&&!this.isDisabled&&this.options.scalable&&(i(t)&&(a.scaleX=t,o=!0),i(s)&&(a.scaleY=s,o=!0),o&&this.renderImage(!0))},scaleX:function(t){var e=this.image.scaleY;this.scale(t,i(e)?e:1)},scaleY:function(t){var e=this.image.scaleX;this.scale(i(e)?e:1,t)},getData:function(i){var e,s,a=this.options,o=this.image,h=this.canvas,n=this.cropBox;return this.isBuilt&&this.isCropped?(s={x:n.left-h.left,y:n.top-h.top,width:n.width,height:n.height},e=o.width/o.naturalWidth,t.each(s,function(t,a){a/=e,s[t]=i?Dt(a):a})):s={x:0,y:0,width:0,height:0},a.rotatable&&(s.rotate=o.rotate||0),a.scalable&&(s.scaleX=o.scaleX||1,s.scaleY=o.scaleY||1),s},setData:function(e){var s,a,o,h=this.options,n=this.image,r=this.canvas,p={};t.isFunction(e)&&(e=e.call(this.element)),this.isBuilt&&!this.isDisabled&&t.isPlainObject(e)&&(h.rotatable&&i(e.rotate)&&e.rotate!==n.rotate&&(n.rotate=e.rotate,this.isRotated=s=!0),h.scalable&&(i(e.scaleX)&&e.scaleX!==n.scaleX&&(n.scaleX=e.scaleX,a=!0),i(e.scaleY)&&e.scaleY!==n.scaleY&&(n.scaleY=e.scaleY,a=!0)),s?this.renderCanvas():a&&this.renderImage(),o=n.width/n.naturalWidth,i(e.x)&&(p.left=e.x*o+r.left),i(e.y)&&(p.top=e.y*o+r.top),i(e.width)&&(p.width=e.width*o),i(e.height)&&(p.height=e.height*o),this.setCropBoxData(p))},getContainerData:function(){return this.isBuilt?this.container:{}},getImageData:function(){return this.isLoaded?this.image:{}},getCanvasData:function(){var i=this.canvas,e={};return this.isBuilt&&t.each(["left","top","width","height","naturalWidth","naturalHeight"],function(t,s){e[s]=i[s]}),e},setCanvasData:function(e){var s=this.canvas,a=s.aspectRatio;t.isFunction(e)&&(e=e.call(this.$element)),this.isBuilt&&!this.isDisabled&&t.isPlainObject(e)&&(i(e.left)&&(s.left=e.left),i(e.top)&&(s.top=e.top),i(e.width)?(s.width=e.width,s.height=e.width/a):i(e.height)&&(s.height=e.height,s.width=e.height*a),this.renderCanvas(!0))},getCropBoxData:function(){var t,i=this.cropBox;return this.isBuilt&&this.isCropped&&(t={left:i.left,top:i.top,width:i.width,height:i.height}),t||{}},setCropBoxData:function(e){var s,a,o=this.cropBox,h=this.options.aspectRatio;t.isFunction(e)&&(e=e.call(this.$element)),this.isBuilt&&this.isCropped&&!this.isDisabled&&t.isPlainObject(e)&&(i(e.left)&&(o.left=e.left),i(e.top)&&(o.top=e.top),i(e.width)&&(s=!0,o.width=e.width),i(e.height)&&(a=!0,o.height=e.height),h&&(s?o.height=o.width/h:a&&(o.width=o.height*h)),this.renderCropBox())},getCroppedCanvas:function(i){var e,s,a,o,h,n,r,p,l,d,g;if(this.isBuilt&&ft)return this.isCropped?(t.isPlainObject(i)||(i={}),g=this.getData(),e=g.width,s=g.height,p=e/s,t.isPlainObject(i)&&(h=i.width,n=i.height,h?(n=h/p,r=h/e):n&&(h=n*p,r=n/s)),a=$t(h||e),o=$t(n||s),l=t("")[0],l.width=a,l.height=o,d=l.getContext("2d"),i.fillColor&&(d.fillStyle=i.fillColor,d.fillRect(0,0,a,o)),d.drawImage.apply(d,function(){var t,i,a,o,h,n,p=c(this.$clone[0],this.image),l=p.width,d=p.height,u=this.canvas,f=[p],m=g.x+u.naturalWidth*(Ct(g.scaleX||1)-1)/2,v=g.y+u.naturalHeight*(Ct(g.scaleY||1)-1)/2;return m<=-e||m>l?m=t=a=h=0:m<=0?(a=-m,m=0,t=h=wt(l,e+m)):m<=l&&(a=0,t=h=wt(e,l-m)),t<=0||v<=-s||v>d?v=i=o=n=0:v<=0?(o=-v,v=0,i=n=wt(d,s+v)):v<=d&&(o=0,i=n=wt(s,d-v)),f.push($t(m),$t(v),$t(t),$t(i)),r&&(a*=r,o*=r,h*=r,n*=r),h>0&&n>0&&f.push($t(a),$t(o),$t(h),$t(n)),f}.call(this)),l):c(this.$clone[0],this.image)},setAspectRatio:function(t){var i=this.options;this.isDisabled||e(t)||(i.aspectRatio=xt(0,t)||NaN,this.isBuilt&&(this.initCropBox(),this.isCropped&&this.renderCropBox()))},setDragMode:function(t){var i,e,s=this.options;this.isLoaded&&!this.isDisabled&&(i=t===ct,e=s.movable&&t===dt,t=i||e?t:ut,this.$dragBox.data(it,t).toggleClass(W,i).toggleClass(M,e),s.cropBoxMovable||this.$face.data(it,t).toggleClass(W,i).toggleClass(M,e))}},v.DEFAULTS={viewMode:0,dragMode:"crop",aspectRatio:NaN,data:null,preview:"",responsive:!0,restore:!0,checkCrossOrigin:!0,checkOrientation:!0,modal:!0,guides:!0,center:!0,highlight:!0,background:!0,autoCrop:!0,autoCropArea:.8,movable:!0,rotatable:!0,scalable:!0,zoomable:!0,zoomOnTouch:!0,zoomOnWheel:!0,wheelZoomRatio:.1,cropBoxMovable:!0,cropBoxResizable:!0,toggleDragModeOnDblclick:!0,minCanvasWidth:0,minCanvasHeight:0,minCropBoxWidth:0,minCropBoxHeight:0,minContainerWidth:200,minContainerHeight:100,build:null,built:null,cropstart:null,cropmove:null,cropend:null,crop:null,zoom:null},v.setDefaults=function(i){t.extend(v.DEFAULTS,i)},v.TEMPLATE='
',v.other=t.fn.cropper,t.fn.cropper=function(i){var a,o=s(arguments,1);return this.each(function(){var e,s,h=t(this),n=h.data(L);if(!n){if(/destroy/.test(i))return;e=t.extend({},h.data(),t.isPlainObject(i)&&i),h.data(L,n=new v(this,e))}"string"==typeof i&&t.isFunction(s=n[i])&&(a=s.apply(n,o))}),e(a)?this:a},t.fn.cropper.Constructor=v,t.fn.cropper.setDefaults=v.setDefaults,t.fn.cropper.noConflict=function(){return t.fn.cropper=v.other,this}}); diff --git a/OpenOversight/app/static/js/dropzone.js b/OpenOversight/app/static/js/dropzone.js index f9ba2ddd0..0c008c8db 100644 --- a/OpenOversight/app/static/js/dropzone.js +++ b/OpenOversight/app/static/js/dropzone.js @@ -105,9 +105,9 @@ /* This is a list of all available events you can register on a dropzone object. - + You can register an event handler like this: - + dropzone.on("dragEnter", function() { }); */ @@ -1655,7 +1655,7 @@ /* - + Bugfix for iOS 6 and 7 Source: http://stackoverflow.com/questions/11929099/html5-canvas-drawimage-ratio-bug-ios based on the work of https://github.com/stomita/ios-imagefile-megapixel diff --git a/OpenOversight/app/static/js/find_officer.js b/OpenOversight/app/static/js/find_officer.js index 631003444..2507e98b5 100644 --- a/OpenOversight/app/static/js/find_officer.js +++ b/OpenOversight/app/static/js/find_officer.js @@ -18,7 +18,7 @@ $(document).ready(function() { $target.show(); } }); - + let $deptSelectionId = $('#dept').val() $('ul.setup-panel li.active a').trigger('click'); @@ -55,11 +55,11 @@ $(document).ready(function() { if (targetDeptUii) { $('#current-uii').text(targetDeptUii); } else { - $('#uii-question').hide(); + $('#uii-question').hide(); } $(this).remove(); }) - + $('#activate-step-3').on('click', function(e) { $('ul.setup-panel li:eq(2)').removeClass('disabled'); $('ul.setup-panel li a[href="#step-3"]').trigger('click'); diff --git a/OpenOversight/app/static/js/html5shiv.min.js b/OpenOversight/app/static/js/html5shiv.min.js index d4c731ad5..c2e7da61a 100644 --- a/OpenOversight/app/static/js/html5shiv.min.js +++ b/OpenOversight/app/static/js/html5shiv.min.js @@ -1,4 +1,4 @@ /** * @preserve HTML5 Shiv 3.7.2 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed */ -!function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=t.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=t.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),t.elements=c+" "+a,j(b)}function f(a){var b=s[a[q]];return b||(b={},r++,a[q]=r,s[r]=b),b}function g(a,c,d){if(c||(c=b),l)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():p.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||o.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),l)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return t.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(t,b.frag)}function j(a){a||(a=b);var d=f(a);return!t.shivCSS||k||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),l||i(a,d),a}var k,l,m="3.7.2",n=a.html5||{},o=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,p=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,q="_html5shiv",r=0,s={};!function(){try{var a=b.createElement("a");a.innerHTML="",k="hidden"in a,l=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){k=!0,l=!0}}();var t={elements:n.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:n.shivCSS!==!1,supportsUnknownElements:l,shivMethods:n.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=t,j(b)}(this,document); \ No newline at end of file +!function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=t.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=t.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),t.elements=c+" "+a,j(b)}function f(a){var b=s[a[q]];return b||(b={},r++,a[q]=r,s[r]=b),b}function g(a,c,d){if(c||(c=b),l)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():p.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||o.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),l)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return t.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(t,b.frag)}function j(a){a||(a=b);var d=f(a);return!t.shivCSS||k||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),l||i(a,d),a}var k,l,m="3.7.2",n=a.html5||{},o=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,p=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,q="_html5shiv",r=0,s={};!function(){try{var a=b.createElement("a");a.innerHTML="",k="hidden"in a,l=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){k=!0,l=!0}}();var t={elements:n.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:n.shivCSS!==!1,supportsUnknownElements:l,shivMethods:n.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=t,j(b)}(this,document); diff --git a/OpenOversight/app/static/js/incidentDescription.js b/OpenOversight/app/static/js/incidentDescription.js index 5bc748733..23c5155f8 100644 --- a/OpenOversight/app/static/js/incidentDescription.js +++ b/OpenOversight/app/static/js/incidentDescription.js @@ -14,4 +14,4 @@ $(document).ready(function() { }) } }) -}); \ No newline at end of file +}); diff --git a/OpenOversight/app/static/js/jquery-ui.min.js b/OpenOversight/app/static/js/jquery-ui.min.js index feadb475f..ebd747c7a 100644 --- a/OpenOversight/app/static/js/jquery-ui.min.js +++ b/OpenOversight/app/static/js/jquery-ui.min.js @@ -4,4 +4,4 @@ * Copyright jQuery Foundation and other contributors; Licensed MIT */ (function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t(jQuery)})(function(t){t.ui=t.ui||{},t.ui.version="1.12.1";var e=0,i=Array.prototype.slice;t.cleanData=function(e){return function(i){var s,n,o;for(o=0;null!=(n=i[o]);o++)try{s=t._data(n,"events"),s&&s.remove&&t(n).triggerHandler("remove")}catch(a){}e(i)}}(t.cleanData),t.widget=function(e,i,s){var n,o,a,r={},l=e.split(".")[0];e=e.split(".")[1];var h=l+"-"+e;return s||(s=i,i=t.Widget),t.isArray(s)&&(s=t.extend.apply(null,[{}].concat(s))),t.expr[":"][h.toLowerCase()]=function(e){return!!t.data(e,h)},t[l]=t[l]||{},n=t[l][e],o=t[l][e]=function(t,e){return this._createWidget?(arguments.length&&this._createWidget(t,e),void 0):new o(t,e)},t.extend(o,n,{version:s.version,_proto:t.extend({},s),_childConstructors:[]}),a=new i,a.options=t.widget.extend({},a.options),t.each(s,function(e,s){return t.isFunction(s)?(r[e]=function(){function t(){return i.prototype[e].apply(this,arguments)}function n(t){return i.prototype[e].apply(this,t)}return function(){var e,i=this._super,o=this._superApply;return this._super=t,this._superApply=n,e=s.apply(this,arguments),this._super=i,this._superApply=o,e}}(),void 0):(r[e]=s,void 0)}),o.prototype=t.widget.extend(a,{widgetEventPrefix:n?a.widgetEventPrefix||e:e},r,{constructor:o,namespace:l,widgetName:e,widgetFullName:h}),n?(t.each(n._childConstructors,function(e,i){var s=i.prototype;t.widget(s.namespace+"."+s.widgetName,o,i._proto)}),delete n._childConstructors):i._childConstructors.push(o),t.widget.bridge(e,o),o},t.widget.extend=function(e){for(var s,n,o=i.call(arguments,1),a=0,r=o.length;r>a;a++)for(s in o[a])n=o[a][s],o[a].hasOwnProperty(s)&&void 0!==n&&(e[s]=t.isPlainObject(n)?t.isPlainObject(e[s])?t.widget.extend({},e[s],n):t.widget.extend({},n):n);return e},t.widget.bridge=function(e,s){var n=s.prototype.widgetFullName||e;t.fn[e]=function(o){var a="string"==typeof o,r=i.call(arguments,1),l=this;return a?this.length||"instance"!==o?this.each(function(){var i,s=t.data(this,n);return"instance"===o?(l=s,!1):s?t.isFunction(s[o])&&"_"!==o.charAt(0)?(i=s[o].apply(s,r),i!==s&&void 0!==i?(l=i&&i.jquery?l.pushStack(i.get()):i,!1):void 0):t.error("no such method '"+o+"' for "+e+" widget instance"):t.error("cannot call methods on "+e+" prior to initialization; "+"attempted to call method '"+o+"'")}):l=void 0:(r.length&&(o=t.widget.extend.apply(null,[o].concat(r))),this.each(function(){var e=t.data(this,n);e?(e.option(o||{}),e._init&&e._init()):t.data(this,n,new s(o,this))})),l}},t.Widget=function(){},t.Widget._childConstructors=[],t.Widget.prototype={widgetName:"widget",widgetEventPrefix:"",defaultElement:"
",options:{classes:{},disabled:!1,create:null},_createWidget:function(i,s){s=t(s||this.defaultElement||this)[0],this.element=t(s),this.uuid=e++,this.eventNamespace="."+this.widgetName+this.uuid,this.bindings=t(),this.hoverable=t(),this.focusable=t(),this.classesElementLookup={},s!==this&&(t.data(s,this.widgetFullName,this),this._on(!0,this.element,{remove:function(t){t.target===s&&this.destroy()}}),this.document=t(s.style?s.ownerDocument:s.document||s),this.window=t(this.document[0].defaultView||this.document[0].parentWindow)),this.options=t.widget.extend({},this.options,this._getCreateOptions(),i),this._create(),this.options.disabled&&this._setOptionDisabled(this.options.disabled),this._trigger("create",null,this._getCreateEventData()),this._init()},_getCreateOptions:function(){return{}},_getCreateEventData:t.noop,_create:t.noop,_init:t.noop,destroy:function(){var e=this;this._destroy(),t.each(this.classesElementLookup,function(t,i){e._removeClass(i,t)}),this.element.off(this.eventNamespace).removeData(this.widgetFullName),this.widget().off(this.eventNamespace).removeAttr("aria-disabled"),this.bindings.off(this.eventNamespace)},_destroy:t.noop,widget:function(){return this.element},option:function(e,i){var s,n,o,a=e;if(0===arguments.length)return t.widget.extend({},this.options);if("string"==typeof e)if(a={},s=e.split("."),e=s.shift(),s.length){for(n=a[e]=t.widget.extend({},this.options[e]),o=0;s.length-1>o;o++)n[s[o]]=n[s[o]]||{},n=n[s[o]];if(e=s.pop(),1===arguments.length)return void 0===n[e]?null:n[e];n[e]=i}else{if(1===arguments.length)return void 0===this.options[e]?null:this.options[e];a[e]=i}return this._setOptions(a),this},_setOptions:function(t){var e;for(e in t)this._setOption(e,t[e]);return this},_setOption:function(t,e){return"classes"===t&&this._setOptionClasses(e),this.options[t]=e,"disabled"===t&&this._setOptionDisabled(e),this},_setOptionClasses:function(e){var i,s,n;for(i in e)n=this.classesElementLookup[i],e[i]!==this.options.classes[i]&&n&&n.length&&(s=t(n.get()),this._removeClass(n,i),s.addClass(this._classes({element:s,keys:i,classes:e,add:!0})))},_setOptionDisabled:function(t){this._toggleClass(this.widget(),this.widgetFullName+"-disabled",null,!!t),t&&(this._removeClass(this.hoverable,null,"ui-state-hover"),this._removeClass(this.focusable,null,"ui-state-focus"))},enable:function(){return this._setOptions({disabled:!1})},disable:function(){return this._setOptions({disabled:!0})},_classes:function(e){function i(i,o){var a,r;for(r=0;i.length>r;r++)a=n.classesElementLookup[i[r]]||t(),a=e.add?t(t.unique(a.get().concat(e.element.get()))):t(a.not(e.element).get()),n.classesElementLookup[i[r]]=a,s.push(i[r]),o&&e.classes[i[r]]&&s.push(e.classes[i[r]])}var s=[],n=this;return e=t.extend({element:this.element,classes:this.options.classes||{}},e),this._on(e.element,{remove:"_untrackClassesElement"}),e.keys&&i(e.keys.match(/\S+/g)||[],!0),e.extra&&i(e.extra.match(/\S+/g)||[]),s.join(" ")},_untrackClassesElement:function(e){var i=this;t.each(i.classesElementLookup,function(s,n){-1!==t.inArray(e.target,n)&&(i.classesElementLookup[s]=t(n.not(e.target).get()))})},_removeClass:function(t,e,i){return this._toggleClass(t,e,i,!1)},_addClass:function(t,e,i){return this._toggleClass(t,e,i,!0)},_toggleClass:function(t,e,i,s){s="boolean"==typeof s?s:i;var n="string"==typeof t||null===t,o={extra:n?e:i,keys:n?t:e,element:n?this.element:t,add:s};return o.element.toggleClass(this._classes(o),s),this},_on:function(e,i,s){var n,o=this;"boolean"!=typeof e&&(s=i,i=e,e=!1),s?(i=n=t(i),this.bindings=this.bindings.add(i)):(s=i,i=this.element,n=this.widget()),t.each(s,function(s,a){function r(){return e||o.options.disabled!==!0&&!t(this).hasClass("ui-state-disabled")?("string"==typeof a?o[a]:a).apply(o,arguments):void 0}"string"!=typeof a&&(r.guid=a.guid=a.guid||r.guid||t.guid++);var l=s.match(/^([\w:-]*)\s*(.*)$/),h=l[1]+o.eventNamespace,c=l[2];c?n.on(h,c,r):i.on(h,r)})},_off:function(e,i){i=(i||"").split(" ").join(this.eventNamespace+" ")+this.eventNamespace,e.off(i).off(i),this.bindings=t(this.bindings.not(e).get()),this.focusable=t(this.focusable.not(e).get()),this.hoverable=t(this.hoverable.not(e).get())},_delay:function(t,e){function i(){return("string"==typeof t?s[t]:t).apply(s,arguments)}var s=this;return setTimeout(i,e||0)},_hoverable:function(e){this.hoverable=this.hoverable.add(e),this._on(e,{mouseenter:function(e){this._addClass(t(e.currentTarget),null,"ui-state-hover")},mouseleave:function(e){this._removeClass(t(e.currentTarget),null,"ui-state-hover")}})},_focusable:function(e){this.focusable=this.focusable.add(e),this._on(e,{focusin:function(e){this._addClass(t(e.currentTarget),null,"ui-state-focus")},focusout:function(e){this._removeClass(t(e.currentTarget),null,"ui-state-focus")}})},_trigger:function(e,i,s){var n,o,a=this.options[e];if(s=s||{},i=t.Event(i),i.type=(e===this.widgetEventPrefix?e:this.widgetEventPrefix+e).toLowerCase(),i.target=this.element[0],o=i.originalEvent)for(n in o)n in i||(i[n]=o[n]);return this.element.trigger(i,s),!(t.isFunction(a)&&a.apply(this.element[0],[i].concat(s))===!1||i.isDefaultPrevented())}},t.each({show:"fadeIn",hide:"fadeOut"},function(e,i){t.Widget.prototype["_"+e]=function(s,n,o){"string"==typeof n&&(n={effect:n});var a,r=n?n===!0||"number"==typeof n?i:n.effect||i:e;n=n||{},"number"==typeof n&&(n={duration:n}),a=!t.isEmptyObject(n),n.complete=o,n.delay&&s.delay(n.delay),a&&t.effects&&t.effects.effect[r]?s[e](n):r!==e&&s[r]?s[r](n.duration,n.easing,o):s.queue(function(i){t(this)[e](),o&&o.call(s[0]),i()})}}),t.widget,t.extend(t.expr[":"],{data:t.expr.createPseudo?t.expr.createPseudo(function(e){return function(i){return!!t.data(i,e)}}):function(e,i,s){return!!t.data(e,s[3])}}),t.fn.scrollParent=function(e){var i=this.css("position"),s="absolute"===i,n=e?/(auto|scroll|hidden)/:/(auto|scroll)/,o=this.parents().filter(function(){var e=t(this);return s&&"static"===e.css("position")?!1:n.test(e.css("overflow")+e.css("overflow-y")+e.css("overflow-x"))}).eq(0);return"fixed"!==i&&o.length?o:t(this[0].ownerDocument||document)},t.ui.ie=!!/msie [\w.]+/.exec(navigator.userAgent.toLowerCase());var s=!1;t(document).on("mouseup",function(){s=!1}),t.widget("ui.mouse",{version:"1.12.1",options:{cancel:"input, textarea, button, select, option",distance:1,delay:0},_mouseInit:function(){var e=this;this.element.on("mousedown."+this.widgetName,function(t){return e._mouseDown(t)}).on("click."+this.widgetName,function(i){return!0===t.data(i.target,e.widgetName+".preventClickEvent")?(t.removeData(i.target,e.widgetName+".preventClickEvent"),i.stopImmediatePropagation(),!1):void 0}),this.started=!1},_mouseDestroy:function(){this.element.off("."+this.widgetName),this._mouseMoveDelegate&&this.document.off("mousemove."+this.widgetName,this._mouseMoveDelegate).off("mouseup."+this.widgetName,this._mouseUpDelegate)},_mouseDown:function(e){if(!s){this._mouseMoved=!1,this._mouseStarted&&this._mouseUp(e),this._mouseDownEvent=e;var i=this,n=1===e.which,o="string"==typeof this.options.cancel&&e.target.nodeName?t(e.target).closest(this.options.cancel).length:!1;return n&&!o&&this._mouseCapture(e)?(this.mouseDelayMet=!this.options.delay,this.mouseDelayMet||(this._mouseDelayTimer=setTimeout(function(){i.mouseDelayMet=!0},this.options.delay)),this._mouseDistanceMet(e)&&this._mouseDelayMet(e)&&(this._mouseStarted=this._mouseStart(e)!==!1,!this._mouseStarted)?(e.preventDefault(),!0):(!0===t.data(e.target,this.widgetName+".preventClickEvent")&&t.removeData(e.target,this.widgetName+".preventClickEvent"),this._mouseMoveDelegate=function(t){return i._mouseMove(t)},this._mouseUpDelegate=function(t){return i._mouseUp(t)},this.document.on("mousemove."+this.widgetName,this._mouseMoveDelegate).on("mouseup."+this.widgetName,this._mouseUpDelegate),e.preventDefault(),s=!0,!0)):!0}},_mouseMove:function(e){if(this._mouseMoved){if(t.ui.ie&&(!document.documentMode||9>document.documentMode)&&!e.button)return this._mouseUp(e);if(!e.which)if(e.originalEvent.altKey||e.originalEvent.ctrlKey||e.originalEvent.metaKey||e.originalEvent.shiftKey)this.ignoreMissingWhich=!0;else if(!this.ignoreMissingWhich)return this._mouseUp(e)}return(e.which||e.button)&&(this._mouseMoved=!0),this._mouseStarted?(this._mouseDrag(e),e.preventDefault()):(this._mouseDistanceMet(e)&&this._mouseDelayMet(e)&&(this._mouseStarted=this._mouseStart(this._mouseDownEvent,e)!==!1,this._mouseStarted?this._mouseDrag(e):this._mouseUp(e)),!this._mouseStarted)},_mouseUp:function(e){this.document.off("mousemove."+this.widgetName,this._mouseMoveDelegate).off("mouseup."+this.widgetName,this._mouseUpDelegate),this._mouseStarted&&(this._mouseStarted=!1,e.target===this._mouseDownEvent.target&&t.data(e.target,this.widgetName+".preventClickEvent",!0),this._mouseStop(e)),this._mouseDelayTimer&&(clearTimeout(this._mouseDelayTimer),delete this._mouseDelayTimer),this.ignoreMissingWhich=!1,s=!1,e.preventDefault()},_mouseDistanceMet:function(t){return Math.max(Math.abs(this._mouseDownEvent.pageX-t.pageX),Math.abs(this._mouseDownEvent.pageY-t.pageY))>=this.options.distance},_mouseDelayMet:function(){return this.mouseDelayMet},_mouseStart:function(){},_mouseDrag:function(){},_mouseStop:function(){},_mouseCapture:function(){return!0}}),t.ui.plugin={add:function(e,i,s){var n,o=t.ui[e].prototype;for(n in s)o.plugins[n]=o.plugins[n]||[],o.plugins[n].push([i,s[n]])},call:function(t,e,i,s){var n,o=t.plugins[e];if(o&&(s||t.element[0].parentNode&&11!==t.element[0].parentNode.nodeType))for(n=0;o.length>n;n++)t.options[o[n][0]]&&o[n][1].apply(t.element,i)}},t.ui.safeActiveElement=function(t){var e;try{e=t.activeElement}catch(i){e=t.body}return e||(e=t.body),e.nodeName||(e=t.body),e},t.ui.safeBlur=function(e){e&&"body"!==e.nodeName.toLowerCase()&&t(e).trigger("blur")},t.widget("ui.draggable",t.ui.mouse,{version:"1.12.1",widgetEventPrefix:"drag",options:{addClasses:!0,appendTo:"parent",axis:!1,connectToSortable:!1,containment:!1,cursor:"auto",cursorAt:!1,grid:!1,handle:!1,helper:"original",iframeFix:!1,opacity:!1,refreshPositions:!1,revert:!1,revertDuration:500,scope:"default",scroll:!0,scrollSensitivity:20,scrollSpeed:20,snap:!1,snapMode:"both",snapTolerance:20,stack:!1,zIndex:!1,drag:null,start:null,stop:null},_create:function(){"original"===this.options.helper&&this._setPositionRelative(),this.options.addClasses&&this._addClass("ui-draggable"),this._setHandleClassName(),this._mouseInit()},_setOption:function(t,e){this._super(t,e),"handle"===t&&(this._removeHandleClassName(),this._setHandleClassName())},_destroy:function(){return(this.helper||this.element).is(".ui-draggable-dragging")?(this.destroyOnClear=!0,void 0):(this._removeHandleClassName(),this._mouseDestroy(),void 0)},_mouseCapture:function(e){var i=this.options;return this.helper||i.disabled||t(e.target).closest(".ui-resizable-handle").length>0?!1:(this.handle=this._getHandle(e),this.handle?(this._blurActiveElement(e),this._blockFrames(i.iframeFix===!0?"iframe":i.iframeFix),!0):!1)},_blockFrames:function(e){this.iframeBlocks=this.document.find(e).map(function(){var e=t(this);return t("
").css("position","absolute").appendTo(e.parent()).outerWidth(e.outerWidth()).outerHeight(e.outerHeight()).offset(e.offset())[0]})},_unblockFrames:function(){this.iframeBlocks&&(this.iframeBlocks.remove(),delete this.iframeBlocks)},_blurActiveElement:function(e){var i=t.ui.safeActiveElement(this.document[0]),s=t(e.target);s.closest(i).length||t.ui.safeBlur(i)},_mouseStart:function(e){var i=this.options;return this.helper=this._createHelper(e),this._addClass(this.helper,"ui-draggable-dragging"),this._cacheHelperProportions(),t.ui.ddmanager&&(t.ui.ddmanager.current=this),this._cacheMargins(),this.cssPosition=this.helper.css("position"),this.scrollParent=this.helper.scrollParent(!0),this.offsetParent=this.helper.offsetParent(),this.hasFixedAncestor=this.helper.parents().filter(function(){return"fixed"===t(this).css("position")}).length>0,this.positionAbs=this.element.offset(),this._refreshOffsets(e),this.originalPosition=this.position=this._generatePosition(e,!1),this.originalPageX=e.pageX,this.originalPageY=e.pageY,i.cursorAt&&this._adjustOffsetFromHelper(i.cursorAt),this._setContainment(),this._trigger("start",e)===!1?(this._clear(),!1):(this._cacheHelperProportions(),t.ui.ddmanager&&!i.dropBehaviour&&t.ui.ddmanager.prepareOffsets(this,e),this._mouseDrag(e,!0),t.ui.ddmanager&&t.ui.ddmanager.dragStart(this,e),!0)},_refreshOffsets:function(t){this.offset={top:this.positionAbs.top-this.margins.top,left:this.positionAbs.left-this.margins.left,scroll:!1,parent:this._getParentOffset(),relative:this._getRelativeOffset()},this.offset.click={left:t.pageX-this.offset.left,top:t.pageY-this.offset.top}},_mouseDrag:function(e,i){if(this.hasFixedAncestor&&(this.offset.parent=this._getParentOffset()),this.position=this._generatePosition(e,!0),this.positionAbs=this._convertPositionTo("absolute"),!i){var s=this._uiHash();if(this._trigger("drag",e,s)===!1)return this._mouseUp(new t.Event("mouseup",e)),!1;this.position=s.position}return this.helper[0].style.left=this.position.left+"px",this.helper[0].style.top=this.position.top+"px",t.ui.ddmanager&&t.ui.ddmanager.drag(this,e),!1},_mouseStop:function(e){var i=this,s=!1;return t.ui.ddmanager&&!this.options.dropBehaviour&&(s=t.ui.ddmanager.drop(this,e)),this.dropped&&(s=this.dropped,this.dropped=!1),"invalid"===this.options.revert&&!s||"valid"===this.options.revert&&s||this.options.revert===!0||t.isFunction(this.options.revert)&&this.options.revert.call(this.element,s)?t(this.helper).animate(this.originalPosition,parseInt(this.options.revertDuration,10),function(){i._trigger("stop",e)!==!1&&i._clear()}):this._trigger("stop",e)!==!1&&this._clear(),!1},_mouseUp:function(e){return this._unblockFrames(),t.ui.ddmanager&&t.ui.ddmanager.dragStop(this,e),this.handleElement.is(e.target)&&this.element.trigger("focus"),t.ui.mouse.prototype._mouseUp.call(this,e)},cancel:function(){return this.helper.is(".ui-draggable-dragging")?this._mouseUp(new t.Event("mouseup",{target:this.element[0]})):this._clear(),this},_getHandle:function(e){return this.options.handle?!!t(e.target).closest(this.element.find(this.options.handle)).length:!0},_setHandleClassName:function(){this.handleElement=this.options.handle?this.element.find(this.options.handle):this.element,this._addClass(this.handleElement,"ui-draggable-handle")},_removeHandleClassName:function(){this._removeClass(this.handleElement,"ui-draggable-handle")},_createHelper:function(e){var i=this.options,s=t.isFunction(i.helper),n=s?t(i.helper.apply(this.element[0],[e])):"clone"===i.helper?this.element.clone().removeAttr("id"):this.element;return n.parents("body").length||n.appendTo("parent"===i.appendTo?this.element[0].parentNode:i.appendTo),s&&n[0]===this.element[0]&&this._setPositionRelative(),n[0]===this.element[0]||/(fixed|absolute)/.test(n.css("position"))||n.css("position","absolute"),n},_setPositionRelative:function(){/^(?:r|a|f)/.test(this.element.css("position"))||(this.element[0].style.position="relative")},_adjustOffsetFromHelper:function(e){"string"==typeof e&&(e=e.split(" ")),t.isArray(e)&&(e={left:+e[0],top:+e[1]||0}),"left"in e&&(this.offset.click.left=e.left+this.margins.left),"right"in e&&(this.offset.click.left=this.helperProportions.width-e.right+this.margins.left),"top"in e&&(this.offset.click.top=e.top+this.margins.top),"bottom"in e&&(this.offset.click.top=this.helperProportions.height-e.bottom+this.margins.top)},_isRootNode:function(t){return/(html|body)/i.test(t.tagName)||t===this.document[0]},_getParentOffset:function(){var e=this.offsetParent.offset(),i=this.document[0];return"absolute"===this.cssPosition&&this.scrollParent[0]!==i&&t.contains(this.scrollParent[0],this.offsetParent[0])&&(e.left+=this.scrollParent.scrollLeft(),e.top+=this.scrollParent.scrollTop()),this._isRootNode(this.offsetParent[0])&&(e={top:0,left:0}),{top:e.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:e.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if("relative"!==this.cssPosition)return{top:0,left:0};var t=this.element.position(),e=this._isRootNode(this.scrollParent[0]);return{top:t.top-(parseInt(this.helper.css("top"),10)||0)+(e?0:this.scrollParent.scrollTop()),left:t.left-(parseInt(this.helper.css("left"),10)||0)+(e?0:this.scrollParent.scrollLeft())}},_cacheMargins:function(){this.margins={left:parseInt(this.element.css("marginLeft"),10)||0,top:parseInt(this.element.css("marginTop"),10)||0,right:parseInt(this.element.css("marginRight"),10)||0,bottom:parseInt(this.element.css("marginBottom"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var e,i,s,n=this.options,o=this.document[0];return this.relativeContainer=null,n.containment?"window"===n.containment?(this.containment=[t(window).scrollLeft()-this.offset.relative.left-this.offset.parent.left,t(window).scrollTop()-this.offset.relative.top-this.offset.parent.top,t(window).scrollLeft()+t(window).width()-this.helperProportions.width-this.margins.left,t(window).scrollTop()+(t(window).height()||o.body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top],void 0):"document"===n.containment?(this.containment=[0,0,t(o).width()-this.helperProportions.width-this.margins.left,(t(o).height()||o.body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top],void 0):n.containment.constructor===Array?(this.containment=n.containment,void 0):("parent"===n.containment&&(n.containment=this.helper[0].parentNode),i=t(n.containment),s=i[0],s&&(e=/(scroll|auto)/.test(i.css("overflow")),this.containment=[(parseInt(i.css("borderLeftWidth"),10)||0)+(parseInt(i.css("paddingLeft"),10)||0),(parseInt(i.css("borderTopWidth"),10)||0)+(parseInt(i.css("paddingTop"),10)||0),(e?Math.max(s.scrollWidth,s.offsetWidth):s.offsetWidth)-(parseInt(i.css("borderRightWidth"),10)||0)-(parseInt(i.css("paddingRight"),10)||0)-this.helperProportions.width-this.margins.left-this.margins.right,(e?Math.max(s.scrollHeight,s.offsetHeight):s.offsetHeight)-(parseInt(i.css("borderBottomWidth"),10)||0)-(parseInt(i.css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top-this.margins.bottom],this.relativeContainer=i),void 0):(this.containment=null,void 0)},_convertPositionTo:function(t,e){e||(e=this.position);var i="absolute"===t?1:-1,s=this._isRootNode(this.scrollParent[0]);return{top:e.top+this.offset.relative.top*i+this.offset.parent.top*i-("fixed"===this.cssPosition?-this.offset.scroll.top:s?0:this.offset.scroll.top)*i,left:e.left+this.offset.relative.left*i+this.offset.parent.left*i-("fixed"===this.cssPosition?-this.offset.scroll.left:s?0:this.offset.scroll.left)*i}},_generatePosition:function(t,e){var i,s,n,o,a=this.options,r=this._isRootNode(this.scrollParent[0]),l=t.pageX,h=t.pageY;return r&&this.offset.scroll||(this.offset.scroll={top:this.scrollParent.scrollTop(),left:this.scrollParent.scrollLeft()}),e&&(this.containment&&(this.relativeContainer?(s=this.relativeContainer.offset(),i=[this.containment[0]+s.left,this.containment[1]+s.top,this.containment[2]+s.left,this.containment[3]+s.top]):i=this.containment,t.pageX-this.offset.click.lefti[2]&&(l=i[2]+this.offset.click.left),t.pageY-this.offset.click.top>i[3]&&(h=i[3]+this.offset.click.top)),a.grid&&(n=a.grid[1]?this.originalPageY+Math.round((h-this.originalPageY)/a.grid[1])*a.grid[1]:this.originalPageY,h=i?n-this.offset.click.top>=i[1]||n-this.offset.click.top>i[3]?n:n-this.offset.click.top>=i[1]?n-a.grid[1]:n+a.grid[1]:n,o=a.grid[0]?this.originalPageX+Math.round((l-this.originalPageX)/a.grid[0])*a.grid[0]:this.originalPageX,l=i?o-this.offset.click.left>=i[0]||o-this.offset.click.left>i[2]?o:o-this.offset.click.left>=i[0]?o-a.grid[0]:o+a.grid[0]:o),"y"===a.axis&&(l=this.originalPageX),"x"===a.axis&&(h=this.originalPageY)),{top:h-this.offset.click.top-this.offset.relative.top-this.offset.parent.top+("fixed"===this.cssPosition?-this.offset.scroll.top:r?0:this.offset.scroll.top),left:l-this.offset.click.left-this.offset.relative.left-this.offset.parent.left+("fixed"===this.cssPosition?-this.offset.scroll.left:r?0:this.offset.scroll.left)}},_clear:function(){this._removeClass(this.helper,"ui-draggable-dragging"),this.helper[0]===this.element[0]||this.cancelHelperRemoval||this.helper.remove(),this.helper=null,this.cancelHelperRemoval=!1,this.destroyOnClear&&this.destroy()},_trigger:function(e,i,s){return s=s||this._uiHash(),t.ui.plugin.call(this,e,[i,s,this],!0),/^(drag|start|stop)/.test(e)&&(this.positionAbs=this._convertPositionTo("absolute"),s.offset=this.positionAbs),t.Widget.prototype._trigger.call(this,e,i,s)},plugins:{},_uiHash:function(){return{helper:this.helper,position:this.position,originalPosition:this.originalPosition,offset:this.positionAbs}}}),t.ui.plugin.add("draggable","connectToSortable",{start:function(e,i,s){var n=t.extend({},i,{item:s.element});s.sortables=[],t(s.options.connectToSortable).each(function(){var i=t(this).sortable("instance");i&&!i.options.disabled&&(s.sortables.push(i),i.refreshPositions(),i._trigger("activate",e,n))})},stop:function(e,i,s){var n=t.extend({},i,{item:s.element});s.cancelHelperRemoval=!1,t.each(s.sortables,function(){var t=this;t.isOver?(t.isOver=0,s.cancelHelperRemoval=!0,t.cancelHelperRemoval=!1,t._storedCSS={position:t.placeholder.css("position"),top:t.placeholder.css("top"),left:t.placeholder.css("left")},t._mouseStop(e),t.options.helper=t.options._helper):(t.cancelHelperRemoval=!0,t._trigger("deactivate",e,n))})},drag:function(e,i,s){t.each(s.sortables,function(){var n=!1,o=this;o.positionAbs=s.positionAbs,o.helperProportions=s.helperProportions,o.offset.click=s.offset.click,o._intersectsWith(o.containerCache)&&(n=!0,t.each(s.sortables,function(){return this.positionAbs=s.positionAbs,this.helperProportions=s.helperProportions,this.offset.click=s.offset.click,this!==o&&this._intersectsWith(this.containerCache)&&t.contains(o.element[0],this.element[0])&&(n=!1),n})),n?(o.isOver||(o.isOver=1,s._parent=i.helper.parent(),o.currentItem=i.helper.appendTo(o.element).data("ui-sortable-item",!0),o.options._helper=o.options.helper,o.options.helper=function(){return i.helper[0]},e.target=o.currentItem[0],o._mouseCapture(e,!0),o._mouseStart(e,!0,!0),o.offset.click.top=s.offset.click.top,o.offset.click.left=s.offset.click.left,o.offset.parent.left-=s.offset.parent.left-o.offset.parent.left,o.offset.parent.top-=s.offset.parent.top-o.offset.parent.top,s._trigger("toSortable",e),s.dropped=o.element,t.each(s.sortables,function(){this.refreshPositions()}),s.currentItem=s.element,o.fromOutside=s),o.currentItem&&(o._mouseDrag(e),i.position=o.position)):o.isOver&&(o.isOver=0,o.cancelHelperRemoval=!0,o.options._revert=o.options.revert,o.options.revert=!1,o._trigger("out",e,o._uiHash(o)),o._mouseStop(e,!0),o.options.revert=o.options._revert,o.options.helper=o.options._helper,o.placeholder&&o.placeholder.remove(),i.helper.appendTo(s._parent),s._refreshOffsets(e),i.position=s._generatePosition(e,!0),s._trigger("fromSortable",e),s.dropped=!1,t.each(s.sortables,function(){this.refreshPositions()}))})}}),t.ui.plugin.add("draggable","cursor",{start:function(e,i,s){var n=t("body"),o=s.options;n.css("cursor")&&(o._cursor=n.css("cursor")),n.css("cursor",o.cursor)},stop:function(e,i,s){var n=s.options;n._cursor&&t("body").css("cursor",n._cursor)}}),t.ui.plugin.add("draggable","opacity",{start:function(e,i,s){var n=t(i.helper),o=s.options;n.css("opacity")&&(o._opacity=n.css("opacity")),n.css("opacity",o.opacity)},stop:function(e,i,s){var n=s.options;n._opacity&&t(i.helper).css("opacity",n._opacity)}}),t.ui.plugin.add("draggable","scroll",{start:function(t,e,i){i.scrollParentNotHidden||(i.scrollParentNotHidden=i.helper.scrollParent(!1)),i.scrollParentNotHidden[0]!==i.document[0]&&"HTML"!==i.scrollParentNotHidden[0].tagName&&(i.overflowOffset=i.scrollParentNotHidden.offset())},drag:function(e,i,s){var n=s.options,o=!1,a=s.scrollParentNotHidden[0],r=s.document[0];a!==r&&"HTML"!==a.tagName?(n.axis&&"x"===n.axis||(s.overflowOffset.top+a.offsetHeight-e.pageY=0;d--)l=s.snapElements[d].left-s.margins.left,h=l+s.snapElements[d].width,c=s.snapElements[d].top-s.margins.top,u=c+s.snapElements[d].height,l-g>_||m>h+g||c-g>b||v>u+g||!t.contains(s.snapElements[d].item.ownerDocument,s.snapElements[d].item)?(s.snapElements[d].snapping&&s.options.snap.release&&s.options.snap.release.call(s.element,e,t.extend(s._uiHash(),{snapItem:s.snapElements[d].item})),s.snapElements[d].snapping=!1):("inner"!==f.snapMode&&(n=g>=Math.abs(c-b),o=g>=Math.abs(u-v),a=g>=Math.abs(l-_),r=g>=Math.abs(h-m),n&&(i.position.top=s._convertPositionTo("relative",{top:c-s.helperProportions.height,left:0}).top),o&&(i.position.top=s._convertPositionTo("relative",{top:u,left:0}).top),a&&(i.position.left=s._convertPositionTo("relative",{top:0,left:l-s.helperProportions.width}).left),r&&(i.position.left=s._convertPositionTo("relative",{top:0,left:h}).left)),p=n||o||a||r,"outer"!==f.snapMode&&(n=g>=Math.abs(c-v),o=g>=Math.abs(u-b),a=g>=Math.abs(l-m),r=g>=Math.abs(h-_),n&&(i.position.top=s._convertPositionTo("relative",{top:c,left:0}).top),o&&(i.position.top=s._convertPositionTo("relative",{top:u-s.helperProportions.height,left:0}).top),a&&(i.position.left=s._convertPositionTo("relative",{top:0,left:l}).left),r&&(i.position.left=s._convertPositionTo("relative",{top:0,left:h-s.helperProportions.width}).left)),!s.snapElements[d].snapping&&(n||o||a||r||p)&&s.options.snap.snap&&s.options.snap.snap.call(s.element,e,t.extend(s._uiHash(),{snapItem:s.snapElements[d].item})),s.snapElements[d].snapping=n||o||a||r||p)}}),t.ui.plugin.add("draggable","stack",{start:function(e,i,s){var n,o=s.options,a=t.makeArray(t(o.stack)).sort(function(e,i){return(parseInt(t(e).css("zIndex"),10)||0)-(parseInt(t(i).css("zIndex"),10)||0)});a.length&&(n=parseInt(t(a[0]).css("zIndex"),10)||0,t(a).each(function(e){t(this).css("zIndex",n+e)}),this.css("zIndex",n+a.length))}}),t.ui.plugin.add("draggable","zIndex",{start:function(e,i,s){var n=t(i.helper),o=s.options;n.css("zIndex")&&(o._zIndex=n.css("zIndex")),n.css("zIndex",o.zIndex)},stop:function(e,i,s){var n=s.options;n._zIndex&&t(i.helper).css("zIndex",n._zIndex)}}),t.ui.draggable,t.widget("ui.sortable",t.ui.mouse,{version:"1.12.1",widgetEventPrefix:"sort",ready:!1,options:{appendTo:"parent",axis:!1,connectWith:!1,containment:!1,cursor:"auto",cursorAt:!1,dropOnEmpty:!0,forcePlaceholderSize:!1,forceHelperSize:!1,grid:!1,handle:!1,helper:"original",items:"> *",opacity:!1,placeholder:!1,revert:!1,scroll:!0,scrollSensitivity:20,scrollSpeed:20,scope:"default",tolerance:"intersect",zIndex:1e3,activate:null,beforeStop:null,change:null,deactivate:null,out:null,over:null,receive:null,remove:null,sort:null,start:null,stop:null,update:null},_isOverAxis:function(t,e,i){return t>=e&&e+i>t},_isFloating:function(t){return/left|right/.test(t.css("float"))||/inline|table-cell/.test(t.css("display"))},_create:function(){this.containerCache={},this._addClass("ui-sortable"),this.refresh(),this.offset=this.element.offset(),this._mouseInit(),this._setHandleClassName(),this.ready=!0},_setOption:function(t,e){this._super(t,e),"handle"===t&&this._setHandleClassName()},_setHandleClassName:function(){var e=this;this._removeClass(this.element.find(".ui-sortable-handle"),"ui-sortable-handle"),t.each(this.items,function(){e._addClass(this.instance.options.handle?this.item.find(this.instance.options.handle):this.item,"ui-sortable-handle")})},_destroy:function(){this._mouseDestroy();for(var t=this.items.length-1;t>=0;t--)this.items[t].item.removeData(this.widgetName+"-item");return this},_mouseCapture:function(e,i){var s=null,n=!1,o=this;return this.reverting?!1:this.options.disabled||"static"===this.options.type?!1:(this._refreshItems(e),t(e.target).parents().each(function(){return t.data(this,o.widgetName+"-item")===o?(s=t(this),!1):void 0 -}),t.data(e.target,o.widgetName+"-item")===o&&(s=t(e.target)),s?!this.options.handle||i||(t(this.options.handle,s).find("*").addBack().each(function(){this===e.target&&(n=!0)}),n)?(this.currentItem=s,this._removeCurrentsFromItems(),!0):!1:!1)},_mouseStart:function(e,i,s){var n,o,a=this.options;if(this.currentContainer=this,this.refreshPositions(),this.helper=this._createHelper(e),this._cacheHelperProportions(),this._cacheMargins(),this.scrollParent=this.helper.scrollParent(),this.offset=this.currentItem.offset(),this.offset={top:this.offset.top-this.margins.top,left:this.offset.left-this.margins.left},t.extend(this.offset,{click:{left:e.pageX-this.offset.left,top:e.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()}),this.helper.css("position","absolute"),this.cssPosition=this.helper.css("position"),this.originalPosition=this._generatePosition(e),this.originalPageX=e.pageX,this.originalPageY=e.pageY,a.cursorAt&&this._adjustOffsetFromHelper(a.cursorAt),this.domPosition={prev:this.currentItem.prev()[0],parent:this.currentItem.parent()[0]},this.helper[0]!==this.currentItem[0]&&this.currentItem.hide(),this._createPlaceholder(),a.containment&&this._setContainment(),a.cursor&&"auto"!==a.cursor&&(o=this.document.find("body"),this.storedCursor=o.css("cursor"),o.css("cursor",a.cursor),this.storedStylesheet=t("").appendTo(o)),a.opacity&&(this.helper.css("opacity")&&(this._storedOpacity=this.helper.css("opacity")),this.helper.css("opacity",a.opacity)),a.zIndex&&(this.helper.css("zIndex")&&(this._storedZIndex=this.helper.css("zIndex")),this.helper.css("zIndex",a.zIndex)),this.scrollParent[0]!==this.document[0]&&"HTML"!==this.scrollParent[0].tagName&&(this.overflowOffset=this.scrollParent.offset()),this._trigger("start",e,this._uiHash()),this._preserveHelperProportions||this._cacheHelperProportions(),!s)for(n=this.containers.length-1;n>=0;n--)this.containers[n]._trigger("activate",e,this._uiHash(this));return t.ui.ddmanager&&(t.ui.ddmanager.current=this),t.ui.ddmanager&&!a.dropBehaviour&&t.ui.ddmanager.prepareOffsets(this,e),this.dragging=!0,this._addClass(this.helper,"ui-sortable-helper"),this._mouseDrag(e),!0},_mouseDrag:function(e){var i,s,n,o,a=this.options,r=!1;for(this.position=this._generatePosition(e),this.positionAbs=this._convertPositionTo("absolute"),this.lastPositionAbs||(this.lastPositionAbs=this.positionAbs),this.options.scroll&&(this.scrollParent[0]!==this.document[0]&&"HTML"!==this.scrollParent[0].tagName?(this.overflowOffset.top+this.scrollParent[0].offsetHeight-e.pageY=0;i--)if(s=this.items[i],n=s.item[0],o=this._intersectsWithPointer(s),o&&s.instance===this.currentContainer&&n!==this.currentItem[0]&&this.placeholder[1===o?"next":"prev"]()[0]!==n&&!t.contains(this.placeholder[0],n)&&("semi-dynamic"===this.options.type?!t.contains(this.element[0],n):!0)){if(this.direction=1===o?"down":"up","pointer"!==this.options.tolerance&&!this._intersectsWithSides(s))break;this._rearrange(e,s),this._trigger("change",e,this._uiHash());break}return this._contactContainers(e),t.ui.ddmanager&&t.ui.ddmanager.drag(this,e),this._trigger("sort",e,this._uiHash()),this.lastPositionAbs=this.positionAbs,!1},_mouseStop:function(e,i){if(e){if(t.ui.ddmanager&&!this.options.dropBehaviour&&t.ui.ddmanager.drop(this,e),this.options.revert){var s=this,n=this.placeholder.offset(),o=this.options.axis,a={};o&&"x"!==o||(a.left=n.left-this.offset.parent.left-this.margins.left+(this.offsetParent[0]===this.document[0].body?0:this.offsetParent[0].scrollLeft)),o&&"y"!==o||(a.top=n.top-this.offset.parent.top-this.margins.top+(this.offsetParent[0]===this.document[0].body?0:this.offsetParent[0].scrollTop)),this.reverting=!0,t(this.helper).animate(a,parseInt(this.options.revert,10)||500,function(){s._clear(e)})}else this._clear(e,i);return!1}},cancel:function(){if(this.dragging){this._mouseUp(new t.Event("mouseup",{target:null})),"original"===this.options.helper?(this.currentItem.css(this._storedCSS),this._removeClass(this.currentItem,"ui-sortable-helper")):this.currentItem.show();for(var e=this.containers.length-1;e>=0;e--)this.containers[e]._trigger("deactivate",null,this._uiHash(this)),this.containers[e].containerCache.over&&(this.containers[e]._trigger("out",null,this._uiHash(this)),this.containers[e].containerCache.over=0)}return this.placeholder&&(this.placeholder[0].parentNode&&this.placeholder[0].parentNode.removeChild(this.placeholder[0]),"original"!==this.options.helper&&this.helper&&this.helper[0].parentNode&&this.helper.remove(),t.extend(this,{helper:null,dragging:!1,reverting:!1,_noFinalSort:null}),this.domPosition.prev?t(this.domPosition.prev).after(this.currentItem):t(this.domPosition.parent).prepend(this.currentItem)),this},serialize:function(e){var i=this._getItemsAsjQuery(e&&e.connected),s=[];return e=e||{},t(i).each(function(){var i=(t(e.item||this).attr(e.attribute||"id")||"").match(e.expression||/(.+)[\-=_](.+)/);i&&s.push((e.key||i[1]+"[]")+"="+(e.key&&e.expression?i[1]:i[2]))}),!s.length&&e.key&&s.push(e.key+"="),s.join("&")},toArray:function(e){var i=this._getItemsAsjQuery(e&&e.connected),s=[];return e=e||{},i.each(function(){s.push(t(e.item||this).attr(e.attribute||"id")||"")}),s},_intersectsWith:function(t){var e=this.positionAbs.left,i=e+this.helperProportions.width,s=this.positionAbs.top,n=s+this.helperProportions.height,o=t.left,a=o+t.width,r=t.top,l=r+t.height,h=this.offset.click.top,c=this.offset.click.left,u="x"===this.options.axis||s+h>r&&l>s+h,d="y"===this.options.axis||e+c>o&&a>e+c,p=u&&d;return"pointer"===this.options.tolerance||this.options.forcePointerForContainers||"pointer"!==this.options.tolerance&&this.helperProportions[this.floating?"width":"height"]>t[this.floating?"width":"height"]?p:e+this.helperProportions.width/2>o&&a>i-this.helperProportions.width/2&&s+this.helperProportions.height/2>r&&l>n-this.helperProportions.height/2},_intersectsWithPointer:function(t){var e,i,s="x"===this.options.axis||this._isOverAxis(this.positionAbs.top+this.offset.click.top,t.top,t.height),n="y"===this.options.axis||this._isOverAxis(this.positionAbs.left+this.offset.click.left,t.left,t.width),o=s&&n;return o?(e=this._getDragVerticalDirection(),i=this._getDragHorizontalDirection(),this.floating?"right"===i||"down"===e?2:1:e&&("down"===e?2:1)):!1},_intersectsWithSides:function(t){var e=this._isOverAxis(this.positionAbs.top+this.offset.click.top,t.top+t.height/2,t.height),i=this._isOverAxis(this.positionAbs.left+this.offset.click.left,t.left+t.width/2,t.width),s=this._getDragVerticalDirection(),n=this._getDragHorizontalDirection();return this.floating&&n?"right"===n&&i||"left"===n&&!i:s&&("down"===s&&e||"up"===s&&!e)},_getDragVerticalDirection:function(){var t=this.positionAbs.top-this.lastPositionAbs.top;return 0!==t&&(t>0?"down":"up")},_getDragHorizontalDirection:function(){var t=this.positionAbs.left-this.lastPositionAbs.left;return 0!==t&&(t>0?"right":"left")},refresh:function(t){return this._refreshItems(t),this._setHandleClassName(),this.refreshPositions(),this},_connectWith:function(){var t=this.options;return t.connectWith.constructor===String?[t.connectWith]:t.connectWith},_getItemsAsjQuery:function(e){function i(){r.push(this)}var s,n,o,a,r=[],l=[],h=this._connectWith();if(h&&e)for(s=h.length-1;s>=0;s--)for(o=t(h[s],this.document[0]),n=o.length-1;n>=0;n--)a=t.data(o[n],this.widgetFullName),a&&a!==this&&!a.options.disabled&&l.push([t.isFunction(a.options.items)?a.options.items.call(a.element):t(a.options.items,a.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),a]);for(l.push([t.isFunction(this.options.items)?this.options.items.call(this.element,null,{options:this.options,item:this.currentItem}):t(this.options.items,this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),this]),s=l.length-1;s>=0;s--)l[s][0].each(i);return t(r)},_removeCurrentsFromItems:function(){var e=this.currentItem.find(":data("+this.widgetName+"-item)");this.items=t.grep(this.items,function(t){for(var i=0;e.length>i;i++)if(e[i]===t.item[0])return!1;return!0})},_refreshItems:function(e){this.items=[],this.containers=[this];var i,s,n,o,a,r,l,h,c=this.items,u=[[t.isFunction(this.options.items)?this.options.items.call(this.element[0],e,{item:this.currentItem}):t(this.options.items,this.element),this]],d=this._connectWith();if(d&&this.ready)for(i=d.length-1;i>=0;i--)for(n=t(d[i],this.document[0]),s=n.length-1;s>=0;s--)o=t.data(n[s],this.widgetFullName),o&&o!==this&&!o.options.disabled&&(u.push([t.isFunction(o.options.items)?o.options.items.call(o.element[0],e,{item:this.currentItem}):t(o.options.items,o.element),o]),this.containers.push(o));for(i=u.length-1;i>=0;i--)for(a=u[i][1],r=u[i][0],s=0,h=r.length;h>s;s++)l=t(r[s]),l.data(this.widgetName+"-item",a),c.push({item:l,instance:a,width:0,height:0,left:0,top:0})},refreshPositions:function(e){this.floating=this.items.length?"x"===this.options.axis||this._isFloating(this.items[0].item):!1,this.offsetParent&&this.helper&&(this.offset.parent=this._getParentOffset());var i,s,n,o;for(i=this.items.length-1;i>=0;i--)s=this.items[i],s.instance!==this.currentContainer&&this.currentContainer&&s.item[0]!==this.currentItem[0]||(n=this.options.toleranceElement?t(this.options.toleranceElement,s.item):s.item,e||(s.width=n.outerWidth(),s.height=n.outerHeight()),o=n.offset(),s.left=o.left,s.top=o.top);if(this.options.custom&&this.options.custom.refreshContainers)this.options.custom.refreshContainers.call(this);else for(i=this.containers.length-1;i>=0;i--)o=this.containers[i].element.offset(),this.containers[i].containerCache.left=o.left,this.containers[i].containerCache.top=o.top,this.containers[i].containerCache.width=this.containers[i].element.outerWidth(),this.containers[i].containerCache.height=this.containers[i].element.outerHeight();return this},_createPlaceholder:function(e){e=e||this;var i,s=e.options;s.placeholder&&s.placeholder.constructor!==String||(i=s.placeholder,s.placeholder={element:function(){var s=e.currentItem[0].nodeName.toLowerCase(),n=t("<"+s+">",e.document[0]);return e._addClass(n,"ui-sortable-placeholder",i||e.currentItem[0].className)._removeClass(n,"ui-sortable-helper"),"tbody"===s?e._createTrPlaceholder(e.currentItem.find("tr").eq(0),t("",e.document[0]).appendTo(n)):"tr"===s?e._createTrPlaceholder(e.currentItem,n):"img"===s&&n.attr("src",e.currentItem.attr("src")),i||n.css("visibility","hidden"),n},update:function(t,n){(!i||s.forcePlaceholderSize)&&(n.height()||n.height(e.currentItem.innerHeight()-parseInt(e.currentItem.css("paddingTop")||0,10)-parseInt(e.currentItem.css("paddingBottom")||0,10)),n.width()||n.width(e.currentItem.innerWidth()-parseInt(e.currentItem.css("paddingLeft")||0,10)-parseInt(e.currentItem.css("paddingRight")||0,10)))}}),e.placeholder=t(s.placeholder.element.call(e.element,e.currentItem)),e.currentItem.after(e.placeholder),s.placeholder.update(e,e.placeholder)},_createTrPlaceholder:function(e,i){var s=this;e.children().each(function(){t(" ",s.document[0]).attr("colspan",t(this).attr("colspan")||1).appendTo(i)})},_contactContainers:function(e){var i,s,n,o,a,r,l,h,c,u,d=null,p=null;for(i=this.containers.length-1;i>=0;i--)if(!t.contains(this.currentItem[0],this.containers[i].element[0]))if(this._intersectsWith(this.containers[i].containerCache)){if(d&&t.contains(this.containers[i].element[0],d.element[0]))continue;d=this.containers[i],p=i}else this.containers[i].containerCache.over&&(this.containers[i]._trigger("out",e,this._uiHash(this)),this.containers[i].containerCache.over=0);if(d)if(1===this.containers.length)this.containers[p].containerCache.over||(this.containers[p]._trigger("over",e,this._uiHash(this)),this.containers[p].containerCache.over=1);else{for(n=1e4,o=null,c=d.floating||this._isFloating(this.currentItem),a=c?"left":"top",r=c?"width":"height",u=c?"pageX":"pageY",s=this.items.length-1;s>=0;s--)t.contains(this.containers[p].element[0],this.items[s].item[0])&&this.items[s].item[0]!==this.currentItem[0]&&(l=this.items[s].item.offset()[a],h=!1,e[u]-l>this.items[s][r]/2&&(h=!0),n>Math.abs(e[u]-l)&&(n=Math.abs(e[u]-l),o=this.items[s],this.direction=h?"up":"down"));if(!o&&!this.options.dropOnEmpty)return;if(this.currentContainer===this.containers[p])return this.currentContainer.containerCache.over||(this.containers[p]._trigger("over",e,this._uiHash()),this.currentContainer.containerCache.over=1),void 0;o?this._rearrange(e,o,null,!0):this._rearrange(e,null,this.containers[p].element,!0),this._trigger("change",e,this._uiHash()),this.containers[p]._trigger("change",e,this._uiHash(this)),this.currentContainer=this.containers[p],this.options.placeholder.update(this.currentContainer,this.placeholder),this.containers[p]._trigger("over",e,this._uiHash(this)),this.containers[p].containerCache.over=1}},_createHelper:function(e){var i=this.options,s=t.isFunction(i.helper)?t(i.helper.apply(this.element[0],[e,this.currentItem])):"clone"===i.helper?this.currentItem.clone():this.currentItem;return s.parents("body").length||t("parent"!==i.appendTo?i.appendTo:this.currentItem[0].parentNode)[0].appendChild(s[0]),s[0]===this.currentItem[0]&&(this._storedCSS={width:this.currentItem[0].style.width,height:this.currentItem[0].style.height,position:this.currentItem.css("position"),top:this.currentItem.css("top"),left:this.currentItem.css("left")}),(!s[0].style.width||i.forceHelperSize)&&s.width(this.currentItem.width()),(!s[0].style.height||i.forceHelperSize)&&s.height(this.currentItem.height()),s},_adjustOffsetFromHelper:function(e){"string"==typeof e&&(e=e.split(" ")),t.isArray(e)&&(e={left:+e[0],top:+e[1]||0}),"left"in e&&(this.offset.click.left=e.left+this.margins.left),"right"in e&&(this.offset.click.left=this.helperProportions.width-e.right+this.margins.left),"top"in e&&(this.offset.click.top=e.top+this.margins.top),"bottom"in e&&(this.offset.click.top=this.helperProportions.height-e.bottom+this.margins.top)},_getParentOffset:function(){this.offsetParent=this.helper.offsetParent();var e=this.offsetParent.offset();return"absolute"===this.cssPosition&&this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])&&(e.left+=this.scrollParent.scrollLeft(),e.top+=this.scrollParent.scrollTop()),(this.offsetParent[0]===this.document[0].body||this.offsetParent[0].tagName&&"html"===this.offsetParent[0].tagName.toLowerCase()&&t.ui.ie)&&(e={top:0,left:0}),{top:e.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:e.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if("relative"===this.cssPosition){var t=this.currentItem.position();return{top:t.top-(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:t.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.currentItem.css("marginLeft"),10)||0,top:parseInt(this.currentItem.css("marginTop"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var e,i,s,n=this.options;"parent"===n.containment&&(n.containment=this.helper[0].parentNode),("document"===n.containment||"window"===n.containment)&&(this.containment=[0-this.offset.relative.left-this.offset.parent.left,0-this.offset.relative.top-this.offset.parent.top,"document"===n.containment?this.document.width():this.window.width()-this.helperProportions.width-this.margins.left,("document"===n.containment?this.document.height()||document.body.parentNode.scrollHeight:this.window.height()||this.document[0].body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top]),/^(document|window|parent)$/.test(n.containment)||(e=t(n.containment)[0],i=t(n.containment).offset(),s="hidden"!==t(e).css("overflow"),this.containment=[i.left+(parseInt(t(e).css("borderLeftWidth"),10)||0)+(parseInt(t(e).css("paddingLeft"),10)||0)-this.margins.left,i.top+(parseInt(t(e).css("borderTopWidth"),10)||0)+(parseInt(t(e).css("paddingTop"),10)||0)-this.margins.top,i.left+(s?Math.max(e.scrollWidth,e.offsetWidth):e.offsetWidth)-(parseInt(t(e).css("borderLeftWidth"),10)||0)-(parseInt(t(e).css("paddingRight"),10)||0)-this.helperProportions.width-this.margins.left,i.top+(s?Math.max(e.scrollHeight,e.offsetHeight):e.offsetHeight)-(parseInt(t(e).css("borderTopWidth"),10)||0)-(parseInt(t(e).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top])},_convertPositionTo:function(e,i){i||(i=this.position);var s="absolute"===e?1:-1,n="absolute"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])?this.scrollParent:this.offsetParent,o=/(html|body)/i.test(n[0].tagName);return{top:i.top+this.offset.relative.top*s+this.offset.parent.top*s-("fixed"===this.cssPosition?-this.scrollParent.scrollTop():o?0:n.scrollTop())*s,left:i.left+this.offset.relative.left*s+this.offset.parent.left*s-("fixed"===this.cssPosition?-this.scrollParent.scrollLeft():o?0:n.scrollLeft())*s}},_generatePosition:function(e){var i,s,n=this.options,o=e.pageX,a=e.pageY,r="absolute"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])?this.scrollParent:this.offsetParent,l=/(html|body)/i.test(r[0].tagName);return"relative"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&this.scrollParent[0]!==this.offsetParent[0]||(this.offset.relative=this._getRelativeOffset()),this.originalPosition&&(this.containment&&(e.pageX-this.offset.click.leftthis.containment[2]&&(o=this.containment[2]+this.offset.click.left),e.pageY-this.offset.click.top>this.containment[3]&&(a=this.containment[3]+this.offset.click.top)),n.grid&&(i=this.originalPageY+Math.round((a-this.originalPageY)/n.grid[1])*n.grid[1],a=this.containment?i-this.offset.click.top>=this.containment[1]&&i-this.offset.click.top<=this.containment[3]?i:i-this.offset.click.top>=this.containment[1]?i-n.grid[1]:i+n.grid[1]:i,s=this.originalPageX+Math.round((o-this.originalPageX)/n.grid[0])*n.grid[0],o=this.containment?s-this.offset.click.left>=this.containment[0]&&s-this.offset.click.left<=this.containment[2]?s:s-this.offset.click.left>=this.containment[0]?s-n.grid[0]:s+n.grid[0]:s)),{top:a-this.offset.click.top-this.offset.relative.top-this.offset.parent.top+("fixed"===this.cssPosition?-this.scrollParent.scrollTop():l?0:r.scrollTop()),left:o-this.offset.click.left-this.offset.relative.left-this.offset.parent.left+("fixed"===this.cssPosition?-this.scrollParent.scrollLeft():l?0:r.scrollLeft())}},_rearrange:function(t,e,i,s){i?i[0].appendChild(this.placeholder[0]):e.item[0].parentNode.insertBefore(this.placeholder[0],"down"===this.direction?e.item[0]:e.item[0].nextSibling),this.counter=this.counter?++this.counter:1;var n=this.counter;this._delay(function(){n===this.counter&&this.refreshPositions(!s)})},_clear:function(t,e){function i(t,e,i){return function(s){i._trigger(t,s,e._uiHash(e))}}this.reverting=!1;var s,n=[];if(!this._noFinalSort&&this.currentItem.parent().length&&this.placeholder.before(this.currentItem),this._noFinalSort=null,this.helper[0]===this.currentItem[0]){for(s in this._storedCSS)("auto"===this._storedCSS[s]||"static"===this._storedCSS[s])&&(this._storedCSS[s]="");this.currentItem.css(this._storedCSS),this._removeClass(this.currentItem,"ui-sortable-helper")}else this.currentItem.show();for(this.fromOutside&&!e&&n.push(function(t){this._trigger("receive",t,this._uiHash(this.fromOutside))}),!this.fromOutside&&this.domPosition.prev===this.currentItem.prev().not(".ui-sortable-helper")[0]&&this.domPosition.parent===this.currentItem.parent()[0]||e||n.push(function(t){this._trigger("update",t,this._uiHash())}),this!==this.currentContainer&&(e||(n.push(function(t){this._trigger("remove",t,this._uiHash())}),n.push(function(t){return function(e){t._trigger("receive",e,this._uiHash(this))}}.call(this,this.currentContainer)),n.push(function(t){return function(e){t._trigger("update",e,this._uiHash(this))}}.call(this,this.currentContainer)))),s=this.containers.length-1;s>=0;s--)e||n.push(i("deactivate",this,this.containers[s])),this.containers[s].containerCache.over&&(n.push(i("out",this,this.containers[s])),this.containers[s].containerCache.over=0);if(this.storedCursor&&(this.document.find("body").css("cursor",this.storedCursor),this.storedStylesheet.remove()),this._storedOpacity&&this.helper.css("opacity",this._storedOpacity),this._storedZIndex&&this.helper.css("zIndex","auto"===this._storedZIndex?"":this._storedZIndex),this.dragging=!1,e||this._trigger("beforeStop",t,this._uiHash()),this.placeholder[0].parentNode.removeChild(this.placeholder[0]),this.cancelHelperRemoval||(this.helper[0]!==this.currentItem[0]&&this.helper.remove(),this.helper=null),!e){for(s=0;n.length>s;s++)n[s].call(this,t);this._trigger("stop",t,this._uiHash())}return this.fromOutside=!1,!this.cancelHelperRemoval},_trigger:function(){t.Widget.prototype._trigger.apply(this,arguments)===!1&&this.cancel()},_uiHash:function(e){var i=e||this;return{helper:i.helper,placeholder:i.placeholder||t([]),position:i.position,originalPosition:i.originalPosition,offset:i.positionAbs,item:i.currentItem,sender:e?e.element:null}}})}); \ No newline at end of file +}),t.data(e.target,o.widgetName+"-item")===o&&(s=t(e.target)),s?!this.options.handle||i||(t(this.options.handle,s).find("*").addBack().each(function(){this===e.target&&(n=!0)}),n)?(this.currentItem=s,this._removeCurrentsFromItems(),!0):!1:!1)},_mouseStart:function(e,i,s){var n,o,a=this.options;if(this.currentContainer=this,this.refreshPositions(),this.helper=this._createHelper(e),this._cacheHelperProportions(),this._cacheMargins(),this.scrollParent=this.helper.scrollParent(),this.offset=this.currentItem.offset(),this.offset={top:this.offset.top-this.margins.top,left:this.offset.left-this.margins.left},t.extend(this.offset,{click:{left:e.pageX-this.offset.left,top:e.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()}),this.helper.css("position","absolute"),this.cssPosition=this.helper.css("position"),this.originalPosition=this._generatePosition(e),this.originalPageX=e.pageX,this.originalPageY=e.pageY,a.cursorAt&&this._adjustOffsetFromHelper(a.cursorAt),this.domPosition={prev:this.currentItem.prev()[0],parent:this.currentItem.parent()[0]},this.helper[0]!==this.currentItem[0]&&this.currentItem.hide(),this._createPlaceholder(),a.containment&&this._setContainment(),a.cursor&&"auto"!==a.cursor&&(o=this.document.find("body"),this.storedCursor=o.css("cursor"),o.css("cursor",a.cursor),this.storedStylesheet=t("").appendTo(o)),a.opacity&&(this.helper.css("opacity")&&(this._storedOpacity=this.helper.css("opacity")),this.helper.css("opacity",a.opacity)),a.zIndex&&(this.helper.css("zIndex")&&(this._storedZIndex=this.helper.css("zIndex")),this.helper.css("zIndex",a.zIndex)),this.scrollParent[0]!==this.document[0]&&"HTML"!==this.scrollParent[0].tagName&&(this.overflowOffset=this.scrollParent.offset()),this._trigger("start",e,this._uiHash()),this._preserveHelperProportions||this._cacheHelperProportions(),!s)for(n=this.containers.length-1;n>=0;n--)this.containers[n]._trigger("activate",e,this._uiHash(this));return t.ui.ddmanager&&(t.ui.ddmanager.current=this),t.ui.ddmanager&&!a.dropBehaviour&&t.ui.ddmanager.prepareOffsets(this,e),this.dragging=!0,this._addClass(this.helper,"ui-sortable-helper"),this._mouseDrag(e),!0},_mouseDrag:function(e){var i,s,n,o,a=this.options,r=!1;for(this.position=this._generatePosition(e),this.positionAbs=this._convertPositionTo("absolute"),this.lastPositionAbs||(this.lastPositionAbs=this.positionAbs),this.options.scroll&&(this.scrollParent[0]!==this.document[0]&&"HTML"!==this.scrollParent[0].tagName?(this.overflowOffset.top+this.scrollParent[0].offsetHeight-e.pageY=0;i--)if(s=this.items[i],n=s.item[0],o=this._intersectsWithPointer(s),o&&s.instance===this.currentContainer&&n!==this.currentItem[0]&&this.placeholder[1===o?"next":"prev"]()[0]!==n&&!t.contains(this.placeholder[0],n)&&("semi-dynamic"===this.options.type?!t.contains(this.element[0],n):!0)){if(this.direction=1===o?"down":"up","pointer"!==this.options.tolerance&&!this._intersectsWithSides(s))break;this._rearrange(e,s),this._trigger("change",e,this._uiHash());break}return this._contactContainers(e),t.ui.ddmanager&&t.ui.ddmanager.drag(this,e),this._trigger("sort",e,this._uiHash()),this.lastPositionAbs=this.positionAbs,!1},_mouseStop:function(e,i){if(e){if(t.ui.ddmanager&&!this.options.dropBehaviour&&t.ui.ddmanager.drop(this,e),this.options.revert){var s=this,n=this.placeholder.offset(),o=this.options.axis,a={};o&&"x"!==o||(a.left=n.left-this.offset.parent.left-this.margins.left+(this.offsetParent[0]===this.document[0].body?0:this.offsetParent[0].scrollLeft)),o&&"y"!==o||(a.top=n.top-this.offset.parent.top-this.margins.top+(this.offsetParent[0]===this.document[0].body?0:this.offsetParent[0].scrollTop)),this.reverting=!0,t(this.helper).animate(a,parseInt(this.options.revert,10)||500,function(){s._clear(e)})}else this._clear(e,i);return!1}},cancel:function(){if(this.dragging){this._mouseUp(new t.Event("mouseup",{target:null})),"original"===this.options.helper?(this.currentItem.css(this._storedCSS),this._removeClass(this.currentItem,"ui-sortable-helper")):this.currentItem.show();for(var e=this.containers.length-1;e>=0;e--)this.containers[e]._trigger("deactivate",null,this._uiHash(this)),this.containers[e].containerCache.over&&(this.containers[e]._trigger("out",null,this._uiHash(this)),this.containers[e].containerCache.over=0)}return this.placeholder&&(this.placeholder[0].parentNode&&this.placeholder[0].parentNode.removeChild(this.placeholder[0]),"original"!==this.options.helper&&this.helper&&this.helper[0].parentNode&&this.helper.remove(),t.extend(this,{helper:null,dragging:!1,reverting:!1,_noFinalSort:null}),this.domPosition.prev?t(this.domPosition.prev).after(this.currentItem):t(this.domPosition.parent).prepend(this.currentItem)),this},serialize:function(e){var i=this._getItemsAsjQuery(e&&e.connected),s=[];return e=e||{},t(i).each(function(){var i=(t(e.item||this).attr(e.attribute||"id")||"").match(e.expression||/(.+)[\-=_](.+)/);i&&s.push((e.key||i[1]+"[]")+"="+(e.key&&e.expression?i[1]:i[2]))}),!s.length&&e.key&&s.push(e.key+"="),s.join("&")},toArray:function(e){var i=this._getItemsAsjQuery(e&&e.connected),s=[];return e=e||{},i.each(function(){s.push(t(e.item||this).attr(e.attribute||"id")||"")}),s},_intersectsWith:function(t){var e=this.positionAbs.left,i=e+this.helperProportions.width,s=this.positionAbs.top,n=s+this.helperProportions.height,o=t.left,a=o+t.width,r=t.top,l=r+t.height,h=this.offset.click.top,c=this.offset.click.left,u="x"===this.options.axis||s+h>r&&l>s+h,d="y"===this.options.axis||e+c>o&&a>e+c,p=u&&d;return"pointer"===this.options.tolerance||this.options.forcePointerForContainers||"pointer"!==this.options.tolerance&&this.helperProportions[this.floating?"width":"height"]>t[this.floating?"width":"height"]?p:e+this.helperProportions.width/2>o&&a>i-this.helperProportions.width/2&&s+this.helperProportions.height/2>r&&l>n-this.helperProportions.height/2},_intersectsWithPointer:function(t){var e,i,s="x"===this.options.axis||this._isOverAxis(this.positionAbs.top+this.offset.click.top,t.top,t.height),n="y"===this.options.axis||this._isOverAxis(this.positionAbs.left+this.offset.click.left,t.left,t.width),o=s&&n;return o?(e=this._getDragVerticalDirection(),i=this._getDragHorizontalDirection(),this.floating?"right"===i||"down"===e?2:1:e&&("down"===e?2:1)):!1},_intersectsWithSides:function(t){var e=this._isOverAxis(this.positionAbs.top+this.offset.click.top,t.top+t.height/2,t.height),i=this._isOverAxis(this.positionAbs.left+this.offset.click.left,t.left+t.width/2,t.width),s=this._getDragVerticalDirection(),n=this._getDragHorizontalDirection();return this.floating&&n?"right"===n&&i||"left"===n&&!i:s&&("down"===s&&e||"up"===s&&!e)},_getDragVerticalDirection:function(){var t=this.positionAbs.top-this.lastPositionAbs.top;return 0!==t&&(t>0?"down":"up")},_getDragHorizontalDirection:function(){var t=this.positionAbs.left-this.lastPositionAbs.left;return 0!==t&&(t>0?"right":"left")},refresh:function(t){return this._refreshItems(t),this._setHandleClassName(),this.refreshPositions(),this},_connectWith:function(){var t=this.options;return t.connectWith.constructor===String?[t.connectWith]:t.connectWith},_getItemsAsjQuery:function(e){function i(){r.push(this)}var s,n,o,a,r=[],l=[],h=this._connectWith();if(h&&e)for(s=h.length-1;s>=0;s--)for(o=t(h[s],this.document[0]),n=o.length-1;n>=0;n--)a=t.data(o[n],this.widgetFullName),a&&a!==this&&!a.options.disabled&&l.push([t.isFunction(a.options.items)?a.options.items.call(a.element):t(a.options.items,a.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),a]);for(l.push([t.isFunction(this.options.items)?this.options.items.call(this.element,null,{options:this.options,item:this.currentItem}):t(this.options.items,this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),this]),s=l.length-1;s>=0;s--)l[s][0].each(i);return t(r)},_removeCurrentsFromItems:function(){var e=this.currentItem.find(":data("+this.widgetName+"-item)");this.items=t.grep(this.items,function(t){for(var i=0;e.length>i;i++)if(e[i]===t.item[0])return!1;return!0})},_refreshItems:function(e){this.items=[],this.containers=[this];var i,s,n,o,a,r,l,h,c=this.items,u=[[t.isFunction(this.options.items)?this.options.items.call(this.element[0],e,{item:this.currentItem}):t(this.options.items,this.element),this]],d=this._connectWith();if(d&&this.ready)for(i=d.length-1;i>=0;i--)for(n=t(d[i],this.document[0]),s=n.length-1;s>=0;s--)o=t.data(n[s],this.widgetFullName),o&&o!==this&&!o.options.disabled&&(u.push([t.isFunction(o.options.items)?o.options.items.call(o.element[0],e,{item:this.currentItem}):t(o.options.items,o.element),o]),this.containers.push(o));for(i=u.length-1;i>=0;i--)for(a=u[i][1],r=u[i][0],s=0,h=r.length;h>s;s++)l=t(r[s]),l.data(this.widgetName+"-item",a),c.push({item:l,instance:a,width:0,height:0,left:0,top:0})},refreshPositions:function(e){this.floating=this.items.length?"x"===this.options.axis||this._isFloating(this.items[0].item):!1,this.offsetParent&&this.helper&&(this.offset.parent=this._getParentOffset());var i,s,n,o;for(i=this.items.length-1;i>=0;i--)s=this.items[i],s.instance!==this.currentContainer&&this.currentContainer&&s.item[0]!==this.currentItem[0]||(n=this.options.toleranceElement?t(this.options.toleranceElement,s.item):s.item,e||(s.width=n.outerWidth(),s.height=n.outerHeight()),o=n.offset(),s.left=o.left,s.top=o.top);if(this.options.custom&&this.options.custom.refreshContainers)this.options.custom.refreshContainers.call(this);else for(i=this.containers.length-1;i>=0;i--)o=this.containers[i].element.offset(),this.containers[i].containerCache.left=o.left,this.containers[i].containerCache.top=o.top,this.containers[i].containerCache.width=this.containers[i].element.outerWidth(),this.containers[i].containerCache.height=this.containers[i].element.outerHeight();return this},_createPlaceholder:function(e){e=e||this;var i,s=e.options;s.placeholder&&s.placeholder.constructor!==String||(i=s.placeholder,s.placeholder={element:function(){var s=e.currentItem[0].nodeName.toLowerCase(),n=t("<"+s+">",e.document[0]);return e._addClass(n,"ui-sortable-placeholder",i||e.currentItem[0].className)._removeClass(n,"ui-sortable-helper"),"tbody"===s?e._createTrPlaceholder(e.currentItem.find("tr").eq(0),t("",e.document[0]).appendTo(n)):"tr"===s?e._createTrPlaceholder(e.currentItem,n):"img"===s&&n.attr("src",e.currentItem.attr("src")),i||n.css("visibility","hidden"),n},update:function(t,n){(!i||s.forcePlaceholderSize)&&(n.height()||n.height(e.currentItem.innerHeight()-parseInt(e.currentItem.css("paddingTop")||0,10)-parseInt(e.currentItem.css("paddingBottom")||0,10)),n.width()||n.width(e.currentItem.innerWidth()-parseInt(e.currentItem.css("paddingLeft")||0,10)-parseInt(e.currentItem.css("paddingRight")||0,10)))}}),e.placeholder=t(s.placeholder.element.call(e.element,e.currentItem)),e.currentItem.after(e.placeholder),s.placeholder.update(e,e.placeholder)},_createTrPlaceholder:function(e,i){var s=this;e.children().each(function(){t(" ",s.document[0]).attr("colspan",t(this).attr("colspan")||1).appendTo(i)})},_contactContainers:function(e){var i,s,n,o,a,r,l,h,c,u,d=null,p=null;for(i=this.containers.length-1;i>=0;i--)if(!t.contains(this.currentItem[0],this.containers[i].element[0]))if(this._intersectsWith(this.containers[i].containerCache)){if(d&&t.contains(this.containers[i].element[0],d.element[0]))continue;d=this.containers[i],p=i}else this.containers[i].containerCache.over&&(this.containers[i]._trigger("out",e,this._uiHash(this)),this.containers[i].containerCache.over=0);if(d)if(1===this.containers.length)this.containers[p].containerCache.over||(this.containers[p]._trigger("over",e,this._uiHash(this)),this.containers[p].containerCache.over=1);else{for(n=1e4,o=null,c=d.floating||this._isFloating(this.currentItem),a=c?"left":"top",r=c?"width":"height",u=c?"pageX":"pageY",s=this.items.length-1;s>=0;s--)t.contains(this.containers[p].element[0],this.items[s].item[0])&&this.items[s].item[0]!==this.currentItem[0]&&(l=this.items[s].item.offset()[a],h=!1,e[u]-l>this.items[s][r]/2&&(h=!0),n>Math.abs(e[u]-l)&&(n=Math.abs(e[u]-l),o=this.items[s],this.direction=h?"up":"down"));if(!o&&!this.options.dropOnEmpty)return;if(this.currentContainer===this.containers[p])return this.currentContainer.containerCache.over||(this.containers[p]._trigger("over",e,this._uiHash()),this.currentContainer.containerCache.over=1),void 0;o?this._rearrange(e,o,null,!0):this._rearrange(e,null,this.containers[p].element,!0),this._trigger("change",e,this._uiHash()),this.containers[p]._trigger("change",e,this._uiHash(this)),this.currentContainer=this.containers[p],this.options.placeholder.update(this.currentContainer,this.placeholder),this.containers[p]._trigger("over",e,this._uiHash(this)),this.containers[p].containerCache.over=1}},_createHelper:function(e){var i=this.options,s=t.isFunction(i.helper)?t(i.helper.apply(this.element[0],[e,this.currentItem])):"clone"===i.helper?this.currentItem.clone():this.currentItem;return s.parents("body").length||t("parent"!==i.appendTo?i.appendTo:this.currentItem[0].parentNode)[0].appendChild(s[0]),s[0]===this.currentItem[0]&&(this._storedCSS={width:this.currentItem[0].style.width,height:this.currentItem[0].style.height,position:this.currentItem.css("position"),top:this.currentItem.css("top"),left:this.currentItem.css("left")}),(!s[0].style.width||i.forceHelperSize)&&s.width(this.currentItem.width()),(!s[0].style.height||i.forceHelperSize)&&s.height(this.currentItem.height()),s},_adjustOffsetFromHelper:function(e){"string"==typeof e&&(e=e.split(" ")),t.isArray(e)&&(e={left:+e[0],top:+e[1]||0}),"left"in e&&(this.offset.click.left=e.left+this.margins.left),"right"in e&&(this.offset.click.left=this.helperProportions.width-e.right+this.margins.left),"top"in e&&(this.offset.click.top=e.top+this.margins.top),"bottom"in e&&(this.offset.click.top=this.helperProportions.height-e.bottom+this.margins.top)},_getParentOffset:function(){this.offsetParent=this.helper.offsetParent();var e=this.offsetParent.offset();return"absolute"===this.cssPosition&&this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])&&(e.left+=this.scrollParent.scrollLeft(),e.top+=this.scrollParent.scrollTop()),(this.offsetParent[0]===this.document[0].body||this.offsetParent[0].tagName&&"html"===this.offsetParent[0].tagName.toLowerCase()&&t.ui.ie)&&(e={top:0,left:0}),{top:e.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:e.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if("relative"===this.cssPosition){var t=this.currentItem.position();return{top:t.top-(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:t.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.currentItem.css("marginLeft"),10)||0,top:parseInt(this.currentItem.css("marginTop"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var e,i,s,n=this.options;"parent"===n.containment&&(n.containment=this.helper[0].parentNode),("document"===n.containment||"window"===n.containment)&&(this.containment=[0-this.offset.relative.left-this.offset.parent.left,0-this.offset.relative.top-this.offset.parent.top,"document"===n.containment?this.document.width():this.window.width()-this.helperProportions.width-this.margins.left,("document"===n.containment?this.document.height()||document.body.parentNode.scrollHeight:this.window.height()||this.document[0].body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top]),/^(document|window|parent)$/.test(n.containment)||(e=t(n.containment)[0],i=t(n.containment).offset(),s="hidden"!==t(e).css("overflow"),this.containment=[i.left+(parseInt(t(e).css("borderLeftWidth"),10)||0)+(parseInt(t(e).css("paddingLeft"),10)||0)-this.margins.left,i.top+(parseInt(t(e).css("borderTopWidth"),10)||0)+(parseInt(t(e).css("paddingTop"),10)||0)-this.margins.top,i.left+(s?Math.max(e.scrollWidth,e.offsetWidth):e.offsetWidth)-(parseInt(t(e).css("borderLeftWidth"),10)||0)-(parseInt(t(e).css("paddingRight"),10)||0)-this.helperProportions.width-this.margins.left,i.top+(s?Math.max(e.scrollHeight,e.offsetHeight):e.offsetHeight)-(parseInt(t(e).css("borderTopWidth"),10)||0)-(parseInt(t(e).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top])},_convertPositionTo:function(e,i){i||(i=this.position);var s="absolute"===e?1:-1,n="absolute"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])?this.scrollParent:this.offsetParent,o=/(html|body)/i.test(n[0].tagName);return{top:i.top+this.offset.relative.top*s+this.offset.parent.top*s-("fixed"===this.cssPosition?-this.scrollParent.scrollTop():o?0:n.scrollTop())*s,left:i.left+this.offset.relative.left*s+this.offset.parent.left*s-("fixed"===this.cssPosition?-this.scrollParent.scrollLeft():o?0:n.scrollLeft())*s}},_generatePosition:function(e){var i,s,n=this.options,o=e.pageX,a=e.pageY,r="absolute"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&t.contains(this.scrollParent[0],this.offsetParent[0])?this.scrollParent:this.offsetParent,l=/(html|body)/i.test(r[0].tagName);return"relative"!==this.cssPosition||this.scrollParent[0]!==this.document[0]&&this.scrollParent[0]!==this.offsetParent[0]||(this.offset.relative=this._getRelativeOffset()),this.originalPosition&&(this.containment&&(e.pageX-this.offset.click.leftthis.containment[2]&&(o=this.containment[2]+this.offset.click.left),e.pageY-this.offset.click.top>this.containment[3]&&(a=this.containment[3]+this.offset.click.top)),n.grid&&(i=this.originalPageY+Math.round((a-this.originalPageY)/n.grid[1])*n.grid[1],a=this.containment?i-this.offset.click.top>=this.containment[1]&&i-this.offset.click.top<=this.containment[3]?i:i-this.offset.click.top>=this.containment[1]?i-n.grid[1]:i+n.grid[1]:i,s=this.originalPageX+Math.round((o-this.originalPageX)/n.grid[0])*n.grid[0],o=this.containment?s-this.offset.click.left>=this.containment[0]&&s-this.offset.click.left<=this.containment[2]?s:s-this.offset.click.left>=this.containment[0]?s-n.grid[0]:s+n.grid[0]:s)),{top:a-this.offset.click.top-this.offset.relative.top-this.offset.parent.top+("fixed"===this.cssPosition?-this.scrollParent.scrollTop():l?0:r.scrollTop()),left:o-this.offset.click.left-this.offset.relative.left-this.offset.parent.left+("fixed"===this.cssPosition?-this.scrollParent.scrollLeft():l?0:r.scrollLeft())}},_rearrange:function(t,e,i,s){i?i[0].appendChild(this.placeholder[0]):e.item[0].parentNode.insertBefore(this.placeholder[0],"down"===this.direction?e.item[0]:e.item[0].nextSibling),this.counter=this.counter?++this.counter:1;var n=this.counter;this._delay(function(){n===this.counter&&this.refreshPositions(!s)})},_clear:function(t,e){function i(t,e,i){return function(s){i._trigger(t,s,e._uiHash(e))}}this.reverting=!1;var s,n=[];if(!this._noFinalSort&&this.currentItem.parent().length&&this.placeholder.before(this.currentItem),this._noFinalSort=null,this.helper[0]===this.currentItem[0]){for(s in this._storedCSS)("auto"===this._storedCSS[s]||"static"===this._storedCSS[s])&&(this._storedCSS[s]="");this.currentItem.css(this._storedCSS),this._removeClass(this.currentItem,"ui-sortable-helper")}else this.currentItem.show();for(this.fromOutside&&!e&&n.push(function(t){this._trigger("receive",t,this._uiHash(this.fromOutside))}),!this.fromOutside&&this.domPosition.prev===this.currentItem.prev().not(".ui-sortable-helper")[0]&&this.domPosition.parent===this.currentItem.parent()[0]||e||n.push(function(t){this._trigger("update",t,this._uiHash())}),this!==this.currentContainer&&(e||(n.push(function(t){this._trigger("remove",t,this._uiHash())}),n.push(function(t){return function(e){t._trigger("receive",e,this._uiHash(this))}}.call(this,this.currentContainer)),n.push(function(t){return function(e){t._trigger("update",e,this._uiHash(this))}}.call(this,this.currentContainer)))),s=this.containers.length-1;s>=0;s--)e||n.push(i("deactivate",this,this.containers[s])),this.containers[s].containerCache.over&&(n.push(i("out",this,this.containers[s])),this.containers[s].containerCache.over=0);if(this.storedCursor&&(this.document.find("body").css("cursor",this.storedCursor),this.storedStylesheet.remove()),this._storedOpacity&&this.helper.css("opacity",this._storedOpacity),this._storedZIndex&&this.helper.css("zIndex","auto"===this._storedZIndex?"":this._storedZIndex),this.dragging=!1,e||this._trigger("beforeStop",t,this._uiHash()),this.placeholder[0].parentNode.removeChild(this.placeholder[0]),this.cancelHelperRemoval||(this.helper[0]!==this.currentItem[0]&&this.helper.remove(),this.helper=null),!e){for(s=0;n.length>s;s++)n[s].call(this,t);this._trigger("stop",t,this._uiHash())}return this.fromOutside=!1,!this.cancelHelperRemoval},_trigger:function(){t.Widget.prototype._trigger.apply(this,arguments)===!1&&this.cancel()},_uiHash:function(e){var i=e||this;return{helper:i.helper,placeholder:i.placeholder||t([]),position:i.position,originalPosition:i.originalPosition,offset:i.positionAbs,item:i.currentItem,sender:e?e.element:null}}})}); diff --git a/OpenOversight/app/static/js/npm.js b/OpenOversight/app/static/js/npm.js old mode 100755 new mode 100644 index bf6aa8060..81f01216f --- a/OpenOversight/app/static/js/npm.js +++ b/OpenOversight/app/static/js/npm.js @@ -10,4 +10,4 @@ require('../../js/tooltip.js') require('../../js/popover.js') require('../../js/scrollspy.js') require('../../js/tab.js') -require('../../js/affix.js') \ No newline at end of file +require('../../js/affix.js') diff --git a/OpenOversight/app/static/js/qunit.js b/OpenOversight/app/static/js/qunit.js old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/js/respond.min.js b/OpenOversight/app/static/js/respond.min.js index 80a7b69dc..f8076c719 100644 --- a/OpenOversight/app/static/js/respond.min.js +++ b/OpenOversight/app/static/js/respond.min.js @@ -2,4 +2,4 @@ * Licensed under https://github.com/scottjehl/Respond/blob/master/LICENSE-MIT * */ -!function(a){"use strict";a.matchMedia=a.matchMedia||function(a){var b,c=a.documentElement,d=c.firstElementChild||c.firstChild,e=a.createElement("body"),f=a.createElement("div");return f.id="mq-test-1",f.style.cssText="position:absolute;top:-100em",e.style.background="none",e.appendChild(f),function(a){return f.innerHTML='­',c.insertBefore(e,d),b=42===f.offsetWidth,c.removeChild(e),{matches:b,media:a}}}(a.document)}(this),function(a){"use strict";function b(){u(!0)}var c={};a.respond=c,c.update=function(){};var d=[],e=function(){var b=!1;try{b=new a.XMLHttpRequest}catch(c){b=new a.ActiveXObject("Microsoft.XMLHTTP")}return function(){return b}}(),f=function(a,b){var c=e();c&&(c.open("GET",a,!0),c.onreadystatechange=function(){4!==c.readyState||200!==c.status&&304!==c.status||b(c.responseText)},4!==c.readyState&&c.send(null))};if(c.ajax=f,c.queue=d,c.regex={media:/@media[^\{]+\{([^\{\}]*\{[^\}\{]*\})+/gi,keyframes:/@(?:\-(?:o|moz|webkit)\-)?keyframes[^\{]+\{(?:[^\{\}]*\{[^\}\{]*\})+[^\}]*\}/gi,urls:/(url\()['"]?([^\/\)'"][^:\)'"]+)['"]?(\))/g,findStyles:/@media *([^\{]+)\{([\S\s]+?)$/,only:/(only\s+)?([a-zA-Z]+)\s?/,minw:/\([\s]*min\-width\s*:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/,maxw:/\([\s]*max\-width\s*:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/},c.mediaQueriesSupported=a.matchMedia&&null!==a.matchMedia("only all")&&a.matchMedia("only all").matches,!c.mediaQueriesSupported){var g,h,i,j=a.document,k=j.documentElement,l=[],m=[],n=[],o={},p=30,q=j.getElementsByTagName("head")[0]||k,r=j.getElementsByTagName("base")[0],s=q.getElementsByTagName("link"),t=function(){var a,b=j.createElement("div"),c=j.body,d=k.style.fontSize,e=c&&c.style.fontSize,f=!1;return b.style.cssText="position:absolute;font-size:1em;width:1em",c||(c=f=j.createElement("body"),c.style.background="none"),k.style.fontSize="100%",c.style.fontSize="100%",c.appendChild(b),f&&k.insertBefore(c,k.firstChild),a=b.offsetWidth,f?k.removeChild(c):c.removeChild(b),k.style.fontSize=d,e&&(c.style.fontSize=e),a=i=parseFloat(a)},u=function(b){var c="clientWidth",d=k[c],e="CSS1Compat"===j.compatMode&&d||j.body[c]||d,f={},o=s[s.length-1],r=(new Date).getTime();if(b&&g&&p>r-g)return a.clearTimeout(h),h=a.setTimeout(u,p),void 0;g=r;for(var v in l)if(l.hasOwnProperty(v)){var w=l[v],x=w.minw,y=w.maxw,z=null===x,A=null===y,B="em";x&&(x=parseFloat(x)*(x.indexOf(B)>-1?i||t():1)),y&&(y=parseFloat(y)*(y.indexOf(B)>-1?i||t():1)),w.hasquery&&(z&&A||!(z||e>=x)||!(A||y>=e))||(f[w.media]||(f[w.media]=[]),f[w.media].push(m[w.rules]))}for(var C in n)n.hasOwnProperty(C)&&n[C]&&n[C].parentNode===q&&q.removeChild(n[C]);n.length=0;for(var D in f)if(f.hasOwnProperty(D)){var E=j.createElement("style"),F=f[D].join("\n");E.type="text/css",E.media=D,q.insertBefore(E,o.nextSibling),E.styleSheet?E.styleSheet.cssText=F:E.appendChild(j.createTextNode(F)),n.push(E)}},v=function(a,b,d){var e=a.replace(c.regex.keyframes,"").match(c.regex.media),f=e&&e.length||0;b=b.substring(0,b.lastIndexOf("/"));var g=function(a){return a.replace(c.regex.urls,"$1"+b+"$2$3")},h=!f&&d;b.length&&(b+="/"),h&&(f=1);for(var i=0;f>i;i++){var j,k,n,o;h?(j=d,m.push(g(a))):(j=e[i].match(c.regex.findStyles)&&RegExp.$1,m.push(RegExp.$2&&g(RegExp.$2))),n=j.split(","),o=n.length;for(var p=0;o>p;p++)k=n[p],l.push({media:k.split("(")[0].match(c.regex.only)&&RegExp.$2||"all",rules:m.length-1,hasquery:k.indexOf("(")>-1,minw:k.match(c.regex.minw)&&parseFloat(RegExp.$1)+(RegExp.$2||""),maxw:k.match(c.regex.maxw)&&parseFloat(RegExp.$1)+(RegExp.$2||"")})}u()},w=function(){if(d.length){var b=d.shift();f(b.href,function(c){v(c,b.href,b.media),o[b.href]=!0,a.setTimeout(function(){w()},0)})}},x=function(){for(var b=0;b #mq-test-1 { width: 42px; }',c.insertBefore(e,d),b=42===f.offsetWidth,c.removeChild(e),{matches:b,media:a}}}(a.document)}(this),function(a){"use strict";function b(){u(!0)}var c={};a.respond=c,c.update=function(){};var d=[],e=function(){var b=!1;try{b=new a.XMLHttpRequest}catch(c){b=new a.ActiveXObject("Microsoft.XMLHTTP")}return function(){return b}}(),f=function(a,b){var c=e();c&&(c.open("GET",a,!0),c.onreadystatechange=function(){4!==c.readyState||200!==c.status&&304!==c.status||b(c.responseText)},4!==c.readyState&&c.send(null))};if(c.ajax=f,c.queue=d,c.regex={media:/@media[^\{]+\{([^\{\}]*\{[^\}\{]*\})+/gi,keyframes:/@(?:\-(?:o|moz|webkit)\-)?keyframes[^\{]+\{(?:[^\{\}]*\{[^\}\{]*\})+[^\}]*\}/gi,urls:/(url\()['"]?([^\/\)'"][^:\)'"]+)['"]?(\))/g,findStyles:/@media *([^\{]+)\{([\S\s]+?)$/,only:/(only\s+)?([a-zA-Z]+)\s?/,minw:/\([\s]*min\-width\s*:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/,maxw:/\([\s]*max\-width\s*:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/},c.mediaQueriesSupported=a.matchMedia&&null!==a.matchMedia("only all")&&a.matchMedia("only all").matches,!c.mediaQueriesSupported){var g,h,i,j=a.document,k=j.documentElement,l=[],m=[],n=[],o={},p=30,q=j.getElementsByTagName("head")[0]||k,r=j.getElementsByTagName("base")[0],s=q.getElementsByTagName("link"),t=function(){var a,b=j.createElement("div"),c=j.body,d=k.style.fontSize,e=c&&c.style.fontSize,f=!1;return b.style.cssText="position:absolute;font-size:1em;width:1em",c||(c=f=j.createElement("body"),c.style.background="none"),k.style.fontSize="100%",c.style.fontSize="100%",c.appendChild(b),f&&k.insertBefore(c,k.firstChild),a=b.offsetWidth,f?k.removeChild(c):c.removeChild(b),k.style.fontSize=d,e&&(c.style.fontSize=e),a=i=parseFloat(a)},u=function(b){var c="clientWidth",d=k[c],e="CSS1Compat"===j.compatMode&&d||j.body[c]||d,f={},o=s[s.length-1],r=(new Date).getTime();if(b&&g&&p>r-g)return a.clearTimeout(h),h=a.setTimeout(u,p),void 0;g=r;for(var v in l)if(l.hasOwnProperty(v)){var w=l[v],x=w.minw,y=w.maxw,z=null===x,A=null===y,B="em";x&&(x=parseFloat(x)*(x.indexOf(B)>-1?i||t():1)),y&&(y=parseFloat(y)*(y.indexOf(B)>-1?i||t():1)),w.hasquery&&(z&&A||!(z||e>=x)||!(A||y>=e))||(f[w.media]||(f[w.media]=[]),f[w.media].push(m[w.rules]))}for(var C in n)n.hasOwnProperty(C)&&n[C]&&n[C].parentNode===q&&q.removeChild(n[C]);n.length=0;for(var D in f)if(f.hasOwnProperty(D)){var E=j.createElement("style"),F=f[D].join("\n");E.type="text/css",E.media=D,q.insertBefore(E,o.nextSibling),E.styleSheet?E.styleSheet.cssText=F:E.appendChild(j.createTextNode(F)),n.push(E)}},v=function(a,b,d){var e=a.replace(c.regex.keyframes,"").match(c.regex.media),f=e&&e.length||0;b=b.substring(0,b.lastIndexOf("/"));var g=function(a){return a.replace(c.regex.urls,"$1"+b+"$2$3")},h=!f&&d;b.length&&(b+="/"),h&&(f=1);for(var i=0;f>i;i++){var j,k,n,o;h?(j=d,m.push(g(a))):(j=e[i].match(c.regex.findStyles)&&RegExp.$1,m.push(RegExp.$2&&g(RegExp.$2))),n=j.split(","),o=n.length;for(var p=0;o>p;p++)k=n[p],l.push({media:k.split("(")[0].match(c.regex.only)&&RegExp.$2||"all",rules:m.length-1,hasquery:k.indexOf("(")>-1,minw:k.match(c.regex.minw)&&parseFloat(RegExp.$1)+(RegExp.$2||""),maxw:k.match(c.regex.maxw)&&parseFloat(RegExp.$1)+(RegExp.$2||"")})}u()},w=function(){if(d.length){var b=d.shift();f(b.href,function(c){v(c,b.href,b.media),o[b.href]=!0,a.setTimeout(function(){w()},0)})}},x=function(){for(var b=0;b - + OpenOversight
- + {% include 'partials/links_and_videos_row.html' %} {% if current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} diff --git a/OpenOversight/app/templates/index.html b/OpenOversight/app/templates/index.html index 35f78d3ea..0b490d279 100644 --- a/OpenOversight/app/templates/index.html +++ b/OpenOversight/app/templates/index.html @@ -16,10 +16,10 @@

Browse or Find a Law Enforcement Officer

Search the database
- +

Search our public database for available information on officers in your city or to identify an officer with whom you have had a negative interaction. - +

Browse officers @@ -29,7 +29,7 @@

Identify officers

- +

Submit an Image @@ -42,7 +42,7 @@

Submit Image

- +
@@ -79,7 +79,7 @@

Donate

- +
diff --git a/OpenOversight/app/templates/input_find_officer.html b/OpenOversight/app/templates/input_find_officer.html index 400b08635..777d5c3df 100644 --- a/OpenOversight/app/templates/input_find_officer.html +++ b/OpenOversight/app/templates/input_find_officer.html @@ -88,7 +88,7 @@

Do you know any part of the Officer's [{{ error }}]

{% endfor %}

-
+

diff --git a/OpenOversight/app/templates/label_data.html b/OpenOversight/app/templates/label_data.html index cbde69465..d934ac94c 100644 --- a/OpenOversight/app/templates/label_data.html +++ b/OpenOversight/app/templates/label_data.html @@ -133,7 +133,7 @@

{{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} - + {{ wtf.form_field(form.email) }} {{ wtf.form_field(form.password) }}
diff --git a/OpenOversight/app/templates/partials/incident_fields.html b/OpenOversight/app/templates/partials/incident_fields.html index afc07ae44..324dbd5c1 100644 --- a/OpenOversight/app/templates/partials/incident_fields.html +++ b/OpenOversight/app/templates/partials/incident_fields.html @@ -97,4 +97,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/OpenOversight/app/templates/partials/officer_faces.html b/OpenOversight/app/templates/partials/officer_faces.html index 72ac1e952..3d6920357 100644 --- a/OpenOversight/app/templates/partials/officer_faces.html +++ b/OpenOversight/app/templates/partials/officer_faces.html @@ -1,5 +1,5 @@ {% for path in paths %} - + {% if is_admin_or_coordinator %} {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_form_fields_hidden.html b/OpenOversight/app/templates/partials/officer_form_fields_hidden.html index 7aec5c1ee..9158691ea 100644 --- a/OpenOversight/app/templates/partials/officer_form_fields_hidden.html +++ b/OpenOversight/app/templates/partials/officer_form_fields_hidden.html @@ -6,4 +6,4 @@ {{ form.rank(class="hidden") }} {{ form.min_age(class="hidden") }} {{ form.max_age(class="hidden") }} -{{ form.hidden_tag() }} \ No newline at end of file +{{ form.hidden_tag() }} diff --git a/OpenOversight/app/templates/partials/roster_form_fields.html b/OpenOversight/app/templates/partials/roster_form_fields.html index 4b768bd30..6a131eb29 100644 --- a/OpenOversight/app/templates/partials/roster_form_fields.html +++ b/OpenOversight/app/templates/partials/roster_form_fields.html @@ -1,4 +1,4 @@ {{ form.name(class="hidden") }} {{ form.badge(class="hidden") }} {{ form.dept(class="hidden") }} -{{ form.hidden_tag() }} \ No newline at end of file +{{ form.hidden_tag() }} diff --git a/OpenOversight/app/utils.py b/OpenOversight/app/utils.py index 34432ff37..44a038fc2 100644 --- a/OpenOversight/app/utils.py +++ b/OpenOversight/app/utils.py @@ -1,34 +1,48 @@ -from typing import Optional - -from future.utils import iteritems -from urllib.request import urlopen - -from io import BytesIO - -import boto3 -from botocore.exceptions import ClientError -import botocore import datetime import hashlib +import imghdr as imghdr import os import random import sys -from traceback import format_exc from distutils.util import strtobool +from io import BytesIO +from traceback import format_exc +from typing import Optional +from urllib.request import urlopen -from sqlalchemy import func, or_ -from sqlalchemy.sql.expression import cast -from sqlalchemy.orm import selectinload -import imghdr as imghdr +import boto3 +import botocore +from botocore.exceptions import ClientError from flask import current_app, url_for from flask_login import current_user +from future.utils import iteritems from PIL import Image as Pimage from PIL.PngImagePlugin import PngImageFile +from sqlalchemy import func, or_ +from sqlalchemy.orm import selectinload +from sqlalchemy.sql.expression import cast from .custom import add_jpeg_patch -from .models import (db, Officer, Assignment, Job, Image, Face, User, Unit, Department, - Incident, Location, LicensePlate, Link, Note, Description, Salary) -from .main.choices import RACE_CHOICES, GENDER_CHOICES +from .main.choices import GENDER_CHOICES, RACE_CHOICES +from .models import ( + Assignment, + Department, + Description, + Face, + Image, + Incident, + Job, + LicensePlate, + Link, + Location, + Note, + Officer, + Salary, + Unit, + User, + db, +) + # Ensure the file is read/write by the creator only SAVED_UMASK = os.umask(0o077) @@ -48,12 +62,12 @@ def set_dynamic_default(form_field, value): def get_or_create(session, model, defaults=None, **kwargs): - if 'csrf_token' in kwargs: - kwargs.pop('csrf_token') + if "csrf_token" in kwargs: + kwargs.pop("csrf_token") # Because id is a keyword in Python, officers member is called oo_id - if 'oo_id' in kwargs: - kwargs = {'id': kwargs['oo_id']} + if "oo_id" in kwargs: + kwargs = {"id": kwargs["oo_id"]} # We need to convert empty strings to None for filter_by # as '' != None in the database and @@ -61,7 +75,7 @@ def get_or_create(session, model, defaults=None, **kwargs): # of null. filter_params = {} for key, value in kwargs.items(): - if value != '': + if value != "": filter_params.update({key: value}) else: filter_params.update({key: None}) @@ -81,7 +95,12 @@ def get_or_create(session, model, defaults=None, **kwargs): def unit_choices(department_id: Optional[int] = None): if department_id is not None: - return db.session.query(Unit).filter_by(department_id=department_id).order_by(Unit.descrip.asc()).all() + return ( + db.session.query(Unit) + .filter_by(department_id=department_id) + .order_by(Unit.descrip.asc()) + .all() + ) return db.session.query(Unit).order_by(Unit.descrip.asc()).all() @@ -95,17 +114,19 @@ def add_new_assignment(officer_id, form): else: unit_id = None - job = Job.query\ - .filter_by(department_id=form.job_title.data.department_id, - job_title=form.job_title.data.job_title)\ - .one_or_none() - - new_assignment = Assignment(officer_id=officer_id, - star_no=form.star_no.data, - job_id=job.id, - unit_id=unit_id, - star_date=form.star_date.data, - resign_date=form.resign_date.data) + job = Job.query.filter_by( + department_id=form.job_title.data.department_id, + job_title=form.job_title.data.job_title, + ).one_or_none() + + new_assignment = Assignment( + officer_id=officer_id, + star_no=form.star_no.data, + job_id=job.id, + unit_id=unit_id, + star_date=form.star_date.data, + resign_date=form.resign_date.data, + ) db.session.add(new_assignment) db.session.commit() @@ -130,15 +151,17 @@ def edit_existing_assignment(assignment, form): def add_officer_profile(form, current_user): - officer = Officer(first_name=form.first_name.data, - last_name=form.last_name.data, - middle_initial=form.middle_initial.data, - suffix=form.suffix.data, - race=form.race.data, - gender=form.gender.data, - birth_year=form.birth_year.data, - employment_date=form.employment_date.data, - department_id=form.department.data.id) + officer = Officer( + first_name=form.first_name.data, + last_name=form.last_name.data, + middle_initial=form.middle_initial.data, + suffix=form.suffix.data, + race=form.race.data, + gender=form.gender.data, + birth_year=form.birth_year.data, + employment_date=form.employment_date.data, + department_id=form.department.data.id, + ) db.session.add(officer) db.session.commit() @@ -147,51 +170,56 @@ def add_officer_profile(form, current_user): else: officer_unit = None - assignment = Assignment(baseofficer=officer, - star_no=form.star_no.data, - job_id=form.job_id.data, - unit=officer_unit, - star_date=form.employment_date.data) + assignment = Assignment( + baseofficer=officer, + star_no=form.star_no.data, + job_id=form.job_id.data, + unit=officer_unit, + star_date=form.employment_date.data, + ) db.session.add(assignment) if form.links.data: - for link in form.data['links']: + for link in form.data["links"]: # don't try to create with a blank string - if link['url']: + if link["url"]: li, _ = get_or_create(db.session, Link, **link) if li: officer.links.append(li) if form.notes.data: - for note in form.data['notes']: + for note in form.data["notes"]: # don't try to create with a blank string - if note['text_contents']: + if note["text_contents"]: new_note = Note( - note=note['text_contents'], + note=note["text_contents"], user_id=current_user.get_id(), officer=officer, date_created=datetime.datetime.now(), - date_updated=datetime.datetime.now()) + date_updated=datetime.datetime.now(), + ) db.session.add(new_note) if form.descriptions.data: - for description in form.data['descriptions']: + for description in form.data["descriptions"]: # don't try to create with a blank string - if description['text_contents']: + if description["text_contents"]: new_description = Description( - description=description['text_contents'], + description=description["text_contents"], user_id=current_user.get_id(), officer=officer, date_created=datetime.datetime.now(), - date_updated=datetime.datetime.now()) + date_updated=datetime.datetime.now(), + ) db.session.add(new_description) if form.salaries.data: - for salary in form.data['salaries']: + for salary in form.data["salaries"]: # don't try to create with a blank string - if salary['salary']: + if salary["salary"]: new_salary = Salary( officer=officer, - salary=salary['salary'], - overtime_pay=salary['overtime_pay'], - year=salary['year'], - is_fiscal_year=salary['is_fiscal_year']) + salary=salary["salary"], + overtime_pay=salary["overtime_pay"], + year=salary["year"], + is_fiscal_year=salary["is_fiscal_year"], + ) db.session.add(new_salary) db.session.commit() @@ -208,8 +236,11 @@ def edit_officer_profile(officer, form): def allowed_file(filename): - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] + return ( + "." in filename + and filename.rsplit(".", 1)[1].lower() + in current_app.config["ALLOWED_EXTENSIONS"] + ) def get_random_image(image_query): @@ -221,10 +252,10 @@ def get_random_image(image_query): def serve_image(filepath): - if 'http' in filepath: + if "http" in filepath: return filepath - if 'static' in filepath: - return url_for('static', filename=filepath.replace('static/', '').lstrip('/')) + if "static" in filepath: + return url_for("static", filename=filepath.replace("static/", "").lstrip("/")) def compute_hash(data_to_hash): @@ -232,7 +263,7 @@ def compute_hash(data_to_hash): def upload_obj_to_s3(file_obj, dest_filename): - s3_client = boto3.client('s3') + s3_client = boto3.client("s3") # Folder to store files in on S3 is first two chars of dest_filename s3_folder = dest_filename[0:2] @@ -240,106 +271,120 @@ def upload_obj_to_s3(file_obj, dest_filename): file_ending = imghdr.what(None, h=file_obj.read()) file_obj.seek(0) s3_content_type = "image/%s" % file_ending - s3_path = '{}/{}'.format(s3_folder, s3_filename) - s3_client.upload_fileobj(file_obj, - current_app.config['S3_BUCKET_NAME'], - s3_path, - ExtraArgs={'ContentType': s3_content_type, 'ACL': 'public-read'}) + s3_path = "{}/{}".format(s3_folder, s3_filename) + s3_client.upload_fileobj( + file_obj, + current_app.config["S3_BUCKET_NAME"], + s3_path, + ExtraArgs={"ContentType": s3_content_type, "ACL": "public-read"}, + ) config = s3_client._client_config config.signature_version = botocore.UNSIGNED - url = boto3.resource( - 's3', config=config).meta.client.generate_presigned_url( - 'get_object', - Params={'Bucket': current_app.config['S3_BUCKET_NAME'], - 'Key': s3_path}) + url = boto3.resource("s3", config=config).meta.client.generate_presigned_url( + "get_object", + Params={"Bucket": current_app.config["S3_BUCKET_NAME"], "Key": s3_path}, + ) return url def filter_by_form(form_data, officer_query, department_id=None): - if form_data.get('name'): - officer_query = officer_query.filter( - Officer.last_name.ilike('%%{}%%'.format(form_data['name'])) - ) - if not department_id and form_data.get('dept'): - department_id = form_data['dept'].id + if form_data.get("name"): officer_query = officer_query.filter( - Officer.department_id == department_id + Officer.last_name.ilike("%%{}%%".format(form_data["name"])) ) + if not department_id and form_data.get("dept"): + department_id = form_data["dept"].id + officer_query = officer_query.filter(Officer.department_id == department_id) - if form_data.get('unique_internal_identifier'): + if form_data.get("unique_internal_identifier"): officer_query = officer_query.filter( - Officer.unique_internal_identifier.ilike('%%{}%%'.format(form_data['unique_internal_identifier'])) + Officer.unique_internal_identifier.ilike( + "%%{}%%".format(form_data["unique_internal_identifier"]) + ) ) race_values = [x for x, _ in RACE_CHOICES] - if form_data.get('race') and all(race in race_values for race in form_data['race']): - if 'Not Sure' in form_data['race']: - form_data['race'].append(None) - officer_query = officer_query.filter(Officer.race.in_(form_data['race'])) + if form_data.get("race") and all(race in race_values for race in form_data["race"]): + if "Not Sure" in form_data["race"]: + form_data["race"].append(None) + officer_query = officer_query.filter(Officer.race.in_(form_data["race"])) gender_values = [x for x, _ in GENDER_CHOICES] - if form_data.get('gender') and all(gender in gender_values for gender in form_data['gender']): - if 'Not Sure' not in form_data['gender']: - officer_query = officer_query.filter(or_(Officer.gender.in_(form_data['gender']), Officer.gender.is_(None))) + if form_data.get("gender") and all( + gender in gender_values for gender in form_data["gender"] + ): + if "Not Sure" not in form_data["gender"]: + officer_query = officer_query.filter( + or_(Officer.gender.in_(form_data["gender"]), Officer.gender.is_(None)) + ) - if form_data.get('min_age') and form_data.get('max_age'): + if form_data.get("min_age") and form_data.get("max_age"): current_year = datetime.datetime.now().year - min_birth_year = current_year - int(form_data['min_age']) - max_birth_year = current_year - int(form_data['max_age']) - officer_query = officer_query.filter(db.or_(db.and_(Officer.birth_year <= min_birth_year, - Officer.birth_year >= max_birth_year), - Officer.birth_year == None)) # noqa + min_birth_year = current_year - int(form_data["min_age"]) + max_birth_year = current_year - int(form_data["max_age"]) + officer_query = officer_query.filter( + db.or_( + db.and_( + Officer.birth_year <= min_birth_year, + Officer.birth_year >= max_birth_year, + ), + Officer.birth_year == None, # noqa: E711 + ) + ) job_ids = [] - if form_data.get('rank'): - job_ids = [job.id for job in Job.query.filter_by(department_id=department_id).filter(Job.job_title.in_(form_data.get("rank"))).all()] + if form_data.get("rank"): + job_ids = [ + job.id + for job in Job.query.filter_by(department_id=department_id) + .filter(Job.job_title.in_(form_data.get("rank"))) + .all() + ] print(form_data) - if 'Not Sure' in form_data['rank']: - form_data['rank'].append(None) + if "Not Sure" in form_data["rank"]: + form_data["rank"].append(None) - if form_data.get('badge') or form_data.get('unit') or job_ids: + if form_data.get("badge") or form_data.get("unit") or job_ids: officer_query = officer_query.join(Officer.assignments) - if form_data.get('badge'): + if form_data.get("badge"): officer_query = officer_query.filter( - Assignment.star_no.like('%%{}%%'.format(form_data['badge'])) + Assignment.star_no.like("%%{}%%".format(form_data["badge"])) ) - if form_data.get('unit'): + if form_data.get("unit"): officer_query = officer_query.filter( - Assignment.unit_id == form_data['unit'] + Assignment.unit_id == form_data["unit"] ) if job_ids: officer_query = officer_query.filter(Assignment.job_id.in_(job_ids)) - officer_query = ( - officer_query - .options(selectinload(Officer.assignments_lazy)).distinct() - ) + officer_query = officer_query.options( + selectinload(Officer.assignments_lazy) + ).distinct() return officer_query def filter_roster(form, officer_query): - if 'name' in form and form['name']: + if "name" in form and form["name"]: officer_query = officer_query.filter( - Officer.last_name.ilike('%%{}%%'.format(form['name'])) + Officer.last_name.ilike("%%{}%%".format(form["name"])) ) officer_query = officer_query.outerjoin(Assignment) - if 'badge' in form and form['badge']: + if "badge" in form and form["badge"]: officer_query = officer_query.filter( - cast(Assignment.star_no, db.String) - .like('%%{}%%'.format(form['badge'])) - ) - if 'dept' in form and form['dept']: - officer_query = officer_query.filter( - Officer.department_id == form['dept'].id + cast(Assignment.star_no, db.String).like("%%{}%%".format(form["badge"])) ) + if "dept" in form and form["dept"]: + officer_query = officer_query.filter(Officer.department_id == form["dept"].id) - officer_query = officer_query.outerjoin(Face) \ - .order_by(Face.officer_id.asc()) \ - .order_by(Officer.id.desc()) + officer_query = ( + officer_query.outerjoin(Face) + .order_by(Face.officer_id.asc()) + .order_by(Officer.id.desc()) + ) return officer_query @@ -352,16 +397,24 @@ def grab_officers(form): def compute_leaderboard_stats(select_top=25): - top_sorters = db.session.query(User, func.count(Image.user_id)) \ - .select_from(Image).join(User) \ - .group_by(User) \ - .order_by(func.count(Image.user_id).desc()) \ - .limit(select_top).all() - top_taggers = db.session.query(User, func.count(Face.user_id)) \ - .select_from(Face).join(User) \ - .group_by(User) \ - .order_by(func.count(Face.user_id).desc()) \ - .limit(select_top).all() + top_sorters = ( + db.session.query(User, func.count(Image.user_id)) + .select_from(Image) + .join(User) + .group_by(User) + .order_by(func.count(Image.user_id).desc()) + .limit(select_top) + .all() + ) + top_taggers = ( + db.session.query(User, func.count(Face.user_id)) + .select_from(Face) + .join(User) + .group_by(User) + .order_by(func.count(Face.user_id).desc()) + .limit(select_top) + .all() + ) return top_sorters, top_taggers @@ -375,25 +428,30 @@ def add_department_query(form, current_user): """Limits the departments available on forms for acs""" if not current_user.is_administrator: form.department.query = Department.query.filter_by( - id=current_user.ac_department_id) + id=current_user.ac_department_id + ) def add_unit_query(form, current_user): if not current_user.is_administrator: form.unit.query = Unit.query.filter_by( - department_id=current_user.ac_department_id).order_by(Unit.descrip.asc()) + department_id=current_user.ac_department_id + ).order_by(Unit.descrip.asc()) else: form.unit.query = Unit.query.order_by(Unit.descrip.asc()).all() def replace_list(items, obj, attr, model, db): - """Takes a list of items, and object, the attribute of that object that needs to be replaced, the model corresponding the items, and the db + """Set the objects attribute to the list of items received. + + This function take a list of items, an object, the attribute of that + object that needs to be replaced, the model corresponding the items, and the db. - Sets the objects attribute to the list of items received. DOES NOT SAVE TO DB. + DOES NOT SAVE TO DB. """ new_list = [] if not hasattr(obj, attr): - raise LookupError('The object does not have the {} attribute'.format(attr)) + raise LookupError("The object does not have the {} attribute".format(attr)) for item in items: new_item, _ = get_or_create(db.session, model, **item) @@ -403,54 +461,55 @@ def replace_list(items, obj, attr, model, db): def create_incident(self, form): fields = { - 'date': form.date_field.data, - 'time': form.time_field.data, - 'officers': [], - 'license_plates': [], - 'links': [], - 'address': '', - 'creator_id': form.creator_id.data, - 'last_updated_id': form.last_updated_id.data + "date": form.date_field.data, + "time": form.time_field.data, + "officers": [], + "license_plates": [], + "links": [], + "address": "", + "creator_id": form.creator_id.data, + "last_updated_id": form.last_updated_id.data, } - if 'address' in form.data: - address, _ = get_or_create(db.session, Location, **form.data['address']) - fields['address'] = address + if "address" in form.data: + address, _ = get_or_create(db.session, Location, **form.data["address"]) + fields["address"] = address - if 'officers' in form.data: - for officer in form.data['officers']: - if officer['oo_id']: + if "officers" in form.data: + for officer in form.data["officers"]: + if officer["oo_id"]: of, _ = get_or_create(db.session, Officer, **officer) if of: - fields['officers'].append(of) + fields["officers"].append(of) - if 'license_plates' in form.data: - for plate in form.data['license_plates']: - if plate['number']: + if "license_plates" in form.data: + for plate in form.data["license_plates"]: + if plate["number"]: pl, _ = get_or_create(db.session, LicensePlate, **plate) if pl: - fields['license_plates'].append(pl) + fields["license_plates"].append(pl) - if 'links' in form.data: - for link in form.data['links']: + if "links" in form.data: + for link in form.data["links"]: # don't try to create with a blank string - if link['url']: + if link["url"]: li, _ = get_or_create(db.session, Link, **link) if li: - fields['links'].append(li) + fields["links"].append(li) return Incident( - date=fields['date'], - time=fields['time'], - description=form.data['description'], - department=form.data['department'], - address=fields['address'], - officers=fields['officers'], - report_number=form.data['report_number'], - license_plates=fields['license_plates'], - links=fields['links'], - creator_id=fields['creator_id'], - last_updated_id=fields['last_updated_id']) + date=fields["date"], + time=fields["time"], + description=form.data["description"], + department=form.data["department"], + address=fields["address"], + officers=fields["officers"], + report_number=form.data["report_number"], + license_plates=fields["license_plates"], + links=fields["links"], + creator_id=fields["creator_id"], + last_updated_id=fields["last_updated_id"], + ) def create_note(self, form): @@ -459,7 +518,8 @@ def create_note(self, form): creator_id=form.creator_id.data, officer_id=form.officer_id.data, date_created=datetime.datetime.now(), - date_updated=datetime.datetime.now()) + date_updated=datetime.datetime.now(), + ) def create_description(self, form): @@ -468,26 +528,27 @@ def create_description(self, form): creator_id=form.creator_id.data, officer_id=form.officer_id.data, date_created=datetime.datetime.now(), - date_updated=datetime.datetime.now()) + date_updated=datetime.datetime.now(), + ) def crop_image(image, crop_data=None, department_id=None): - if 'http' in image.filepath: + if "http" in image.filepath: with urlopen(image.filepath) as response: image_buf = BytesIO(response.read()) else: - image_buf = open(os.path.abspath(current_app.root_path) + image.filepath, 'rb') + image_buf = open(os.path.abspath(current_app.root_path) + image.filepath, "rb") image_buf.seek(0) image_type = imghdr.what(image_buf) if not image_type: image_type = os.path.splitext(image.filepath)[1].lower()[1:] - if image_type in ('jp2', 'j2k', 'jpf', 'jpx', 'jpm', 'mj2'): - image_type = 'jpeg2000' - elif image_type in ('jpg', 'jpeg', 'jpe', 'jif', 'jfif', 'jfi'): - image_type = 'jpeg' - elif image_type in ('tif', 'tiff'): - image_type = 'tiff' + if image_type in ("jp2", "j2k", "jpf", "jpx", "jpm", "mj2"): + image_type = "jpeg2000" + elif image_type in ("jpg", "jpeg", "jpe", "jif", "jfif", "jfi"): + image_type = "jpeg" + elif image_type in ("tif", "tiff"): + image_type = "tiff" pimage = Pimage.open(image_buf) SIZE = 300, 300 @@ -503,7 +564,9 @@ def crop_image(image, crop_data=None, department_id=None): cropped_image_buf = BytesIO() pimage.save(cropped_image_buf, image_type) - return upload_image_to_s3_and_store_in_db(cropped_image_buf, current_user.get_id(), department_id) + return upload_image_to_s3_and_store_in_db( + cropped_image_buf, current_user.get_id(), department_id + ) def upload_image_to_s3_and_store_in_db(image_buf, user_id, department_id=None): @@ -514,13 +577,13 @@ def upload_image_to_s3_and_store_in_db(image_buf, user_id, department_id=None): """ image_buf.seek(0) image_type = imghdr.what(image_buf) - if image_type not in current_app.config['ALLOWED_EXTENSIONS']: - raise ValueError('Attempted to pass invalid data type: {}'.format(image_type)) + if image_type not in current_app.config["ALLOWED_EXTENSIONS"]: + raise ValueError("Attempted to pass invalid data type: {}".format(image_type)) image_buf.seek(0) pimage = Pimage.open(image_buf) date_taken = find_date_taken(pimage) if date_taken: - date_taken = datetime.datetime.strptime(date_taken, '%Y:%m:%d %H:%M:%S') + date_taken = datetime.datetime.strptime(date_taken, "%Y:%m:%d %H:%M:%S") pimage.getexif().clear() scrubbed_image_buf = BytesIO() pimage.save(scrubbed_image_buf, image_type) @@ -532,23 +595,26 @@ def upload_image_to_s3_and_store_in_db(image_buf, user_id, department_id=None): if existing_image: return existing_image try: - new_filename = '{}.{}'.format(hash_img, image_type) + new_filename = "{}.{}".format(hash_img, image_type) url = upload_obj_to_s3(scrubbed_image_buf, new_filename) - new_image = Image(filepath=url, hash_img=hash_img, - date_image_inserted=datetime.datetime.now(), - department_id=department_id, - date_image_taken=date_taken, - user_id=user_id - ) + new_image = Image( + filepath=url, + hash_img=hash_img, + date_image_inserted=datetime.datetime.now(), + department_id=department_id, + date_image_taken=date_taken, + user_id=user_id, + ) db.session.add(new_image) db.session.commit() return new_image except ClientError: exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error uploading to S3: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) + current_app.logger.error( + "Error uploading to S3: {}".format( + " ".join([str(exception_type), str(value), format_exc()]) + ) + ) return None @@ -556,7 +622,7 @@ def find_date_taken(pimage): if isinstance(pimage, PngImageFile): return None - exif = hasattr(pimage, '_getexif') and pimage._getexif() + exif = hasattr(pimage, "_getexif") and pimage._getexif() if exif: # 36867 in the exif tags holds the date and the original image was taken https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif.html if 36867 in exif: @@ -567,13 +633,13 @@ def find_date_taken(pimage): def get_officer(department_id, star_no, first_name, last_name): """ - Returns first officer with the given name and badge combo in the department, if they exist + Return the first officer with the given name and badge combo in the department, if one exists. If star_no is None, just return the first officer with the given first and last name. """ - officers = Officer.query.filter_by(department_id=department_id, - first_name=first_name, - last_name=last_name).all() + officers = Officer.query.filter_by( + department_id=department_id, first_name=first_name, last_name=last_name + ).all() if star_no is None: return officers[0] @@ -613,13 +679,12 @@ def prompt_yes_no(prompt, default="no"): while True: sys.stdout.write(prompt + yn) choice = input().lower() - if default is not None and choice == '': + if default is not None and choice == "": return strtobool(default) try: ret = strtobool(choice) except ValueError: - sys.stdout.write("Please respond with 'yes' or 'no' " - "(or 'y' or 'n').\n") + sys.stdout.write("Please respond with 'yes' or 'no' " "(or 'y' or 'n').\n") continue return ret diff --git a/OpenOversight/app/validators.py b/OpenOversight/app/validators.py index 5079421b3..a62c4856e 100644 --- a/OpenOversight/app/validators.py +++ b/OpenOversight/app/validators.py @@ -1,19 +1,20 @@ -from us import states from urllib.parse import urlparse +from us import states + def state_validator(state): list_of_states = [st.abbr for st in states.STATES] - if state not in list_of_states and state != 'DC' and state is not None: - raise ValueError('Not a valid US state') + if state not in list_of_states and state != "DC" and state is not None: + raise ValueError("Not a valid US state") return state def url_validator(url): parsed = urlparse(url) - if parsed.scheme not in ['http', 'https']: - raise ValueError('Not a valid URL') + if parsed.scheme not in ["http", "https"]: + raise ValueError("Not a valid URL") return url diff --git a/OpenOversight/app/widgets.py b/OpenOversight/app/widgets.py index f9d5d2a18..1c321e5f6 100644 --- a/OpenOversight/app/widgets.py +++ b/OpenOversight/app/widgets.py @@ -1,39 +1,50 @@ -from wtforms.widgets.core import ListWidget, html_params, HTMLString -from wtforms.fields import FormField from wtforms.compat import text_type +from wtforms.fields import FormField +from wtforms.widgets.core import HTMLString, ListWidget, html_params class BootstrapListWidget(ListWidget): - def __init__(self, html_tag='ul', prefix_label=True, classes='list-unstyled'): + def __init__(self, html_tag="ul", prefix_label=True, classes="list-unstyled"): super(BootstrapListWidget, self).__init__() self.classes = classes def __call__(self, field, **kwargs): - c = kwargs.pop('classes', '') or kwargs.pop('class_', '') - kwargs['class'] = u'%s %s' % (self.classes, c) - kwargs.setdefault('id', field.id) - html = ['<%s %s>' % (self.html_tag, html_params(**kwargs))] + c = kwargs.pop("classes", "") or kwargs.pop("class_", "") + kwargs["class"] = "%s %s" % (self.classes, c) + kwargs.setdefault("id", field.id) + html = ["<%s %s>" % (self.html_tag, html_params(**kwargs))] for subfield in field: if type(subfield) == FormField: - html.append('
  • %s
    %s
  • ' % (subfield.label.text, subfield())) + html.append( + "
  • %s
    %s
  • " % (subfield.label.text, subfield()) + ) if self.prefix_label: - html.append('
  • %s %s
  • ' % (subfield.label, subfield())) + html.append( + '
  • %s %s
  • ' + % (subfield.label, subfield()) + ) else: - html.append('
  • %s %s
    <
  • ' % (subfield(), subfield.label)) - html.append('' % self.html_tag) - return HTMLString(''.join(html)) + html.append( + '
  • %s %s
    <
  • ' + % (subfield(), subfield.label) + ) + html.append("" % self.html_tag) + return HTMLString("".join(html)) class FormFieldWidget(object): def __call__(self, field, **kwargs): html = [] - hidden = '' + hidden = "" for subfield in field: - if subfield.type == 'HiddenField' or subfield.type == 'CSRFTokenField': + if subfield.type == "HiddenField" or subfield.type == "CSRFTokenField": hidden += text_type(subfield) else: - html.append('
    %s %s %s
    ' % (text_type(subfield.label.text), hidden, text_type(subfield))) - hidden = '' + html.append( + '
    %s %s %s
    ' + % (text_type(subfield.label.text), hidden, text_type(subfield)) + ) + hidden = "" if hidden: html.append(hidden) - return HTMLString(''.join(html)) + return HTMLString("".join(html)) diff --git a/OpenOversight/migrations/README b/OpenOversight/migrations/README old mode 100755 new mode 100644 index 98e4f9c44..2500aa1bc --- a/OpenOversight/migrations/README +++ b/OpenOversight/migrations/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/OpenOversight/migrations/env.py b/OpenOversight/migrations/env.py old mode 100755 new mode 100644 index 9fc02f837..39858f045 --- a/OpenOversight/migrations/env.py +++ b/OpenOversight/migrations/env.py @@ -1,8 +1,11 @@ from __future__ import with_statement + +import logging +from logging.config import fileConfig + from alembic import context from sqlalchemy import engine_from_config, pool -from logging.config import fileConfig -import logging + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -11,16 +14,19 @@ # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') +logger = logging.getLogger("alembic.env") # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -from flask import current_app # noqa -config.set_main_option('sqlalchemy.url', - current_app.config.get('SQLALCHEMY_DATABASE_URI')) -target_metadata = current_app.extensions['migrate'].db.metadata +from flask import current_app # noqa: E402 + + +config.set_main_option( + "sqlalchemy.url", current_app.config.get("SQLALCHEMY_DATABASE_URI") +) +target_metadata = current_app.extensions["migrate"].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: @@ -59,22 +65,26 @@ def run_migrations_online(): # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): - if getattr(config.cmd_opts, 'autogenerate', False): + if getattr(config.cmd_opts, "autogenerate", False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] - logger.info('No changes in schema detected.') + logger.info("No changes in schema detected.") - engine = engine_from_config(config.get_section(config.config_ini_section), - prefix='sqlalchemy.', - poolclass=pool.NullPool) + engine = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) connection = engine.connect() - context.configure(connection=connection, - target_metadata=target_metadata, - compare_type=True, - process_revision_directives=process_revision_directives, - **current_app.extensions['migrate'].configure_args) + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + process_revision_directives=process_revision_directives, + **current_app.extensions["migrate"].configure_args, + ) try: with context.begin_transaction(): diff --git a/OpenOversight/migrations/script.py.mako b/OpenOversight/migrations/script.py.mako old mode 100755 new mode 100644 diff --git a/OpenOversight/migrations/versions/0acbb0f0b1ef_.py b/OpenOversight/migrations/versions/0acbb0f0b1ef_.py index 24031c411..5878d9b95 100644 --- a/OpenOversight/migrations/versions/0acbb0f0b1ef_.py +++ b/OpenOversight/migrations/versions/0acbb0f0b1ef_.py @@ -5,46 +5,65 @@ Create Date: 2018-05-03 15:00:36.849627 """ -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql + # revision identifiers, used by Alembic. -revision = '0acbb0f0b1ef' -down_revision = '0ed957db0058' +revision = "0acbb0f0b1ef" +down_revision = "0ed957db0058" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('assignments', 'resign_date', - existing_type=postgresql.TIMESTAMP(), - type_=sa.Date(), - existing_nullable=True) - op.alter_column('assignments', 'star_date', - existing_type=postgresql.TIMESTAMP(), - type_=sa.Date(), - existing_nullable=True) - op.alter_column('officers', 'employment_date', - existing_type=postgresql.TIMESTAMP(), - type_=sa.Date(), - existing_nullable=True) + op.alter_column( + "assignments", + "resign_date", + existing_type=postgresql.TIMESTAMP(), + type_=sa.Date(), + existing_nullable=True, + ) + op.alter_column( + "assignments", + "star_date", + existing_type=postgresql.TIMESTAMP(), + type_=sa.Date(), + existing_nullable=True, + ) + op.alter_column( + "officers", + "employment_date", + existing_type=postgresql.TIMESTAMP(), + type_=sa.Date(), + existing_nullable=True, + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('officers', 'employment_date', - existing_type=sa.Date(), - type_=postgresql.TIMESTAMP(), - existing_nullable=True) - op.alter_column('assignments', 'star_date', - existing_type=sa.Date(), - type_=postgresql.TIMESTAMP(), - existing_nullable=True) - op.alter_column('assignments', 'resign_date', - existing_type=sa.Date(), - type_=postgresql.TIMESTAMP(), - existing_nullable=True) + op.alter_column( + "officers", + "employment_date", + existing_type=sa.Date(), + type_=postgresql.TIMESTAMP(), + existing_nullable=True, + ) + op.alter_column( + "assignments", + "star_date", + existing_type=sa.Date(), + type_=postgresql.TIMESTAMP(), + existing_nullable=True, + ) + op.alter_column( + "assignments", + "resign_date", + existing_type=sa.Date(), + type_=postgresql.TIMESTAMP(), + existing_nullable=True, + ) # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/0ed957db0058_add_description_to_officers.py b/OpenOversight/migrations/versions/0ed957db0058_add_description_to_officers.py index d4f842f98..2ddfe3028 100644 --- a/OpenOversight/migrations/versions/0ed957db0058_add_description_to_officers.py +++ b/OpenOversight/migrations/versions/0ed957db0058_add_description_to_officers.py @@ -5,36 +5,37 @@ Create Date: 2018-08-11 20:31:02.265231 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '0ed957db0058' -down_revision = '2c27bfebe66e' +revision = "0ed957db0058" +down_revision = "2c27bfebe66e" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('descriptions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('text_contents', sa.Text(), nullable=True), - sa.Column('creator_id', sa.Integer(), nullable=True), - sa.Column('officer_id', sa.Integer(), nullable=True), - sa.Column('date_created', sa.DateTime(), nullable=True), - sa.Column('date_updated', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['officer_id'], ['officers.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.alter_column('notes', 'note', new_column_name='text_contents') + op.create_table( + "descriptions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("text_contents", sa.Text(), nullable=True), + sa.Column("creator_id", sa.Integer(), nullable=True), + sa.Column("officer_id", sa.Integer(), nullable=True), + sa.Column("date_created", sa.DateTime(), nullable=True), + sa.Column("date_updated", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["officer_id"], ["officers.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.alter_column("notes", "note", new_column_name="text_contents") # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('notes', 'text_contents', new_column_name='note') - op.drop_table('descriptions') + op.alter_column("notes", "text_contents", new_column_name="note") + op.drop_table("descriptions") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/114919b27a9f_.py b/OpenOversight/migrations/versions/114919b27a9f_.py index 1475b0b63..f43e7afbd 100644 --- a/OpenOversight/migrations/versions/114919b27a9f_.py +++ b/OpenOversight/migrations/versions/114919b27a9f_.py @@ -5,12 +5,12 @@ Create Date: 2017-12-10 05:20:45.748342 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '114919b27a9f' +revision = "114919b27a9f" down_revision = None branch_labels = None depends_on = None @@ -18,28 +18,33 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('migrate_version') - op.add_column('officers', sa.Column('department_id', sa.Integer(), nullable=True)) - op.drop_index('ix_officers_pd_id', table_name='officers') - op.create_foreign_key(None, 'officers', 'departments', ['department_id'], ['id']) - op.drop_column('officers', 'pd_id') - op.add_column('unit_types', sa.Column('department_id', sa.Integer(), nullable=True)) - op.create_foreign_key(None, 'unit_types', 'departments', ['department_id'], ['id']) + op.drop_table("migrate_version") + op.add_column("officers", sa.Column("department_id", sa.Integer(), nullable=True)) + op.drop_index("ix_officers_pd_id", table_name="officers") + op.create_foreign_key(None, "officers", "departments", ["department_id"], ["id"]) + op.drop_column("officers", "pd_id") + op.add_column("unit_types", sa.Column("department_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "unit_types", "departments", ["department_id"], ["id"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'unit_types', type_='foreignkey') - op.drop_column('unit_types', 'department_id') - op.add_column('officers', sa.Column('pd_id', sa.INTEGER(), autoincrement=False, nullable=True)) - op.drop_constraint(None, 'officers', type_='foreignkey') - op.create_index('ix_officers_pd_id', 'officers', ['pd_id'], unique=False) - op.drop_column('officers', 'department_id') - op.create_table('migrate_version', - sa.Column('repository_id', sa.VARCHAR(length=250), autoincrement=False, nullable=False), - sa.Column('repository_path', sa.TEXT(), autoincrement=False, nullable=True), - sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=True), - sa.PrimaryKeyConstraint('repository_id', name=u'migrate_version_pkey') - ) # noqa + op.drop_constraint(None, "unit_types", type_="foreignkey") + op.drop_column("unit_types", "department_id") + op.add_column( + "officers", sa.Column("pd_id", sa.INTEGER(), autoincrement=False, nullable=True) + ) + op.drop_constraint(None, "officers", type_="foreignkey") + op.create_index("ix_officers_pd_id", "officers", ["pd_id"], unique=False) + op.drop_column("officers", "department_id") + op.create_table( + "migrate_version", + sa.Column( + "repository_id", sa.VARCHAR(length=250), autoincrement=False, nullable=False + ), + sa.Column("repository_path", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("version", sa.INTEGER(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint("repository_id", name="migrate_version_pkey"), + ) # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/2040f0c804b0_.py b/OpenOversight/migrations/versions/2040f0c804b0_.py index b40fd7bb6..5e905adad 100644 --- a/OpenOversight/migrations/versions/2040f0c804b0_.py +++ b/OpenOversight/migrations/versions/2040f0c804b0_.py @@ -5,13 +5,13 @@ Create Date: 2018-06-06 19:34:16.439093 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '2040f0c804b0' -down_revision = 'bd0398fe4aab' +revision = "2040f0c804b0" +down_revision = "bd0398fe4aab" branch_labels = None depends_on = None @@ -19,21 +19,27 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table( - 'notes', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('note', sa.Text(), nullable=True), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('officer_id', sa.Integer(), nullable=True), - sa.Column('date_created', sa.DateTime(), nullable=True), - sa.Column('date_updated', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['officer_id'], ['officers.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') + "notes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("officer_id", sa.Integer(), nullable=True), + sa.Column("date_created", sa.DateTime(), nullable=True), + sa.Column("date_updated", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["officer_id"], + ["officers.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('notes') + op.drop_table("notes") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/2a9064a2507c_remove_dots_middle_initial.py b/OpenOversight/migrations/versions/2a9064a2507c_remove_dots_middle_initial.py index b0edf5726..deb745884 100644 --- a/OpenOversight/migrations/versions/2a9064a2507c_remove_dots_middle_initial.py +++ b/OpenOversight/migrations/versions/2a9064a2507c_remove_dots_middle_initial.py @@ -5,17 +5,20 @@ Create Date: 2019-02-03 05:33:05.296642 """ -from flask import current_app import os import sys + +from flask import current_app + + # Add our Flask app to the search paths for modules sys.path.insert(0, os.path.dirname(current_app.root_path)) from app.models import Officer, db # noqa: E402 # revision identifiers, used by Alembic. -revision = '2a9064a2507c' -down_revision = '5c5b80cab45e' +revision = "2a9064a2507c" +down_revision = "5c5b80cab45e" branch_labels = None depends_on = None diff --git a/OpenOversight/migrations/versions/2c27bfebe66e_add_unique_internal_identifier_to_.py b/OpenOversight/migrations/versions/2c27bfebe66e_add_unique_internal_identifier_to_.py index 8b20845b8..add940679 100644 --- a/OpenOversight/migrations/versions/2c27bfebe66e_add_unique_internal_identifier_to_.py +++ b/OpenOversight/migrations/versions/2c27bfebe66e_add_unique_internal_identifier_to_.py @@ -5,26 +5,34 @@ Create Date: 2018-08-18 13:36:00.987327 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '2c27bfebe66e' -down_revision = '7bb53dee8ac9' +revision = "2c27bfebe66e" +down_revision = "7bb53dee8ac9" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('officers', sa.Column('unique_internal_identifier', sa.String(length=50), nullable=True)) - op.create_index(op.f('ix_officers_unique_internal_identifier'), 'officers', ['unique_internal_identifier'], unique=True) + op.add_column( + "officers", + sa.Column("unique_internal_identifier", sa.String(length=50), nullable=True), + ) + op.create_index( + op.f("ix_officers_unique_internal_identifier"), + "officers", + ["unique_internal_identifier"], + unique=True, + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_officers_unique_internal_identifier'), table_name='officers') - op.drop_column('officers', 'unique_internal_identifier') + op.drop_index(op.f("ix_officers_unique_internal_identifier"), table_name="officers") + op.drop_column("officers", "unique_internal_identifier") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/3015d1dd9eb4_add_jobs_table.py b/OpenOversight/migrations/versions/3015d1dd9eb4_add_jobs_table.py index cf03cc4de..54e414e25 100644 --- a/OpenOversight/migrations/versions/3015d1dd9eb4_add_jobs_table.py +++ b/OpenOversight/migrations/versions/3015d1dd9eb4_add_jobs_table.py @@ -5,13 +5,13 @@ Create Date: 2019-04-20 17:54:41.661851 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '3015d1dd9eb4' -down_revision = 'c1fc26073f85' +revision = "3015d1dd9eb4" +down_revision = "c1fc26073f85" branch_labels = None depends_on = None @@ -19,20 +19,31 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table( - 'jobs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('job_title', sa.String(length=255), nullable=False), - sa.Column('is_sworn_officer', sa.Boolean(), nullable=True), - sa.Column('order', sa.Integer(), nullable=True), - sa.Column('department_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('job_title', 'department_id', name='unique_department_job_titles'), - sa.UniqueConstraint('order', 'department_id', name='unique_department_job_order')) - op.create_index(op.f('ix_jobs_is_sworn_officer'), 'jobs', ['is_sworn_officer'], unique=False) - op.create_index(op.f('ix_jobs_job_title'), 'jobs', ['job_title'], unique=False) - op.create_index(op.f('ix_jobs_order'), 'jobs', ['order'], unique=False) - op.execute(""" + "jobs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("job_title", sa.String(length=255), nullable=False), + sa.Column("is_sworn_officer", sa.Boolean(), nullable=True), + sa.Column("order", sa.Integer(), nullable=True), + sa.Column("department_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["department_id"], + ["departments.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "job_title", "department_id", name="unique_department_job_titles" + ), + sa.UniqueConstraint( + "order", "department_id", name="unique_department_job_order" + ), + ) + op.create_index( + op.f("ix_jobs_is_sworn_officer"), "jobs", ["is_sworn_officer"], unique=False + ) + op.create_index(op.f("ix_jobs_job_title"), "jobs", ["job_title"], unique=False) + op.create_index(op.f("ix_jobs_order"), "jobs", ["order"], unique=False) + op.execute( + """ INSERT INTO jobs (job_title, is_sworn_officer, department_id) SELECT DISTINCT @@ -41,10 +52,14 @@ def upgrade(): officers.department_id FROM assignments - INNER JOIN officers ON assignments.officer_id = officers.id""") - op.add_column('assignments', sa.Column('job_id', sa.Integer(), nullable=True)) - op.create_foreign_key('fk_job_assignment', 'assignments', 'jobs', ['job_id'], ['id']) - op.execute(""" + INNER JOIN officers ON assignments.officer_id = officers.id""" + ) + op.add_column("assignments", sa.Column("job_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_job_assignment", "assignments", "jobs", ["job_id"], ["id"] + ) + op.execute( + """ UPDATE assignments SET @@ -54,19 +69,24 @@ def upgrade(): INNER JOIN jobs ON jobs.department_id = officers.department_id WHERE assignments.rank = jobs.job_title - AND assignments.officer_id = officers.id""") - op.drop_index('ix_assignments_rank', table_name='assignments') - op.drop_column('assignments', 'rank') - op.alter_column('assignments', 'unit', new_column_name='unit_id') + AND assignments.officer_id = officers.id""" + ) + op.drop_index("ix_assignments_rank", table_name="assignments") + op.drop_column("assignments", "rank") + op.alter_column("assignments", "unit", new_column_name="unit_id") # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('assignments', 'unit_id', new_column_name='unit') - op.add_column('assignments', sa.Column('rank', sa.VARCHAR(length=120), autoincrement=False, nullable=True)) - op.create_index('ix_assignments_rank', 'assignments', ['rank'], unique=False) - op.execute(""" + op.alter_column("assignments", "unit_id", new_column_name="unit") + op.add_column( + "assignments", + sa.Column("rank", sa.VARCHAR(length=120), autoincrement=False, nullable=True), + ) + op.create_index("ix_assignments_rank", "assignments", ["rank"], unique=False) + op.execute( + """ UPDATE assignments SET @@ -74,11 +94,12 @@ def downgrade(): FROM jobs WHERE - assignments.job_id = jobs.id""") - op.drop_constraint('fk_job_assignment', 'assignments', type_='foreignkey') - op.drop_column('assignments', 'job_id') - op.drop_index(op.f('ix_jobs_order'), table_name='jobs') - op.drop_index(op.f('ix_jobs_job_title'), table_name='jobs') - op.drop_index(op.f('ix_jobs_is_sworn_officer'), table_name='jobs') - op.drop_table('jobs') + assignments.job_id = jobs.id""" + ) + op.drop_constraint("fk_job_assignment", "assignments", type_="foreignkey") + op.drop_column("assignments", "job_id") + op.drop_index(op.f("ix_jobs_order"), table_name="jobs") + op.drop_index(op.f("ix_jobs_job_title"), table_name="jobs") + op.drop_index(op.f("ix_jobs_is_sworn_officer"), table_name="jobs") + op.drop_table("jobs") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/42233d18ac7b_.py b/OpenOversight/migrations/versions/42233d18ac7b_.py index c9d3b8396..cced08c05 100644 --- a/OpenOversight/migrations/versions/42233d18ac7b_.py +++ b/OpenOversight/migrations/versions/42233d18ac7b_.py @@ -5,30 +5,36 @@ Create Date: 2017-12-23 18:03:07.304611 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '42233d18ac7b' -down_revision = '114919b27a9f' +revision = "42233d18ac7b" +down_revision = "114919b27a9f" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('assignments', 'star_no', - existing_type=sa.INTEGER(), - type_=sa.String(length=120), - existing_nullable=True) + op.alter_column( + "assignments", + "star_no", + existing_type=sa.INTEGER(), + type_=sa.String(length=120), + existing_nullable=True, + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('assignments', 'star_no', - existing_type=sa.String(length=120), - type_=sa.INTEGER(), - existing_nullable=True) + op.alter_column( + "assignments", + "star_no", + existing_type=sa.String(length=120), + type_=sa.INTEGER(), + existing_nullable=True, + ) # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/4a490771dda1_add_original_image_fk.py b/OpenOversight/migrations/versions/4a490771dda1_add_original_image_fk.py index dd20efcee..b31e51917 100644 --- a/OpenOversight/migrations/versions/4a490771dda1_add_original_image_fk.py +++ b/OpenOversight/migrations/versions/4a490771dda1_add_original_image_fk.py @@ -5,30 +5,52 @@ Create Date: 2018-06-07 15:32:25.524117 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '4a490771dda1' -down_revision = '8ce7926aa132' +revision = "4a490771dda1" +down_revision = "8ce7926aa132" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('faces', sa.Column('fk_face_original_image_id', sa.Integer(), nullable=True)) - op.drop_constraint(u'faces_img_id_fkey', 'faces', type_='foreignkey') - op.create_foreign_key('fk_face_image_id', 'faces', 'raw_images', ['img_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE', use_alter=True) - op.create_foreign_key(None, 'faces', 'raw_images', ['fk_face_original_image_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL', use_alter=True) + op.add_column( + "faces", sa.Column("fk_face_original_image_id", sa.Integer(), nullable=True) + ) + op.drop_constraint("faces_img_id_fkey", "faces", type_="foreignkey") + op.create_foreign_key( + "fk_face_image_id", + "faces", + "raw_images", + ["img_id"], + ["id"], + onupdate="CASCADE", + ondelete="CASCADE", + use_alter=True, + ) + op.create_foreign_key( + None, + "faces", + "raw_images", + ["fk_face_original_image_id"], + ["id"], + onupdate="CASCADE", + ondelete="SET NULL", + use_alter=True, + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'faces', type_='foreignkey') - op.drop_constraint('fk_face_image_id', 'faces', type_='foreignkey') - op.create_foreign_key(u'faces_img_id_fkey', 'faces', 'raw_images', ['img_id'], ['id']) - op.drop_column('faces', 'fk_face_original_image_id') + op.drop_constraint(None, "faces", type_="foreignkey") + op.drop_constraint("fk_face_image_id", "faces", type_="foreignkey") + op.create_foreign_key( + "faces_img_id_fkey", "faces", "raw_images", ["img_id"], ["id"] + ) + op.drop_column("faces", "fk_face_original_image_id") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/562bd5f1bc1f_add_order_to_jobs.py b/OpenOversight/migrations/versions/562bd5f1bc1f_add_order_to_jobs.py index a679b2b68..818ffff5f 100644 --- a/OpenOversight/migrations/versions/562bd5f1bc1f_add_order_to_jobs.py +++ b/OpenOversight/migrations/versions/562bd5f1bc1f_add_order_to_jobs.py @@ -5,41 +5,42 @@ Create Date: 2020-04-24 01:58:05.146902 """ -from alembic import op import sqlalchemy as sa -from sqlalchemy.sql import table, column +from alembic import op +from sqlalchemy.sql import column, table + # revision identifiers, used by Alembic. -revision = '562bd5f1bc1f' -down_revision = '6045f42587ec' +revision = "562bd5f1bc1f" +down_revision = "6045f42587ec" branch_labels = None depends_on = None def upgrade(): connection = op.get_bind() - job = table('jobs', - column('department_id', sa.Integer), - column('order', sa.Integer)) + job = table( + "jobs", column("department_id", sa.Integer), column("order", sa.Integer) + ) - department = table('departments', - column('id', sa.Integer), - column('name', sa.String)) + department = table( + "departments", column("id", sa.Integer), column("name", sa.String) + ) - op.drop_constraint('unique_department_job_order', 'jobs', type_='unique') - op.drop_index(op.f('ix_jobs_order'), table_name='jobs') + op.drop_constraint("unique_department_job_order", "jobs", type_="unique") + op.drop_index(op.f("ix_jobs_order"), table_name="jobs") all_depts = connection.execute(department.select()).fetchall() for dept in all_depts: job_order = 0 - connection.execute(job.update().values({"order": job_order}).where(sa.and_(job.c.department_id == dept.id, job.c.order.is_(None)))) + connection.execute( + job.update() + .values({"order": job_order}) + .where(sa.and_(job.c.department_id == dept.id, job.c.order.is_(None))) + ) - op.alter_column('jobs', 'order', - existing_type=sa.INTEGER(), - nullable=False) + op.alter_column("jobs", "order", existing_type=sa.INTEGER(), nullable=False) def downgrade(): - op.alter_column('jobs', 'order', - existing_type=sa.INTEGER(), - nullable=True) + op.alter_column("jobs", "order", existing_type=sa.INTEGER(), nullable=True) diff --git a/OpenOversight/migrations/versions/59e9993c169c_change_faces_to_thumbnails.py b/OpenOversight/migrations/versions/59e9993c169c_change_faces_to_thumbnails.py index 1a8f97d36..743caed9d 100644 --- a/OpenOversight/migrations/versions/59e9993c169c_change_faces_to_thumbnails.py +++ b/OpenOversight/migrations/versions/59e9993c169c_change_faces_to_thumbnails.py @@ -5,9 +5,12 @@ Create Date: 2018-06-04 19:04:23.524079 """ -from flask import current_app import os import sys + +from flask import current_app + + # Add our Flask app to the search paths for modules sys.path.insert(0, os.path.dirname(current_app.root_path)) @@ -16,8 +19,8 @@ # revision identifiers, used by Alembic. -revision = '59e9993c169c' -down_revision = '4a490771dda1' +revision = "59e9993c169c" +down_revision = "4a490771dda1" branch_labels = None depends_on = None @@ -25,8 +28,7 @@ def upgrade(): try: for face in Face.query.all(): - if face.face_position_x \ - and face.image.filepath.split('/')[0] != 'static': + if face.face_position_x and face.image.filepath.split("/")[0] != "static": left = face.face_position_x upper = face.face_position_y right = left + face.face_width @@ -42,7 +44,8 @@ def upgrade(): face_position_y=face.face_position_y, face_height=face.face_height, face_width=face.face_width, - user_id=face.user_id) + user_id=face.user_id, + ) db.session.add(cropped_image) db.session.add(new_face) diff --git a/OpenOversight/migrations/versions/5c5b80cab45e_add_approved.py b/OpenOversight/migrations/versions/5c5b80cab45e_add_approved.py index df9a07ef0..35e38332c 100644 --- a/OpenOversight/migrations/versions/5c5b80cab45e_add_approved.py +++ b/OpenOversight/migrations/versions/5c5b80cab45e_add_approved.py @@ -5,25 +5,25 @@ Create Date: 2019-01-24 15:54:08.123125 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '5c5b80cab45e' -down_revision = 'e2c2efde8b55' +revision = "5c5b80cab45e" +down_revision = "e2c2efde8b55" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('approved', sa.Boolean(), default=False)) + op.add_column("users", sa.Column("approved", sa.Boolean(), default=False)) op.execute("UPDATE users SET approved=True") # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('users', 'approved') + op.drop_column("users", "approved") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/6045f42587ec_split_apart_date_and_time_in_incidents.py b/OpenOversight/migrations/versions/6045f42587ec_split_apart_date_and_time_in_incidents.py index 6cbeb08a3..cb1f7d682 100644 --- a/OpenOversight/migrations/versions/6045f42587ec_split_apart_date_and_time_in_incidents.py +++ b/OpenOversight/migrations/versions/6045f42587ec_split_apart_date_and_time_in_incidents.py @@ -5,38 +5,49 @@ Create Date: 2019-05-04 05:28:06.869101 """ -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql + # revision identifiers, used by Alembic. -revision = '6045f42587ec' -down_revision = '8ce3de7679c2' +revision = "6045f42587ec" +down_revision = "8ce3de7679c2" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('incidents', sa.Column('time', sa.Time(), nullable=True)) - op.execute('UPDATE incidents SET time = date::time') - op.alter_column('incidents', 'date', - existing_type=postgresql.TIMESTAMP(), - type_=sa.Date(), - existing_nullable=True) - op.create_index(op.f('ix_incidents_time'), 'incidents', ['time'], unique=False) - op.execute('UPDATE incidents SET "time" = NULL WHERE "time" = \'01:02:03.045678\'::time') + op.add_column("incidents", sa.Column("time", sa.Time(), nullable=True)) + op.execute("UPDATE incidents SET time = date::time") + op.alter_column( + "incidents", + "date", + existing_type=postgresql.TIMESTAMP(), + type_=sa.Date(), + existing_nullable=True, + ) + op.create_index(op.f("ix_incidents_time"), "incidents", ["time"], unique=False) + op.execute( + 'UPDATE incidents SET "time" = NULL WHERE "time" = \'01:02:03.045678\'::time' + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.execute('UPDATE incidents SET "time" = \'01:02:03.045678\'::time WHERE time IS NULL') - op.drop_index(op.f('ix_incidents_time'), table_name='incidents') - op.alter_column('incidents', 'date', - existing_type=sa.Date(), - type_=postgresql.TIMESTAMP(), - existing_nullable=True) - op.execute('UPDATE incidents SET date = date + time') - op.drop_column('incidents', 'time') + op.execute( + "UPDATE incidents SET \"time\" = '01:02:03.045678'::time WHERE time IS NULL" + ) + op.drop_index(op.f("ix_incidents_time"), table_name="incidents") + op.alter_column( + "incidents", + "date", + existing_type=sa.Date(), + type_=postgresql.TIMESTAMP(), + existing_nullable=True, + ) + op.execute("UPDATE incidents SET date = date + time") + op.drop_column("incidents", "time") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/6065d7cdcbf8_.py b/OpenOversight/migrations/versions/6065d7cdcbf8_.py index d134bc6de..1b53315ac 100644 --- a/OpenOversight/migrations/versions/6065d7cdcbf8_.py +++ b/OpenOversight/migrations/versions/6065d7cdcbf8_.py @@ -5,13 +5,13 @@ Create Date: 2018-05-04 21:05:42.060165 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '6065d7cdcbf8' -down_revision = 'd86feb8fa5d1' +revision = "6065d7cdcbf8" +down_revision = "d86feb8fa5d1" branch_labels = None depends_on = None @@ -19,25 +19,49 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table( - 'incident_officers', - sa.Column('incident_id', sa.Integer(), nullable=False), - sa.Column('officers_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['incident_id'], ['incidents.id'], ), - sa.ForeignKeyConstraint(['officers_id'], ['officers.id'], ), - sa.PrimaryKeyConstraint('incident_id', 'officers_id') + "incident_officers", + sa.Column("incident_id", sa.Integer(), nullable=False), + sa.Column("officers_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["incident_id"], + ["incidents.id"], + ), + sa.ForeignKeyConstraint( + ["officers_id"], + ["officers.id"], + ), + sa.PrimaryKeyConstraint("incident_id", "officers_id"), + ) + op.add_column( + "incident_license_plates", + sa.Column("license_plate_id", sa.Integer(), nullable=False), + ) + op.drop_constraint( + "incident_license_plates_link_id_fkey", + "incident_license_plates", + type_="foreignkey", ) - op.add_column(u'incident_license_plates', sa.Column('license_plate_id', sa.Integer(), nullable=False)) - op.drop_constraint(u'incident_license_plates_link_id_fkey', 'incident_license_plates', type_='foreignkey') - op.create_foreign_key(None, 'incident_license_plates', 'license_plates', ['license_plate_id'], ['id']) - op.drop_column(u'incident_license_plates', 'link_id') + op.create_foreign_key( + None, "incident_license_plates", "license_plates", ["license_plate_id"], ["id"] + ) + op.drop_column("incident_license_plates", "link_id") # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column(u'incident_license_plates', sa.Column('link_id', sa.INTEGER(), autoincrement=False, nullable=False)) - op.drop_constraint(None, 'incident_license_plates', type_='foreignkey') - op.create_foreign_key(u'incident_license_plates_link_id_fkey', 'incident_license_plates', 'license_plates', ['link_id'], ['id']) - op.drop_column(u'incident_license_plates', 'license_plate_id') - op.drop_table('incident_officers') + op.add_column( + "incident_license_plates", + sa.Column("link_id", sa.INTEGER(), autoincrement=False, nullable=False), + ) + op.drop_constraint(None, "incident_license_plates", type_="foreignkey") + op.create_foreign_key( + "incident_license_plates_link_id_fkey", + "incident_license_plates", + "license_plates", + ["link_id"], + ["id"], + ) + op.drop_column("incident_license_plates", "license_plate_id") + op.drop_table("incident_officers") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/770ed51b4e16_add_salaries_table.py b/OpenOversight/migrations/versions/770ed51b4e16_add_salaries_table.py index bfe0c7816..30cd809f7 100644 --- a/OpenOversight/migrations/versions/770ed51b4e16_add_salaries_table.py +++ b/OpenOversight/migrations/versions/770ed51b4e16_add_salaries_table.py @@ -5,13 +5,13 @@ Create Date: 2019-01-25 15:47:13.812837 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '770ed51b4e16' -down_revision = '2a9064a2507c' +revision = "770ed51b4e16" +down_revision = "2a9064a2507c" branch_labels = None depends_on = None @@ -19,26 +19,28 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table( - 'salaries', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('officer_id', sa.Integer(), nullable=False), - sa.Column('salary', sa.Numeric(), nullable=False), - sa.Column('overtime_pay', sa.Numeric(), nullable=True), - sa.Column('year', sa.Integer(), nullable=False), - sa.Column('is_fiscal_year', sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint(['officer_id'], ['officers.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') + "salaries", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("officer_id", sa.Integer(), nullable=False), + sa.Column("salary", sa.Numeric(), nullable=False), + sa.Column("overtime_pay", sa.Numeric(), nullable=True), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("is_fiscal_year", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["officer_id"], ["officers.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_salary_overtime_pay"), "salaries", ["overtime_pay"], unique=False ) - op.create_index(op.f('ix_salary_overtime_pay'), 'salaries', ['overtime_pay'], unique=False) - op.create_index(op.f('ix_salary_salary'), 'salaries', ['salary'], unique=False) - op.create_index(op.f('ix_salary_year'), 'salaries', ['year'], unique=False) + op.create_index(op.f("ix_salary_salary"), "salaries", ["salary"], unique=False) + op.create_index(op.f("ix_salary_year"), "salaries", ["year"], unique=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_salary_year'), table_name='salaries') - op.drop_index(op.f('ix_salary_salary'), table_name='salaries') - op.drop_index(op.f('ix_salary_overtime_pay'), table_name='salaries') - op.drop_table('salaries') + op.drop_index(op.f("ix_salary_year"), table_name="salaries") + op.drop_index(op.f("ix_salary_salary"), table_name="salaries") + op.drop_index(op.f("ix_salary_overtime_pay"), table_name="salaries") + op.drop_table("salaries") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/79a14685454f_add_featured_flag_to_faces_table.py b/OpenOversight/migrations/versions/79a14685454f_add_featured_flag_to_faces_table.py index fd52cb2db..4b85901ae 100644 --- a/OpenOversight/migrations/versions/79a14685454f_add_featured_flag_to_faces_table.py +++ b/OpenOversight/migrations/versions/79a14685454f_add_featured_flag_to_faces_table.py @@ -5,24 +5,27 @@ Create Date: 2020-07-31 14:03:59.871182 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '79a14685454f' -down_revision = '562bd5f1bc1f' +revision = "79a14685454f" +down_revision = "562bd5f1bc1f" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('faces', sa.Column('featured', sa.Boolean(), server_default='false', nullable=False)) + op.add_column( + "faces", + sa.Column("featured", sa.Boolean(), server_default="false", nullable=False), + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('faces', 'featured') + op.drop_column("faces", "featured") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/7bb53dee8ac9_add_suffix_column_to_officers.py b/OpenOversight/migrations/versions/7bb53dee8ac9_add_suffix_column_to_officers.py index 8c6cf1ffa..794ce81f5 100644 --- a/OpenOversight/migrations/versions/7bb53dee8ac9_add_suffix_column_to_officers.py +++ b/OpenOversight/migrations/versions/7bb53dee8ac9_add_suffix_column_to_officers.py @@ -5,26 +5,26 @@ Create Date: 2018-07-26 18:36:10.061968 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '7bb53dee8ac9' -down_revision = '59e9993c169c' +revision = "7bb53dee8ac9" +down_revision = "59e9993c169c" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('officers', sa.Column('suffix', sa.String(length=120), nullable=True)) - op.create_index(op.f('ix_officers_suffix'), 'officers', ['suffix'], unique=False) + op.add_column("officers", sa.Column("suffix", sa.String(length=120), nullable=True)) + op.create_index(op.f("ix_officers_suffix"), "officers", ["suffix"], unique=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_officers_suffix'), table_name='officers') - op.drop_column('officers', 'suffix') + op.drop_index(op.f("ix_officers_suffix"), table_name="officers") + op.drop_column("officers", "suffix") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/86eb228e4bc0_refactor_links.py b/OpenOversight/migrations/versions/86eb228e4bc0_refactor_links.py index 5f561fdc3..dc010abf1 100644 --- a/OpenOversight/migrations/versions/86eb228e4bc0_refactor_links.py +++ b/OpenOversight/migrations/versions/86eb228e4bc0_refactor_links.py @@ -7,24 +7,32 @@ """ from alembic import op + # revision identifiers, used by Alembic. -revision = '86eb228e4bc0' -down_revision = '79a14685454f' +revision = "86eb228e4bc0" +down_revision = "79a14685454f" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint('links_user_id_fkey', 'links', type_='foreignkey') - op.alter_column('links', 'user_id', new_column_name='creator_id') - op.create_foreign_key('links_creator_id_fkey', 'links', 'users', ['creator_id'], ['id'], ondelete='SET NULL') + op.drop_constraint("links_user_id_fkey", "links", type_="foreignkey") + op.alter_column("links", "user_id", new_column_name="creator_id") + op.create_foreign_key( + "links_creator_id_fkey", + "links", + "users", + ["creator_id"], + ["id"], + ondelete="SET NULL", + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint('links_creator_id_fkey', 'links', type_='foreignkey') - op.alter_column('links', 'creator_id', new_column_name='user_id') - op.create_foreign_key('links_user_id_fkey', 'links', 'users', ['user_id'], ['id']) + op.drop_constraint("links_creator_id_fkey", "links", type_="foreignkey") + op.alter_column("links", "creator_id", new_column_name="user_id") + op.create_foreign_key("links_user_id_fkey", "links", "users", ["user_id"], ["id"]) # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/8ce3de7679c2_add_unique_internal_identifier_label.py b/OpenOversight/migrations/versions/8ce3de7679c2_add_unique_internal_identifier_label.py index fec4948c0..361e14c85 100644 --- a/OpenOversight/migrations/versions/8ce3de7679c2_add_unique_internal_identifier_label.py +++ b/OpenOversight/migrations/versions/8ce3de7679c2_add_unique_internal_identifier_label.py @@ -5,24 +5,29 @@ Create Date: 2019-04-17 18:26:35.733783 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = '8ce3de7679c2' -down_revision = '3015d1dd9eb4' +revision = "8ce3de7679c2" +down_revision = "3015d1dd9eb4" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('departments', sa.Column('unique_internal_identifier_label', sa.String(length=100), nullable=True)) + op.add_column( + "departments", + sa.Column( + "unique_internal_identifier_label", sa.String(length=100), nullable=True + ), + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('departments', 'unique_internal_identifier_label') + op.drop_column("departments", "unique_internal_identifier_label") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/8ce7926aa132_.py b/OpenOversight/migrations/versions/8ce7926aa132_.py index 25aab0c97..a8c7e106f 100644 --- a/OpenOversight/migrations/versions/8ce7926aa132_.py +++ b/OpenOversight/migrations/versions/8ce7926aa132_.py @@ -9,25 +9,33 @@ # revision identifiers, used by Alembic. -revision = '8ce7926aa132' -down_revision = 'cfc5f3fd5efe' +revision = "8ce7926aa132" +down_revision = "cfc5f3fd5efe" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(u'notes_officer_id_fkey', 'notes', type_='foreignkey') - op.drop_constraint(u'notes_creator_id_fkey', 'notes', type_='foreignkey') - op.create_foreign_key(None, 'notes', 'officers', ['officer_id'], ['id'], ondelete='CASCADE') - op.create_foreign_key(None, 'notes', 'users', ['creator_id'], ['id'], ondelete='SET NULL') + op.drop_constraint("notes_officer_id_fkey", "notes", type_="foreignkey") + op.drop_constraint("notes_creator_id_fkey", "notes", type_="foreignkey") + op.create_foreign_key( + None, "notes", "officers", ["officer_id"], ["id"], ondelete="CASCADE" + ) + op.create_foreign_key( + None, "notes", "users", ["creator_id"], ["id"], ondelete="SET NULL" + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'notes', type_='foreignkey') - op.drop_constraint(None, 'notes', type_='foreignkey') - op.create_foreign_key(u'notes_creator_id_fkey', 'notes', 'users', ['creator_id'], ['id']) - op.create_foreign_key(u'notes_officer_id_fkey', 'notes', 'officers', ['officer_id'], ['id']) + op.drop_constraint(None, "notes", type_="foreignkey") + op.drop_constraint(None, "notes", type_="foreignkey") + op.create_foreign_key( + "notes_creator_id_fkey", "notes", "users", ["creator_id"], ["id"] + ) + op.create_foreign_key( + "notes_officer_id_fkey", "notes", "officers", ["officer_id"], ["id"] + ) # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/93fc3e074dcc_remove_link_length_cap.py b/OpenOversight/migrations/versions/93fc3e074dcc_remove_link_length_cap.py index 611ec9c8c..b9e3c145c 100644 --- a/OpenOversight/migrations/versions/93fc3e074dcc_remove_link_length_cap.py +++ b/OpenOversight/migrations/versions/93fc3e074dcc_remove_link_length_cap.py @@ -5,8 +5,8 @@ Create Date: 2022-01-05 06:50:40.070530 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. diff --git a/OpenOversight/migrations/versions/9e2827dae28c_.py b/OpenOversight/migrations/versions/9e2827dae28c_.py index 21a4e7cd9..e40ce1e3b 100644 --- a/OpenOversight/migrations/versions/9e2827dae28c_.py +++ b/OpenOversight/migrations/versions/9e2827dae28c_.py @@ -7,22 +7,27 @@ """ from alembic import op + # revision identifiers, used by Alembic. -revision = '9e2827dae28c' -down_revision = '0acbb0f0b1ef' +revision = "9e2827dae28c" +down_revision = "0acbb0f0b1ef" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(u'assignments_officer_id_fkey', 'assignments', type_='foreignkey') - op.create_foreign_key(None, 'assignments', 'officers', ['officer_id'], ['id'], ondelete='CASCADE') + op.drop_constraint("assignments_officer_id_fkey", "assignments", type_="foreignkey") + op.create_foreign_key( + None, "assignments", "officers", ["officer_id"], ["id"], ondelete="CASCADE" + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'assignments', type_='foreignkey') - op.create_foreign_key(u'assignments_officer_id_fkey', 'assignments', 'officers', ['officer_id'], ['id']) + op.drop_constraint(None, "assignments", type_="foreignkey") + op.create_foreign_key( + "assignments_officer_id_fkey", "assignments", "officers", ["officer_id"], ["id"] + ) # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/af933dc1ef93_.py b/OpenOversight/migrations/versions/af933dc1ef93_.py index 01b5f2ea1..fff6fdbd1 100644 --- a/OpenOversight/migrations/versions/af933dc1ef93_.py +++ b/OpenOversight/migrations/versions/af933dc1ef93_.py @@ -5,26 +5,26 @@ Create Date: 2018-04-12 15:41:33.490603 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = 'af933dc1ef93' -down_revision = '42233d18ac7b' +revision = "af933dc1ef93" +down_revision = "42233d18ac7b" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('dept_pref', sa.Integer(), nullable=True)) - op.create_foreign_key(None, 'users', 'departments', ['dept_pref'], ['id']) + op.add_column("users", sa.Column("dept_pref", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "users", "departments", ["dept_pref"], ["id"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'users', type_='foreignkey') - op.drop_column('users', 'dept_pref') + op.drop_constraint(None, "users", type_="foreignkey") + op.drop_column("users", "dept_pref") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/bd0398fe4aab_.py b/OpenOversight/migrations/versions/bd0398fe4aab_.py index ee11edd98..a82bee411 100644 --- a/OpenOversight/migrations/versions/bd0398fe4aab_.py +++ b/OpenOversight/migrations/versions/bd0398fe4aab_.py @@ -5,32 +5,40 @@ Create Date: 2018-05-15 19:56:14.199692 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = 'bd0398fe4aab' -down_revision = 'f4a41e328a06' +revision = "bd0398fe4aab" +down_revision = "f4a41e328a06" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('incidents', 'user_id', new_column_name='creator_id', existing_type=sa.Integer) - op.add_column('incidents', sa.Column('last_updated_id', sa.Integer(), nullable=True)) - op.drop_constraint(u'incidents_user_id_fkey', 'incidents', type_='foreignkey') - op.create_foreign_key(None, 'incidents', 'users', ['creator_id'], ['id']) - op.create_foreign_key(None, 'incidents', 'users', ['last_updated_id'], ['id']) + op.alter_column( + "incidents", "user_id", new_column_name="creator_id", existing_type=sa.Integer + ) + op.add_column( + "incidents", sa.Column("last_updated_id", sa.Integer(), nullable=True) + ) + op.drop_constraint("incidents_user_id_fkey", "incidents", type_="foreignkey") + op.create_foreign_key(None, "incidents", "users", ["creator_id"], ["id"]) + op.create_foreign_key(None, "incidents", "users", ["last_updated_id"], ["id"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('incidents', 'creator_id', new_column_name='user_id', existing_type=sa.Integer) - op.drop_constraint(None, 'incidents', type_='foreignkey') - op.drop_constraint(None, 'incidents', type_='foreignkey') - op.create_foreign_key(u'incidents_user_id_fkey', 'incidents', 'users', ['user_id'], ['id']) - op.drop_column('incidents', 'last_updated_id') + op.alter_column( + "incidents", "creator_id", new_column_name="user_id", existing_type=sa.Integer + ) + op.drop_constraint(None, "incidents", type_="foreignkey") + op.drop_constraint(None, "incidents", type_="foreignkey") + op.create_foreign_key( + "incidents_user_id_fkey", "incidents", "users", ["user_id"], ["id"] + ) + op.drop_column("incidents", "last_updated_id") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/c1fc26073f85_rank_rename_po_police_officer.py b/OpenOversight/migrations/versions/c1fc26073f85_rank_rename_po_police_officer.py index e63fbf81b..1e6d62c39 100644 --- a/OpenOversight/migrations/versions/c1fc26073f85_rank_rename_po_police_officer.py +++ b/OpenOversight/migrations/versions/c1fc26073f85_rank_rename_po_police_officer.py @@ -9,8 +9,8 @@ # revision identifiers, used by Alembic. -revision = 'c1fc26073f85' -down_revision = '770ed51b4e16' +revision = "c1fc26073f85" +down_revision = "770ed51b4e16" branch_labels = None depends_on = None diff --git a/OpenOversight/migrations/versions/ca95c047bf42_.py b/OpenOversight/migrations/versions/ca95c047bf42_.py index 0dff46ad2..df49bf939 100644 --- a/OpenOversight/migrations/versions/ca95c047bf42_.py +++ b/OpenOversight/migrations/versions/ca95c047bf42_.py @@ -5,26 +5,26 @@ Create Date: 2018-05-10 20:02:19.006081 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = 'ca95c047bf42' -down_revision = '6065d7cdcbf8' +revision = "ca95c047bf42" +down_revision = "6065d7cdcbf8" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('incidents', sa.Column('department_id', sa.Integer(), nullable=True)) - op.create_foreign_key(None, 'incidents', 'departments', ['department_id'], ['id']) + op.add_column("incidents", sa.Column("department_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "incidents", "departments", ["department_id"], ["id"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'incidents', type_='foreignkey') - op.drop_column('incidents', 'department_id') + op.drop_constraint(None, "incidents", type_="foreignkey") + op.drop_column("incidents", "department_id") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/cd39b33b5360_constrain_officer_gender_options.py b/OpenOversight/migrations/versions/cd39b33b5360_constrain_officer_gender_options.py index fbce9e7eb..107dd8a94 100644 --- a/OpenOversight/migrations/versions/cd39b33b5360_constrain_officer_gender_options.py +++ b/OpenOversight/migrations/versions/cd39b33b5360_constrain_officer_gender_options.py @@ -5,13 +5,13 @@ Create Date: 2020-07-13 02:45:07.533549 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = 'cd39b33b5360' -down_revision = '86eb228e4bc0' +revision = "cd39b33b5360" +down_revision = "86eb228e4bc0" branch_labels = None depends_on = None @@ -22,7 +22,7 @@ def get_update_statement(normalized, options): SET gender = '{normalized}' WHERE LOWER(gender) in ({options}); """ - options = ', '.join(["'" + o + "'" for o in options]) + options = ", ".join(["'" + o + "'" for o in options]) return template.format(normalized=normalized, options=options) @@ -30,12 +30,12 @@ def upgrade(): conn = op.get_bind() genders = { - "M": ('male', 'm', 'man'), - "F": ('female', 'f', 'woman'), - "Other": ('nonbinary', 'other'), + "M": ("male", "m", "man"), + "F": ("female", "f", "woman"), + "Other": ("nonbinary", "other"), } - update_statement = '' + update_statement = "" for normalized, options in genders.items(): update_statement += get_update_statement(normalized, options) @@ -50,25 +50,29 @@ def upgrade(): conn.execute(null_query) # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('officers', 'gender', - existing_type=sa.VARCHAR(length=120), - type_=sa.VARCHAR(length=5), - existing_nullable=True) + op.alter_column( + "officers", + "gender", + existing_type=sa.VARCHAR(length=120), + type_=sa.VARCHAR(length=5), + existing_nullable=True, + ) # ### end Alembic commands ### op.create_check_constraint( - 'gender_options', - 'officers', - "gender in ('M', 'F', 'Other')" + "gender_options", "officers", "gender in ('M', 'F', 'Other')" ) def downgrade(): - op.drop_constraint('gender_options', 'officers', type_='check') + op.drop_constraint("gender_options", "officers", type_="check") # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('officers', 'gender', - existing_type=sa.VARCHAR(length=5), - type_=sa.VARCHAR(length=120), - existing_nullable=True) + op.alter_column( + "officers", + "gender", + existing_type=sa.VARCHAR(length=5), + type_=sa.VARCHAR(length=120), + existing_nullable=True, + ) # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/cfc5f3fd5efe_.py b/OpenOversight/migrations/versions/cfc5f3fd5efe_.py index c792794d3..bcebbe874 100644 --- a/OpenOversight/migrations/versions/cfc5f3fd5efe_.py +++ b/OpenOversight/migrations/versions/cfc5f3fd5efe_.py @@ -5,30 +5,32 @@ Create Date: 2018-06-07 18:52:31.059396 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = 'cfc5f3fd5efe' -down_revision = '2040f0c804b0' +revision = "cfc5f3fd5efe" +down_revision = "2040f0c804b0" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('notes', sa.Column('creator_id', sa.Integer(), nullable=True)) - op.drop_constraint(u'notes_user_id_fkey', 'notes', type_='foreignkey') - op.create_foreign_key(None, 'notes', 'users', ['creator_id'], ['id']) - op.drop_column('notes', 'user_id') + op.add_column("notes", sa.Column("creator_id", sa.Integer(), nullable=True)) + op.drop_constraint("notes_user_id_fkey", "notes", type_="foreignkey") + op.create_foreign_key(None, "notes", "users", ["creator_id"], ["id"]) + op.drop_column("notes", "user_id") # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('notes', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True)) - op.drop_constraint(None, 'notes', type_='foreignkey') - op.create_foreign_key(u'notes_user_id_fkey', 'notes', 'users', ['user_id'], ['id']) - op.drop_column('notes', 'creator_id') + op.add_column( + "notes", sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=True) + ) + op.drop_constraint(None, "notes", type_="foreignkey") + op.create_foreign_key("notes_user_id_fkey", "notes", "users", ["user_id"], ["id"]) + op.drop_column("notes", "creator_id") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/d86feb8fa5d1_.py b/OpenOversight/migrations/versions/d86feb8fa5d1_.py index 4670a6fd5..8d49f254a 100644 --- a/OpenOversight/migrations/versions/d86feb8fa5d1_.py +++ b/OpenOversight/migrations/versions/d86feb8fa5d1_.py @@ -5,13 +5,13 @@ Create Date: 2018-05-04 21:03:12.925484 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = 'd86feb8fa5d1' -down_revision = 'e14a1aa4b58f' +revision = "d86feb8fa5d1" +down_revision = "e14a1aa4b58f" branch_labels = None depends_on = None @@ -19,101 +19,138 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table( - 'license_plates', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('number', sa.String(length=8), nullable=False), - sa.Column('state', sa.String(length=2), nullable=True), - sa.PrimaryKeyConstraint('id') + "license_plates", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("number", sa.String(length=8), nullable=False), + sa.Column("state", sa.String(length=2), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_license_plates_number"), "license_plates", ["number"], unique=False + ) + op.create_index( + op.f("ix_license_plates_state"), "license_plates", ["state"], unique=False ) - op.create_index(op.f('ix_license_plates_number'), 'license_plates', ['number'], unique=False) - op.create_index(op.f('ix_license_plates_state'), 'license_plates', ['state'], unique=False) op.create_table( - 'links', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('url', sa.String(length=255), nullable=False), - sa.Column('link_type', sa.String(length=100), nullable=True), - sa.PrimaryKeyConstraint('id') + "links", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("url", sa.String(length=255), nullable=False), + sa.Column("link_type", sa.String(length=100), nullable=True), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_links_link_type'), 'links', ['link_type'], unique=False) + op.create_index(op.f("ix_links_link_type"), "links", ["link_type"], unique=False) op.create_table( - 'locations', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('street_name', sa.String(length=100), nullable=True), - sa.Column('cross_street1', sa.String(length=100), nullable=True), - sa.Column('cross_street2', sa.String(length=100), nullable=True), - sa.Column('city', sa.String(length=100), nullable=True), - sa.Column('state', sa.String(length=2), nullable=True), - sa.Column('zip_code', sa.String(length=5), nullable=True), - sa.PrimaryKeyConstraint('id') + "locations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("street_name", sa.String(length=100), nullable=True), + sa.Column("cross_street1", sa.String(length=100), nullable=True), + sa.Column("cross_street2", sa.String(length=100), nullable=True), + sa.Column("city", sa.String(length=100), nullable=True), + sa.Column("state", sa.String(length=2), nullable=True), + sa.Column("zip_code", sa.String(length=5), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_locations_city"), "locations", ["city"], unique=False) + op.create_index(op.f("ix_locations_state"), "locations", ["state"], unique=False) + op.create_index( + op.f("ix_locations_street_name"), "locations", ["street_name"], unique=False + ) + op.create_index( + op.f("ix_locations_zip_code"), "locations", ["zip_code"], unique=False ) - op.create_index(op.f('ix_locations_city'), 'locations', ['city'], unique=False) - op.create_index(op.f('ix_locations_state'), 'locations', ['state'], unique=False) - op.create_index(op.f('ix_locations_street_name'), 'locations', ['street_name'], unique=False) - op.create_index(op.f('ix_locations_zip_code'), 'locations', ['zip_code'], unique=False) op.create_table( - 'incidents', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('date', sa.DateTime(), nullable=True), - sa.Column('report_number', sa.String(length=50), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('address_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['address_id'], ['locations.id'], ), - sa.PrimaryKeyConstraint('id') + "incidents", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("date", sa.DateTime(), nullable=True), + sa.Column("report_number", sa.String(length=50), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("address_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["address_id"], + ["locations.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_incidents_date"), "incidents", ["date"], unique=False) + op.create_index( + op.f("ix_incidents_report_number"), "incidents", ["report_number"], unique=False ) - op.create_index(op.f('ix_incidents_date'), 'incidents', ['date'], unique=False) - op.create_index(op.f('ix_incidents_report_number'), 'incidents', ['report_number'], unique=False) op.create_table( - 'incident_license_plates', - sa.Column('incident_id', sa.Integer(), nullable=False), - sa.Column('link_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['incident_id'], ['incidents.id'], ), - sa.ForeignKeyConstraint(['link_id'], ['license_plates.id'], ), - sa.PrimaryKeyConstraint('incident_id', 'link_id') + "incident_license_plates", + sa.Column("incident_id", sa.Integer(), nullable=False), + sa.Column("link_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["incident_id"], + ["incidents.id"], + ), + sa.ForeignKeyConstraint( + ["link_id"], + ["license_plates.id"], + ), + sa.PrimaryKeyConstraint("incident_id", "link_id"), ) op.create_table( - 'incident_links', - sa.Column('incident_id', sa.Integer(), nullable=False), - sa.Column('link_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['incident_id'], ['incidents.id'], ), - sa.ForeignKeyConstraint(['link_id'], ['links.id'], ), - sa.PrimaryKeyConstraint('incident_id', 'link_id') + "incident_links", + sa.Column("incident_id", sa.Integer(), nullable=False), + sa.Column("link_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["incident_id"], + ["incidents.id"], + ), + sa.ForeignKeyConstraint( + ["link_id"], + ["links.id"], + ), + sa.PrimaryKeyConstraint("incident_id", "link_id"), ) op.create_table( - 'officer_incidents', - sa.Column('officer_id', sa.Integer(), nullable=False), - sa.Column('incident_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['incident_id'], ['incidents.id'], ), - sa.ForeignKeyConstraint(['officer_id'], ['officers.id'], ), - sa.PrimaryKeyConstraint('officer_id', 'incident_id') + "officer_incidents", + sa.Column("officer_id", sa.Integer(), nullable=False), + sa.Column("incident_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["incident_id"], + ["incidents.id"], + ), + sa.ForeignKeyConstraint( + ["officer_id"], + ["officers.id"], + ), + sa.PrimaryKeyConstraint("officer_id", "incident_id"), ) op.create_table( - 'officer_links', - sa.Column('officer_id', sa.Integer(), nullable=False), - sa.Column('link_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['link_id'], ['links.id'], ), - sa.ForeignKeyConstraint(['officer_id'], ['officers.id'], ), - sa.PrimaryKeyConstraint('officer_id', 'link_id') + "officer_links", + sa.Column("officer_id", sa.Integer(), nullable=False), + sa.Column("link_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["link_id"], + ["links.id"], + ), + sa.ForeignKeyConstraint( + ["officer_id"], + ["officers.id"], + ), + sa.PrimaryKeyConstraint("officer_id", "link_id"), ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('officer_links') - op.drop_table('officer_incidents') - op.drop_table('incident_links') - op.drop_table('incident_license_plates') - op.drop_index(op.f('ix_incidents_report_number'), table_name='incidents') - op.drop_index(op.f('ix_incidents_date'), table_name='incidents') - op.drop_table('incidents') - op.drop_index(op.f('ix_locations_zip_code'), table_name='locations') - op.drop_index(op.f('ix_locations_street_name'), table_name='locations') - op.drop_index(op.f('ix_locations_state'), table_name='locations') - op.drop_index(op.f('ix_locations_city'), table_name='locations') - op.drop_table('locations') - op.drop_index(op.f('ix_links_link_type'), table_name='links') - op.drop_table('links') - op.drop_index(op.f('ix_license_plates_state'), table_name='license_plates') - op.drop_index(op.f('ix_license_plates_number'), table_name='license_plates') - op.drop_table('license_plates') + op.drop_table("officer_links") + op.drop_table("officer_incidents") + op.drop_table("incident_links") + op.drop_table("incident_license_plates") + op.drop_index(op.f("ix_incidents_report_number"), table_name="incidents") + op.drop_index(op.f("ix_incidents_date"), table_name="incidents") + op.drop_table("incidents") + op.drop_index(op.f("ix_locations_zip_code"), table_name="locations") + op.drop_index(op.f("ix_locations_street_name"), table_name="locations") + op.drop_index(op.f("ix_locations_state"), table_name="locations") + op.drop_index(op.f("ix_locations_city"), table_name="locations") + op.drop_table("locations") + op.drop_index(op.f("ix_links_link_type"), table_name="links") + op.drop_table("links") + op.drop_index(op.f("ix_license_plates_state"), table_name="license_plates") + op.drop_index(op.f("ix_license_plates_number"), table_name="license_plates") + op.drop_table("license_plates") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/e14a1aa4b58f_.py b/OpenOversight/migrations/versions/e14a1aa4b58f_.py index d4a014c53..6c889a8f4 100644 --- a/OpenOversight/migrations/versions/e14a1aa4b58f_.py +++ b/OpenOversight/migrations/versions/e14a1aa4b58f_.py @@ -5,28 +5,30 @@ Create Date: 2018-04-30 15:48:51.968189 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = 'e14a1aa4b58f' -down_revision = 'af933dc1ef93' +revision = "e14a1aa4b58f" +down_revision = "af933dc1ef93" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('users', sa.Column('ac_department_id', sa.Integer(), nullable=True)) - op.add_column('users', sa.Column('is_area_coordinator', sa.Boolean(), nullable=True)) - op.create_foreign_key(None, 'users', 'departments', ['ac_department_id'], ['id']) + op.add_column("users", sa.Column("ac_department_id", sa.Integer(), nullable=True)) + op.add_column( + "users", sa.Column("is_area_coordinator", sa.Boolean(), nullable=True) + ) + op.create_foreign_key(None, "users", "departments", ["ac_department_id"], ["id"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'users', type_='foreignkey') - op.drop_column('users', 'is_area_coordinator') - op.drop_column('users', 'ac_department_id') + op.drop_constraint(None, "users", type_="foreignkey") + op.drop_column("users", "is_area_coordinator") + op.drop_column("users", "ac_department_id") # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/e2c2efde8b55_face_model_fix.py b/OpenOversight/migrations/versions/e2c2efde8b55_face_model_fix.py index 2b838cd18..4b3f5a58c 100644 --- a/OpenOversight/migrations/versions/e2c2efde8b55_face_model_fix.py +++ b/OpenOversight/migrations/versions/e2c2efde8b55_face_model_fix.py @@ -9,23 +9,46 @@ # revision identifiers, used by Alembic. -revision = 'e2c2efde8b55' -down_revision = '9e2827dae28c' +revision = "e2c2efde8b55" +down_revision = "9e2827dae28c" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('faces', 'fk_face_original_image_id', new_column_name='original_image_id') - op.drop_constraint('faces_fk_face_original_image_id_fkey', 'faces', type_='foreignkey') - op.create_foreign_key('fk_face_original_image_id', 'faces', 'raw_images', ['original_image_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL', use_alter=True) + op.alter_column( + "faces", "fk_face_original_image_id", new_column_name="original_image_id" + ) + op.drop_constraint( + "faces_fk_face_original_image_id_fkey", "faces", type_="foreignkey" + ) + op.create_foreign_key( + "fk_face_original_image_id", + "faces", + "raw_images", + ["original_image_id"], + ["id"], + onupdate="CASCADE", + ondelete="SET NULL", + use_alter=True, + ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint('fk_face_original_image_id', 'faces', type_='foreignkey') - op.create_foreign_key('faces_fk_face_original_image_id_fkey', 'faces', 'raw_images', ['fk_face_original_image_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL') - op.alter_column('faces', 'original_image_id', new_column_name='fk_face_original_image_id') + op.drop_constraint("fk_face_original_image_id", "faces", type_="foreignkey") + op.create_foreign_key( + "faces_fk_face_original_image_id_fkey", + "faces", + "raw_images", + ["fk_face_original_image_id"], + ["id"], + onupdate="CASCADE", + ondelete="SET NULL", + ) + op.alter_column( + "faces", "original_image_id", new_column_name="fk_face_original_image_id" + ) # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/f4a41e328a06_.py b/OpenOversight/migrations/versions/f4a41e328a06_.py index 73b94b770..c7783084c 100644 --- a/OpenOversight/migrations/versions/f4a41e328a06_.py +++ b/OpenOversight/migrations/versions/f4a41e328a06_.py @@ -5,38 +5,38 @@ Create Date: 2018-05-15 17:03:06.270691 """ -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision = 'f4a41e328a06' -down_revision = 'ca95c047bf42' +revision = "f4a41e328a06" +down_revision = "ca95c047bf42" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('incidents', sa.Column('user_id', sa.Integer(), nullable=True)) - op.create_foreign_key(None, 'incidents', 'users', ['user_id'], ['id']) - op.add_column('links', sa.Column('author', sa.String(length=255), nullable=True)) - op.add_column('links', sa.Column('description', sa.Text(), nullable=True)) - op.add_column('links', sa.Column('title', sa.String(length=100), nullable=True)) - op.add_column('links', sa.Column('user_id', sa.Integer(), nullable=True)) - op.create_index(op.f('ix_links_title'), 'links', ['title'], unique=False) - op.create_foreign_key(None, 'links', 'users', ['user_id'], ['id']) + op.add_column("incidents", sa.Column("user_id", sa.Integer(), nullable=True)) + op.create_foreign_key(None, "incidents", "users", ["user_id"], ["id"]) + op.add_column("links", sa.Column("author", sa.String(length=255), nullable=True)) + op.add_column("links", sa.Column("description", sa.Text(), nullable=True)) + op.add_column("links", sa.Column("title", sa.String(length=100), nullable=True)) + op.add_column("links", sa.Column("user_id", sa.Integer(), nullable=True)) + op.create_index(op.f("ix_links_title"), "links", ["title"], unique=False) + op.create_foreign_key(None, "links", "users", ["user_id"], ["id"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'links', type_='foreignkey') - op.drop_index(op.f('ix_links_title'), table_name='links') - op.drop_column('links', 'user_id') - op.drop_column('links', 'title') - op.drop_column('links', 'description') - op.drop_column('links', 'author') - op.drop_constraint(None, 'incidents', type_='foreignkey') - op.drop_column('incidents', 'user_id') + op.drop_constraint(None, "links", type_="foreignkey") + op.drop_index(op.f("ix_links_title"), table_name="links") + op.drop_column("links", "user_id") + op.drop_column("links", "title") + op.drop_column("links", "description") + op.drop_column("links", "author") + op.drop_constraint(None, "incidents", type_="foreignkey") + op.drop_column("incidents", "user_id") # ### end Alembic commands ### diff --git a/OpenOversight/tests/conftest.py b/OpenOversight/tests/conftest.py index 7e3b951bb..e07316d74 100644 --- a/OpenOversight/tests/conftest.py +++ b/OpenOversight/tests/conftest.py @@ -1,38 +1,49 @@ +import csv import datetime +import os +import random +import sys +import threading +import time +import uuid +from io import BytesIO from typing import List -from flask import current_app -from io import BytesIO import pytest -import random -from selenium import webdriver -import time -import threading -from xvfbwrapper import Xvfb from faker import Faker -import csv -import uuid -import sys -import os +from flask import current_app from PIL import Image as Pimage +from selenium import webdriver +from xvfbwrapper import Xvfb from OpenOversight.app import create_app, models +from OpenOversight.app.models import Job, Officer, Unit +from OpenOversight.app.models import db as _db from OpenOversight.app.utils import merge_dicts -from OpenOversight.app.models import db as _db, Unit, Job, Officer from OpenOversight.tests.routes.route_helpers import ADMIN_EMAIL, ADMIN_PASSWORD + factory = Faker() -OFFICERS = [('IVANA', '', 'TINKLE'), - ('SEYMOUR', '', 'BUTZ'), - ('HAYWOOD', 'U', 'CUDDLEME'), - ('BEA', '', 'O\'PROBLEM'), - ('URA', '', 'SNOTBALL'), - ('HUGH', '', 'JASS')] +OFFICERS = [ + ("IVANA", "", "TINKLE"), + ("SEYMOUR", "", "BUTZ"), + ("HAYWOOD", "U", "CUDDLEME"), + ("BEA", "", "O'PROBLEM"), + ("URA", "", "SNOTBALL"), + ("HUGH", "", "JASS"), +] -RANK_CHOICES_1 = ['Not Sure', 'Police Officer', 'Captain', 'Commander'] -RANK_CHOICES_2 = ['Not Sure', 'Police Officer', 'Lieutenant', 'Sergeant', 'Commander', 'Chief'] +RANK_CHOICES_1 = ["Not Sure", "Police Officer", "Captain", "Commander"] +RANK_CHOICES_2 = [ + "Not Sure", + "Police Officer", + "Lieutenant", + "Sergeant", + "Commander", + "Chief", +] AC_DEPT = 1 @@ -45,26 +56,28 @@ def pick_birth_date(): def pick_date(seed: bytes = None, start_year=2000, end_year=2020): # source: https://stackoverflow.com/questions/40351791/how-to-hash-strings-into-a-float-in-01 # Wanted to deterministically create a date from a seed string (e.g. the hash or uuid on an officer object) - from struct import unpack from hashlib import sha256 + from struct import unpack def bytes_to_float(b): - return float(unpack('L', sha256(b).digest()[:8])[0]) / 2 ** 64 + return float(unpack("L", sha256(b).digest()[:8])[0]) / 2**64 if seed is None: - seed = str(uuid.uuid4()).encode('utf-8') + seed = str(uuid.uuid4()).encode("utf-8") - return datetime.datetime(start_year, 1, 1, 00, 00, 00) \ - + datetime.timedelta(days=365 * (end_year - start_year) * bytes_to_float(seed)) + return datetime.datetime(start_year, 1, 1, 00, 00, 00) + datetime.timedelta( + days=365 * (end_year - start_year) * bytes_to_float(seed) + ) def pick_race(): - return random.choice(['WHITE', 'BLACK', 'HISPANIC', 'ASIAN', - 'PACIFIC ISLANDER', 'Not Sure']) + return random.choice( + ["WHITE", "BLACK", "HISPANIC", "ASIAN", "PACIFIC ISLANDER", "Not Sure"] + ) def pick_gender(): - return random.choice(['M', 'F', 'Other', None]) + return random.choice(["M", "F", "Other", None]) def pick_first(): @@ -104,21 +117,27 @@ def generate_officer(): year_born = pick_birth_date() f_name, m_initial, l_name = pick_name() return models.Officer( - last_name=l_name, first_name=f_name, + last_name=l_name, + first_name=f_name, middle_initial=m_initial, - race=pick_race(), gender=pick_gender(), + race=pick_race(), + gender=pick_gender(), birth_year=year_born, employment_date=datetime.datetime(year_born + 20, 4, 4, 1, 1, 1), department_id=pick_department().id, - unique_internal_identifier=pick_uid() + unique_internal_identifier=pick_uid(), ) def build_assignment(officer: Officer, units: List[Unit], jobs: Job): - return models.Assignment(star_no=pick_star(), job_id=random.choice(jobs).id, - officer=officer, unit_id=random.choice(units).id, - star_date=pick_date(officer.full_name().encode('utf-8')), - resign_date=pick_date(officer.full_name().encode('utf-8'))) + return models.Assignment( + star_no=pick_star(), + job_id=random.choice(jobs).id, + officer=officer, + unit_id=random.choice(units).id, + star_date=pick_date(officer.full_name().encode("utf-8")), + resign_date=pick_date(officer.full_name().encode("utf-8")), + ) def build_note(officer, user, content=None): @@ -130,7 +149,8 @@ def build_note(officer, user, content=None): officer_id=officer.id, creator_id=user.id, date_created=date, - date_updated=date) + date_updated=date, + ) def build_description(officer, user, content=None): @@ -142,7 +162,8 @@ def build_description(officer, user, content=None): officer_id=officer.id, creator_id=user.id, date_created=date, - date_updated=date) + date_updated=date, + ) def build_salary(officer): @@ -151,25 +172,28 @@ def build_salary(officer): salary=pick_salary(), overtime_pay=pick_salary(), year=random.randint(2000, 2019), - is_fiscal_year=True if random.randint(0, 1) else False) + is_fiscal_year=True if random.randint(0, 1) else False, + ) def assign_faces(officer, images): if random.uniform(0, 1) >= 0.5: img_id = random.choice(images).id - return models.Face(officer_id=officer.id, - img_id=img_id, - original_image_id=img_id, - featured=False) + return models.Face( + officer_id=officer.id, + img_id=img_id, + original_image_id=img_id, + featured=False, + ) else: return False -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def app(request): """Session-wide test `Flask` application.""" - app = create_app('testing') - app.config['WTF_CSRF_ENABLED'] = False + app = create_app("testing") + app.config["WTF_CSRF_ENABLED"] = False # Establish an application context before running the tests. ctx = app.app_context() @@ -182,7 +206,7 @@ def teardown(): return app -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def db(app, request): """Session-wide test database.""" @@ -196,7 +220,7 @@ def teardown(): return _db -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def session(db, request): """Creates a new database session for a test.""" connection = db.engine.connect() @@ -219,7 +243,7 @@ def teardown(): @pytest.fixture def test_png_BytesIO(): test_dir = os.path.dirname(os.path.realpath(__file__)) - local_path = os.path.join(test_dir, 'images/204Cat.png') + local_path = os.path.join(test_dir, "images/204Cat.png") img = Pimage.open(local_path) byte_io = BytesIO() @@ -231,7 +255,7 @@ def test_png_BytesIO(): @pytest.fixture def test_jpg_BytesIO(): test_dir = os.path.dirname(os.path.realpath(__file__)) - local_path = os.path.join(test_dir, 'images/200Cat.jpeg') + local_path = os.path.join(test_dir, "images/200Cat.jpeg") img = Pimage.open(local_path) byte_io = BytesIO() @@ -247,64 +271,71 @@ def test_csv_dir(): def add_mockdata(session): - NUM_OFFICERS = current_app.config['NUM_OFFICERS'] - department = models.Department(name='Springfield Police Department', - short_name='SPD', unique_internal_identifier_label='homer_number') + NUM_OFFICERS = current_app.config["NUM_OFFICERS"] + department = models.Department( + name="Springfield Police Department", + short_name="SPD", + unique_internal_identifier_label="homer_number", + ) session.add(department) - department2 = models.Department(name='Chicago Police Department', - short_name='CPD') + department2 = models.Department(name="Chicago Police Department", short_name="CPD") session.add(department2) session.commit() i = 0 for rank in RANK_CHOICES_1: - session.add(models.Job( - job_title=rank, - order=i, - is_sworn_officer=True, - department_id=1 - )) + session.add( + models.Job(job_title=rank, order=i, is_sworn_officer=True, department_id=1) + ) i += 1 i = 0 for rank in RANK_CHOICES_2: - session.add(models.Job( - job_title=rank, - order=i, - is_sworn_officer=True, - department_id=2 - )) + session.add( + models.Job(job_title=rank, order=i, is_sworn_officer=True, department_id=2) + ) i += 1 session.commit() # Ensure test data is deterministic - SEED = current_app.config['SEED'] + SEED = current_app.config["SEED"] random.seed(SEED) test_units = [ models.Unit(descrip="test", department_id=1), - models.Unit(descrip='District 13', department_id=1), - models.Unit(descrip='Donut Devourers', department_id=1), - models.Unit(descrip='Bureau of Organized Crime', department_id=2), - models.Unit(descrip='Porky\'s BBQ: Rub Division', department_id=2) + models.Unit(descrip="District 13", department_id=1), + models.Unit(descrip="Donut Devourers", department_id=1), + models.Unit(descrip="Bureau of Organized Crime", department_id=2), + models.Unit(descrip="Porky's BBQ: Rub Division", department_id=2), ] session.add_all(test_units) session.commit() - test_images = [models.Image(filepath='/static/images/test_cop{}.png'.format(x + 1), department_id=1) for x in range(5)] + \ - [models.Image(filepath='/static/images/test_cop{}.png'.format(x + 1), department_id=2) for x in range(5)] + test_images = [ + models.Image( + filepath="/static/images/test_cop{}.png".format(x + 1), department_id=1 + ) + for x in range(5) + ] + [ + models.Image( + filepath="/static/images/test_cop{}.png".format(x + 1), department_id=2 + ) + for x in range(5) + ] test_officer_links = [ models.Link( - url='https://openoversight.com/', - link_type='link', - title='OpenOversight', - description='A public, searchable database of law enforcement officers.'), + url="https://openoversight.com/", + link_type="link", + title="OpenOversight", + description="A public, searchable database of law enforcement officers.", + ), models.Link( - url='http://www.youtube.com/?v=help', - link_type='video', - title='Youtube', - author='the internet'), + url="http://www.youtube.com/?v=help", + link_type="video", + title="Youtube", + author="the internet", + ), ] officers = [generate_officer() for o in range(NUM_OFFICERS)] @@ -324,12 +355,20 @@ def add_mockdata(session): jobs_dept1 = models.Job.query.filter_by(department_id=1).all() jobs_dept2 = models.Job.query.filter_by(department_id=2).all() - assignments_dept1 = [build_assignment(officer, test_units, jobs_dept1) for officer in officers_dept1] - assignments_dept2 = [build_assignment(officer, test_units, jobs_dept2) for officer in officers_dept2] + assignments_dept1 = [ + build_assignment(officer, test_units, jobs_dept1) for officer in officers_dept1 + ] + assignments_dept2 = [ + build_assignment(officer, test_units, jobs_dept2) for officer in officers_dept2 + ] salaries = [build_salary(officer) for officer in all_officers] - faces_dept1 = [assign_faces(officer, assigned_images_dept1) for officer in officers_dept1] - faces_dept2 = [assign_faces(officer, assigned_images_dept2) for officer in officers_dept2] + faces_dept1 = [ + assign_faces(officer, assigned_images_dept1) for officer in officers_dept1 + ] + faces_dept2 = [ + assign_faces(officer, assigned_images_dept2) for officer in officers_dept2 + ] faces1 = [f for f in faces_dept1 if f] faces2 = [f for f in faces_dept2 if f] session.commit() @@ -339,64 +378,79 @@ def add_mockdata(session): session.add_all(faces1) session.add_all(faces2) - test_user = models.User(email='jen@example.org', - username='test_user', - password='dog', - confirmed=True) + test_user = models.User( + email="jen@example.org", username="test_user", password="dog", confirmed=True + ) session.add(test_user) - test_admin = models.User(email=ADMIN_EMAIL, - username='test_admin', - password=ADMIN_PASSWORD, - confirmed=True, - is_administrator=True) + test_admin = models.User( + email=ADMIN_EMAIL, + username="test_admin", + password=ADMIN_PASSWORD, + confirmed=True, + is_administrator=True, + ) session.add(test_admin) - test_area_coordinator = models.User(email='raq929@example.org', - username='test_ac', - password='horse', - confirmed=True, - is_area_coordinator=True, - ac_department_id=AC_DEPT) + test_area_coordinator = models.User( + email="raq929@example.org", + username="test_ac", + password="horse", + confirmed=True, + is_area_coordinator=True, + ac_department_id=AC_DEPT, + ) session.add(test_area_coordinator) - test_unconfirmed_user = models.User(email='freddy@example.org', - username='b_meson', - password='dog', confirmed=False) + test_unconfirmed_user = models.User( + email="freddy@example.org", username="b_meson", password="dog", confirmed=False + ) session.add(test_unconfirmed_user) session.commit() test_addresses = [ models.Location( - street_name='Test St', - cross_street1='Cross St', - cross_street2='2nd St', - city='My City', - state='AZ', - zip_code='23456'), + street_name="Test St", + cross_street1="Cross St", + cross_street2="2nd St", + city="My City", + state="AZ", + zip_code="23456", + ), models.Location( - street_name='Testing St', - cross_street1='First St', - cross_street2='Fourth St', - city='Another City', - state='ME', - zip_code='23456') + street_name="Testing St", + cross_street1="First St", + cross_street2="Fourth St", + city="Another City", + state="ME", + zip_code="23456", + ), ] session.add_all(test_addresses) session.commit() test_license_plates = [ - models.LicensePlate(number='603EEE', state='MA'), - models.LicensePlate(number='404301', state='WA') + models.LicensePlate(number="603EEE", state="MA"), + models.LicensePlate(number="404301", state="WA"), ] session.add_all(test_license_plates) session.commit() test_incident_links = [ - models.Link(url='https://stackoverflow.com/', link_type='link', creator=test_admin, creator_id=test_admin.id), - models.Link(url='http://www.youtube.com/?v=help', link_type='video', creator=test_admin, creator_id=test_admin.id) + models.Link( + url="https://stackoverflow.com/", + link_type="link", + creator=test_admin, + creator_id=test_admin.id, + ), + models.Link( + url="http://www.youtube.com/?v=help", + link_type="video", + creator=test_admin, + creator_id=test_admin.id, + ), ] session.add_all(test_incident_links) @@ -406,40 +460,40 @@ def add_mockdata(session): models.Incident( date=datetime.date(2016, 3, 16), time=datetime.time(4, 20), - report_number='42', - description='### A thing happened\n **Markup** description', + report_number="42", + description="### A thing happened\n **Markup** description", department_id=1, address=test_addresses[0], license_plates=test_license_plates, links=test_incident_links, officers=[all_officers[o] for o in range(4)], creator_id=1, - last_updated_id=1 + last_updated_id=1, ), models.Incident( date=datetime.date(2017, 12, 11), time=datetime.time(2, 40), - report_number='38', - description='A thing happened', + report_number="38", + description="A thing happened", department_id=2, address=test_addresses[1], license_plates=[test_license_plates[0]], links=test_incident_links, officers=[all_officers[o] for o in range(3)], creator_id=2, - last_updated_id=1 + last_updated_id=1, ), models.Incident( date=datetime.datetime(2019, 1, 15), - report_number='39', - description='A test description that has over 300 chars. The purpose is to see how to display a larger descrption. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Lengthy lengthy lengthy.', + report_number="39", + description="A test description that has over 300 chars. The purpose is to see how to display a larger descrption. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Lengthy lengthy lengthy.", department_id=2, address=test_addresses[1], license_plates=[test_license_plates[0]], links=test_incident_links, officers=[all_officers[o] for o in range(1)], creator_id=2, - last_updated_id=1 + last_updated_id=1, ), ] session.add_all(test_incidents) @@ -449,7 +503,9 @@ def add_mockdata(session): # for testing routes first_officer = models.Officer.query.get(1) - note = build_note(first_officer, test_admin, "### A markdown note\nA **test** note!") + note = build_note( + first_officer, test_admin, "### A markdown note\nA **test** note!" + ) session.add(note) for officer in models.Officer.query.limit(20): user = random.choice(users_that_can_create_notes) @@ -462,7 +518,9 @@ def add_mockdata(session): # for testing routes first_officer = models.Officer.query.get(1) - description = build_description(first_officer, test_admin, "### A markdown description\nA **test** description!") + description = build_description( + first_officer, test_admin, "### A markdown description\nA **test** description!" + ) session.add(description) for officer in models.Officer.query.limit(20): user = random.choice(users_that_can_create_descriptions) @@ -530,60 +588,66 @@ def teardown(): tmp_path.mkdir() fieldnames = [ - 'department_id', - 'unique_internal_identifier', - 'first_name', - 'last_name', - 'middle_initial', - 'suffix', - 'gender', - 'race', - 'employment_date', - 'birth_year', - 'star_no', - 'job_title', - 'unit_id', - 'star_date', - 'resign_date', - 'salary', - 'salary_year', - 'salary_is_fiscal_year', - 'overtime_pay' + "department_id", + "unique_internal_identifier", + "first_name", + "last_name", + "middle_initial", + "suffix", + "gender", + "race", + "employment_date", + "birth_year", + "star_no", + "job_title", + "unit_id", + "star_date", + "resign_date", + "salary", + "salary_year", + "salary_is_fiscal_year", + "overtime_pay", ] officers_dept1 = models.Officer.query.filter_by(department_id=1).all() if sys.version_info.major == 2: - csvf = open(str(csv_path), 'w') + csvf = open(str(csv_path), "w") else: - csvf = open(str(csv_path), 'w', newline='') + csvf = open(str(csv_path), "w", newline="") try: - writer = csv.DictWriter(csvf, fieldnames=fieldnames, extrasaction='ignore') + writer = csv.DictWriter(csvf, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() for officer in officers_dept1: if not officer.unique_internal_identifier: officer.unique_internal_identifier = str(uuid.uuid4()) - towrite = merge_dicts(vars(officer), {'department_id': 1}) + towrite = merge_dicts(vars(officer), {"department_id": 1}) if len(list(officer.assignments)) > 0: assignment = officer.assignments[0] - towrite = merge_dicts(towrite, { - 'star_no': assignment.star_no, - 'job_title': assignment.job.job_title if assignment.job else None, - 'unit_id': assignment.unit_id, - 'star_date': assignment.star_date, - 'resign_date': assignment.resign_date - }) + towrite = merge_dicts( + towrite, + { + "star_no": assignment.star_no, + "job_title": assignment.job.job_title + if assignment.job + else None, + "unit_id": assignment.unit_id, + "star_date": assignment.star_date, + "resign_date": assignment.resign_date, + }, + ) if len(list(officer.salaries)) > 0: salary = officer.salaries[0] - towrite = merge_dicts(towrite, { - 'salary': salary.salary, - 'salary_year': salary.year, - 'salary_is_fiscal_year': salary.is_fiscal_year, - 'overtime_pay': salary.overtime_pay - }) + towrite = merge_dicts( + towrite, + { + "salary": salary.salary, + "salary_year": salary.year, + "salary_is_fiscal_year": salary.is_fiscal_year, + "overtime_pay": salary.overtime_pay, + }, + ) writer.writerow(towrite) - except: # noqa E722 - raise finally: csvf.close() @@ -597,6 +661,7 @@ def client(app, request): def teardown(): pass + request.addfinalizer(teardown) return client @@ -611,7 +676,7 @@ def browser(app, request): # start headless webdriver vdisplay = Xvfb() vdisplay.start() - driver = webdriver.Firefox(log_path='/tmp/geckodriver.log') + driver = webdriver.Firefox(log_path="/tmp/geckodriver.log") # wait for browser to start up time.sleep(3) yield driver diff --git a/OpenOversight/tests/routes/route_helpers.py b/OpenOversight/tests/routes/route_helpers.py index 4c03ab99d..a990d6cdf 100644 --- a/OpenOversight/tests/routes/route_helpers.py +++ b/OpenOversight/tests/routes/route_helpers.py @@ -1,53 +1,36 @@ +from flask import url_for from future.utils import iteritems -from flask import url_for from OpenOversight.app.auth.forms import LoginForm + ADMIN_EMAIL = "test@example.org" ADMIN_PASSWORD = "testtest" def login_user(client): - form = LoginForm(email='jen@example.org', - password='dog', - remember_me=True) - rv = client.post( - url_for('auth.login'), - data=form.data, - follow_redirects=False - ) + form = LoginForm(email="jen@example.org", password="dog", remember_me=True) + rv = client.post(url_for("auth.login"), data=form.data, follow_redirects=False) return rv def login_admin(client): - form = LoginForm(email=ADMIN_EMAIL, - password=ADMIN_PASSWORD, - remember_me=True) - rv = client.post( - url_for('auth.login'), - data=form.data, - follow_redirects=False - ) + form = LoginForm(email=ADMIN_EMAIL, password=ADMIN_PASSWORD, remember_me=True) + rv = client.post(url_for("auth.login"), data=form.data, follow_redirects=False) return rv def login_ac(client): - form = LoginForm(email='raq929@example.org', - password='horse', - remember_me=True) - rv = client.post( - url_for('auth.login'), - data=form.data, - follow_redirects=False - ) + form = LoginForm(email="raq929@example.org", password="horse", remember_me=True) + rv = client.post(url_for("auth.login"), data=form.data, follow_redirects=False) return rv def process_form_data(form_dict): """Takes the dict from a form with embedded formd and flattens it - in the way that it is flattened in the browser""" + in the way that it is flattened in the browser""" new_dict = {} for key, value in iteritems(form_dict): @@ -56,15 +39,19 @@ def process_form_data(form_dict): if type(value[0]) is dict: for idx, item in enumerate(value): for subkey, subvalue in iteritems(item): - new_dict['{}-{}-{}'.format(key, idx, subkey)] = subvalue + new_dict["{}-{}-{}".format(key, idx, subkey)] = subvalue elif type(value[0]) is str or type(value[0]) is int: for idx, item in enumerate(value): - new_dict['{}-{}'.format(key, idx)] = item + new_dict["{}-{}".format(key, idx)] = item else: - raise ValueError('Lists must contain dicts, strings or ints. {} submitted'.format(type(value[0]))) + raise ValueError( + "Lists must contain dicts, strings or ints. {} submitted".format( + type(value[0]) + ) + ) elif type(value) == dict: for subkey, subvalue in iteritems(value): - new_dict['{}-{}'.format(key, subkey)] = subvalue + new_dict["{}-{}".format(key, subkey)] = subvalue else: new_dict[key] = value diff --git a/OpenOversight/tests/routes/test_auth.py b/OpenOversight/tests/routes/test_auth.py index 8909d109e..5df19d00d 100644 --- a/OpenOversight/tests/routes/test_auth.py +++ b/OpenOversight/tests/routes/test_auth.py @@ -1,39 +1,53 @@ # Routing and view tests import pytest -from flask import url_for, current_app +from flask import current_app, url_for + + try: from urllib.parse import urlparse except ImportError: from urlparse import urlparse -from .route_helpers import login_user -from OpenOversight.app.auth.forms import (LoginForm, RegistrationForm, - ChangePasswordForm, PasswordResetForm, - PasswordResetRequestForm, - ChangeEmailForm, ChangeDefaultDepartmentForm) +from OpenOversight.app.auth.forms import ( + ChangeDefaultDepartmentForm, + ChangeEmailForm, + ChangePasswordForm, + LoginForm, + PasswordResetForm, + PasswordResetRequestForm, + RegistrationForm, +) from OpenOversight.app.models import User +from .route_helpers import login_user + -@pytest.mark.parametrize("route", [ - ('/auth/login'), - ('/auth/register'), - ('/auth/reset'), -]) +@pytest.mark.parametrize( + "route", + [ + ("/auth/login"), + ("/auth/register"), + ("/auth/reset"), + ], +) def test_routes_ok(route, client, mockdata): rv = client.get(route) assert rv.status_code == 200 # All login_required views should redirect if there is no user logged in -@pytest.mark.parametrize("route", [ - ('/auth/unconfirmed'), - ('/auth/logout'), - ('/auth/confirm/abcd1234'), - ('/auth/confirm'), - ('/auth/change-password'), - ('/auth/change-email'), - ('/auth/change-email/abcd1234') -]) +@pytest.mark.parametrize( + "route", + [ + ("/auth/unconfirmed"), + ("/auth/logout"), + ("/auth/confirm/abcd1234"), + ("/auth/confirm"), + ("/auth/change-password"), + ("/auth/change-email"), + ("/auth/change-email/abcd1234"), + ], +) def test_route_login_required(route, client, mockdata): rv = client.get(route) assert rv.status_code == 302 @@ -43,297 +57,258 @@ def test_valid_user_can_login(mockdata, client, session): with current_app.test_request_context(): rv = login_user(client) assert rv.status_code == 302 - assert urlparse(rv.location).path == '/index' + assert urlparse(rv.location).path == "/index" def test_invalid_user_cannot_login(mockdata, client, session): with current_app.test_request_context(): - form = LoginForm(email='freddy@example.org', - password='bruteforce', - remember_me=True) - rv = client.post( - url_for('auth.login'), - data=form.data + form = LoginForm( + email="freddy@example.org", password="bruteforce", remember_me=True ) - assert b'Invalid username or password.' in rv.data + rv = client.post(url_for("auth.login"), data=form.data) + assert b"Invalid username or password." in rv.data def test_user_can_logout(mockdata, client, session): with current_app.test_request_context(): login_user(client) - rv = client.get( - url_for('auth.logout'), - follow_redirects=True - ) - assert b'You have been logged out.' in rv.data + rv = client.get(url_for("auth.logout"), follow_redirects=True) + assert b"You have been logged out." in rv.data def test_user_cannot_register_with_existing_email(mockdata, client, session): with current_app.test_request_context(): - form = RegistrationForm(email='jen@example.org', - username='redshiftzero', - password='dog', - password2='dog') + form = RegistrationForm( + email="jen@example.org", + username="redshiftzero", + password="dog", + password2="dog", + ) rv = client.post( - url_for('auth.register'), - data=form.data, - follow_redirects=False + url_for("auth.register"), data=form.data, follow_redirects=False ) # Form will return 200 only if the form does not validate assert rv.status_code == 200 - assert b'Email already registered' in rv.data + assert b"Email already registered" in rv.data def test_user_cannot_register_if_passwords_dont_match(mockdata, client, session): with current_app.test_request_context(): - form = RegistrationForm(email='freddy@example.org', - username='b_meson', - password='dog', - password2='cat') + form = RegistrationForm( + email="freddy@example.org", + username="b_meson", + password="dog", + password2="cat", + ) rv = client.post( - url_for('auth.register'), - data=form.data, - follow_redirects=False + url_for("auth.register"), data=form.data, follow_redirects=False ) # Form will return 200 only if the form does not validate assert rv.status_code == 200 - assert b'Passwords must match' in rv.data + assert b"Passwords must match" in rv.data def test_user_can_register_with_legit_credentials(mockdata, client, session): with current_app.test_request_context(): - diceware_password = 'operative hamster perservere verbalize curling' - form = RegistrationForm(email='jen@example.com', - username='redshiftzero', - password=diceware_password, - password2=diceware_password) + diceware_password = "operative hamster perservere verbalize curling" + form = RegistrationForm( + email="jen@example.com", + username="redshiftzero", + password=diceware_password, + password2=diceware_password, + ) rv = client.post( - url_for('auth.register'), - data=form.data, - follow_redirects=True + url_for("auth.register"), data=form.data, follow_redirects=True ) - assert b'A confirmation email has been sent to you.' in rv.data + assert b"A confirmation email has been sent to you." in rv.data def test_user_cannot_register_with_weak_password(mockdata, client, session): with current_app.test_request_context(): - form = RegistrationForm(email='jen@example.com', - username='redshiftzero', - password='weak', - password2='weak') + form = RegistrationForm( + email="jen@example.com", + username="redshiftzero", + password="weak", + password2="weak", + ) rv = client.post( - url_for('auth.register'), - data=form.data, - follow_redirects=True + url_for("auth.register"), data=form.data, follow_redirects=True ) - assert b'A confirmation email has been sent to you.' not in rv.data + assert b"A confirmation email has been sent to you." not in rv.data def test_user_can_get_a_confirmation_token_resent(mockdata, client, session): with current_app.test_request_context(): login_user(client) - rv = client.get( - url_for('auth.resend_confirmation'), - follow_redirects=True - ) + rv = client.get(url_for("auth.resend_confirmation"), follow_redirects=True) - assert b'A new confirmation email has been sent to you.' in rv.data + assert b"A new confirmation email has been sent to you." in rv.data def test_user_can_get_password_reset_token_sent(mockdata, client, session): with current_app.test_request_context(): - form = PasswordResetRequestForm(email='jen@example.org') + form = PasswordResetRequestForm(email="jen@example.org") rv = client.post( - url_for('auth.password_reset_request'), + url_for("auth.password_reset_request"), data=form.data, - follow_redirects=True + follow_redirects=True, ) - assert b'An email with instructions to reset your password' in rv.data + assert b"An email with instructions to reset your password" in rv.data def test_user_can_get_reset_password_with_valid_token(mockdata, client, session): with current_app.test_request_context(): - form = PasswordResetForm(email='jen@example.org', - password='catdog', - password2='catdog') - user = User.query.filter_by(email='jen@example.org').one() + form = PasswordResetForm( + email="jen@example.org", password="catdog", password2="catdog" + ) + user = User.query.filter_by(email="jen@example.org").one() token = user.generate_reset_token() rv = client.post( - url_for('auth.password_reset', token=token), + url_for("auth.password_reset", token=token), data=form.data, - follow_redirects=True + follow_redirects=True, ) - assert b'Your password has been updated.' in rv.data + assert b"Your password has been updated." in rv.data def test_user_cannot_reset_password_with_invalid_token(mockdata, client, session): with current_app.test_request_context(): - form = PasswordResetForm(email='jen@example.org', - password='catdog', - password2='catdog') - token = 'beepboopbeep' + form = PasswordResetForm( + email="jen@example.org", password="catdog", password2="catdog" + ) + token = "beepboopbeep" rv = client.post( - url_for('auth.password_reset', token=token), + url_for("auth.password_reset", token=token), data=form.data, - follow_redirects=True + follow_redirects=True, ) - assert b'Your password has been updated.' not in rv.data + assert b"Your password has been updated." not in rv.data -def test_user_cannot_get_email_reset_token_sent_without_valid_password(mockdata, client, session): +def test_user_cannot_get_email_reset_token_sent_without_valid_password( + mockdata, client, session +): with current_app.test_request_context(): login_user(client) - form = ChangeEmailForm(email='jen@example.org', - password='dogdogdogdog') + form = ChangeEmailForm(email="jen@example.org", password="dogdogdogdog") rv = client.post( - url_for('auth.change_email_request'), - data=form.data, - follow_redirects=True + url_for("auth.change_email_request"), data=form.data, follow_redirects=True ) - assert b'An email with instructions to confirm your new email' not in rv.data + assert b"An email with instructions to confirm your new email" not in rv.data def test_user_can_get_email_reset_token_sent_with_password(mockdata, client, session): with current_app.test_request_context(): login_user(client) - form = ChangeEmailForm(email='alice@example.org', - password='dog') + form = ChangeEmailForm(email="alice@example.org", password="dog") rv = client.post( - url_for('auth.change_email_request'), - data=form.data, - follow_redirects=True + url_for("auth.change_email_request"), data=form.data, follow_redirects=True ) - assert b'An email with instructions to confirm your new email' in rv.data + assert b"An email with instructions to confirm your new email" in rv.data def test_user_can_change_email_with_valid_reset_token(mockdata, client, session): with current_app.test_request_context(): login_user(client) - user = User.query.filter_by(email='jen@example.org').one() - token = user.generate_email_change_token('alice@example.org') + user = User.query.filter_by(email="jen@example.org").one() + token = user.generate_email_change_token("alice@example.org") rv = client.get( - url_for('auth.change_email', token=token), - follow_redirects=True + url_for("auth.change_email", token=token), follow_redirects=True ) - assert b'Your email address has been updated.' in rv.data + assert b"Your email address has been updated." in rv.data def test_user_cannot_change_email_with_invalid_reset_token(mockdata, client, session): with current_app.test_request_context(): login_user(client) - token = 'beepboopbeep' + token = "beepboopbeep" rv = client.get( - url_for('auth.change_email', token=token), - follow_redirects=True + url_for("auth.change_email", token=token), follow_redirects=True ) - assert b'Your email address has been updated.' not in rv.data + assert b"Your email address has been updated." not in rv.data def test_user_can_confirm_account_with_valid_token(mockdata, client, session): with current_app.test_request_context(): login_unconfirmed_user(client) - user = User.query.filter_by(email='freddy@example.org').one() + user = User.query.filter_by(email="freddy@example.org").one() token = user.generate_confirmation_token() - rv = client.get( - url_for('auth.confirm', token=token), - follow_redirects=True - ) + rv = client.get(url_for("auth.confirm", token=token), follow_redirects=True) - assert b'You have confirmed your account.' in rv.data + assert b"You have confirmed your account." in rv.data -def test_user_can_not_confirm_account_with_invalid_token(mockdata, client, - session): +def test_user_can_not_confirm_account_with_invalid_token(mockdata, client, session): with current_app.test_request_context(): login_unconfirmed_user(client) - token = 'beepboopbeep' + token = "beepboopbeep" - rv = client.get( - url_for('auth.confirm', token=token), - follow_redirects=True - ) + rv = client.get(url_for("auth.confirm", token=token), follow_redirects=True) - assert b'The confirmation link is invalid or has expired.' in rv.data + assert b"The confirmation link is invalid or has expired." in rv.data def test_user_can_change_password_if_they_match(mockdata, client, session): with current_app.test_request_context(): login_user(client) - form = ChangePasswordForm(old_password='dog', - password='validpasswd', - password2='validpasswd') + form = ChangePasswordForm( + old_password="dog", password="validpasswd", password2="validpasswd" + ) rv = client.post( - url_for('auth.change_password'), - data=form.data, - follow_redirects=True + url_for("auth.change_password"), data=form.data, follow_redirects=True ) - assert b'Your password has been updated.' in rv.data + assert b"Your password has been updated." in rv.data def login_unconfirmed_user(client): - form = LoginForm(email='freddy@example.org', - password='dog', - remember_me=True) - rv = client.post( - url_for('auth.login'), - data=form.data, - follow_redirects=False - ) - assert b'Invalid username or password' not in rv.data + form = LoginForm(email="freddy@example.org", password="dog", remember_me=True) + rv = client.post(url_for("auth.login"), data=form.data, follow_redirects=False) + assert b"Invalid username or password" not in rv.data return rv -def test_unconfirmed_user_redirected_to_confirm_account(mockdata, client, - session): +def test_unconfirmed_user_redirected_to_confirm_account(mockdata, client, session): with current_app.test_request_context(): login_unconfirmed_user(client) - rv = client.get( - url_for('auth.unconfirmed'), - follow_redirects=False - ) + rv = client.get(url_for("auth.unconfirmed"), follow_redirects=False) - assert b'Please Confirm Your Account' in rv.data + assert b"Please Confirm Your Account" in rv.data -def test_user_cannot_change_password_if_they_dont_match(mockdata, client, - session): +def test_user_cannot_change_password_if_they_dont_match(mockdata, client, session): with current_app.test_request_context(): login_user(client) - form = ChangePasswordForm(old_password='dog', - password='cat', - password2='butts') + form = ChangePasswordForm(old_password="dog", password="cat", password2="butts") rv = client.post( - url_for('auth.change_password'), - data=form.data, - follow_redirects=True + url_for("auth.change_password"), data=form.data, follow_redirects=True ) - assert b'Passwords must match' in rv.data + assert b"Passwords must match" in rv.data def test_user_can_change_dept_pref(mockdata, client, session): @@ -345,12 +320,10 @@ def test_user_can_change_dept_pref(mockdata, client, session): form = ChangeDefaultDepartmentForm(dept_pref=test_department_id) rv = client.post( - url_for('auth.change_dept'), - data=form.data, - follow_redirects=True + url_for("auth.change_dept"), data=form.data, follow_redirects=True ) - assert b'Updated!' in rv.data + assert b"Updated!" in rv.data - user = User.query.filter_by(email='jen@example.org').one() + user = User.query.filter_by(email="jen@example.org").one() assert user.dept_pref == test_department_id diff --git a/OpenOversight/tests/routes/test_descriptions.py b/OpenOversight/tests/routes/test_descriptions.py index 02903c2ef..acf4bc047 100644 --- a/OpenOversight/tests/routes/test_descriptions.py +++ b/OpenOversight/tests/routes/test_descriptions.py @@ -1,29 +1,36 @@ -import pytest from datetime import datetime -from flask import url_for, current_app -from OpenOversight.tests.conftest import AC_DEPT -from .route_helpers import login_user, login_admin, login_ac +import pytest +from flask import current_app, url_for + +from OpenOversight.app.main.forms import EditTextForm, TextForm +from OpenOversight.app.models import Description, Officer, User, db +from OpenOversight.tests.conftest import AC_DEPT -from OpenOversight.app.main.forms import TextForm, EditTextForm -from OpenOversight.app.models import db, Officer, Description, User +from .route_helpers import login_ac, login_admin, login_user -@pytest.mark.parametrize("route", [ - ('officer/1/description/1/edit'), - ('officer/1/description/new'), - ('officer/1/description/1/delete') -]) +@pytest.mark.parametrize( + "route", + [ + ("officer/1/description/1/edit"), + ("officer/1/description/new"), + ("officer/1/description/1/delete"), + ], +) def test_route_login_required(route, client, mockdata): rv = client.get(route) assert rv.status_code == 302 -@pytest.mark.parametrize("route", [ - ('officer/1/description/1/edit'), - ('officer/1/description/new'), - ('officer/1/description/1/delete') -]) +@pytest.mark.parametrize( + "route", + [ + ("officer/1/description/1/edit"), + ("officer/1/description/new"), + ("officer/1/description/1/delete"), + ], +) def test_route_admin_or_required(route, client, mockdata): with current_app.test_request_context(): login_user(client) @@ -34,7 +41,7 @@ def test_route_admin_or_required(route, client, mockdata): def test_officer_descriptions_markdown(mockdata, client, session): with current_app.test_request_context(): login_user(client) - rv = client.get(url_for('main.officer_profile', officer_id=1)) + rv = client.get(url_for("main.officer_profile", officer_id=1)) assert rv.status_code == 200 html = rv.data.decode() print(html) @@ -46,22 +53,20 @@ def test_admins_cannot_inject_unsafe_html(mockdata, client, session): with current_app.test_request_context(): login_admin(client) officer = Officer.query.first() - text_contents = 'New description\n' - admin = User.query.filter_by(email='jen@example.org').first() + text_contents = "New description\n" + admin = User.query.filter_by(email="jen@example.org").first() form = TextForm( - text_contents=text_contents, - officer_id=officer.id, - creator_id=admin.id + text_contents=text_contents, officer_id=officer.id, creator_id=admin.id ) rv = client.post( - url_for('main.description_api', officer_id=officer.id), + url_for("main.description_api", officer_id=officer.id), data=form.data, - follow_redirects=True + follow_redirects=True, ) assert rv.status_code == 200 - assert 'created' in rv.data.decode('utf-8') + assert "created" in rv.data.decode("utf-8") assert "", - department='1', + department="1", address=address_form.data, links=links_forms, license_plates=license_plates_forms, - officers=ooid_forms + officers=ooid_forms, ) data = process_form_data(form.data) rv = client.post( - url_for('main.incident_api', obj_id=inc.id) + '/edit', + url_for("main.incident_api", obj_id=inc.id) + "/edit", data=data, - follow_redirects=True + follow_redirects=True, ) assert rv.status_code == 200 - assert 'successfully updated' in rv.data.decode('utf-8') + assert "successfully updated" in rv.data.decode("utf-8") assert "' - admin = User.query.filter_by(email='jen@example.org').first() + text_contents = "New note\n" + admin = User.query.filter_by(email="jen@example.org").first() form = TextForm( - text_contents=text_contents, - officer_id=officer.id, - creator_id=admin.id + text_contents=text_contents, officer_id=officer.id, creator_id=admin.id ) rv = client.post( - url_for('main.note_api', officer_id=officer.id), + url_for("main.note_api", officer_id=officer.id), data=form.data, - follow_redirects=True + follow_redirects=True, ) assert rv.status_code == 200 - assert 'created' in rv.data.decode('utf-8') + assert "created" in rv.data.decode("utf-8") assert " - {% endblock %} From 033b057023276f3cdd63247619080134f9af4098 Mon Sep 17 00:00:00 2001 From: sea-kelp <66500457+sea-kelp@users.noreply.github.com> Date: Wed, 5 Jan 2022 11:16:44 -0800 Subject: [PATCH 033/137] Make emails case-insensitive for user operations (OrcaCollective#102) * Make emails case-insensitive for user operations * Reuse case-insensitive comparison function Co-authored-by: Madison Swain-Bowden * Reference function correctly * Change to static methods Co-authored-by: Madison Swain-Bowden --- OpenOversight/app/auth/forms.py | 8 +-- OpenOversight/app/auth/views.py | 6 +- OpenOversight/app/commands.py | 4 +- OpenOversight/app/main/views.py | 4 +- OpenOversight/app/models.py | 14 +++- OpenOversight/tests/routes/test_auth.py | 89 ++++++++++++++++++++++++ OpenOversight/tests/routes/test_other.py | 15 ++++ 7 files changed, 128 insertions(+), 12 deletions(-) diff --git a/OpenOversight/app/auth/forms.py b/OpenOversight/app/auth/forms.py index ac2ed7d85..dbbbae343 100644 --- a/OpenOversight/app/auth/forms.py +++ b/OpenOversight/app/auth/forms.py @@ -46,11 +46,11 @@ class RegistrationForm(Form): submit = SubmitField("Register") def validate_email(self, field): - if User.query.filter_by(email=field.data).first(): + if User.by_email(field.data).first(): raise ValidationError("Email already registered.") def validate_username(self, field): - if User.query.filter_by(username=field.data).first(): + if User.by_username(field.data).first(): raise ValidationError("Username already in use.") @@ -86,7 +86,7 @@ class PasswordResetForm(Form): submit = SubmitField("Reset Password") def validate_email(self, field): - if User.query.filter_by(email=field.data).first() is None: + if User.by_email(field.data).first() is None: raise ValidationError("Unknown email address.") @@ -98,7 +98,7 @@ class ChangeEmailForm(Form): submit = SubmitField("Update Email Address") def validate_email(self, field): - if User.query.filter_by(email=field.data).first(): + if User.by_email(field.data).first(): raise ValidationError("Email already registered.") diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 560147f2b..16b7ca84a 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -60,7 +60,7 @@ def unconfirmed(): def login(): form = LoginForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() + user = User.by_email(form.email.data).first() if user is not None and user.verify_password(form.password.data): login_user(user, form.remember_me.data) return redirect(request.args.get("next") or url_for("main.index")) @@ -183,7 +183,7 @@ def password_reset_request(): return redirect(url_for("main.index")) form = PasswordResetRequestForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() + user = User.by_email(form.email.data).first() if user: token = user.generate_reset_token() send_email( @@ -209,7 +209,7 @@ def password_reset(token): return redirect(url_for("main.index")) form = PasswordResetForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() + user = User.by_email(form.email.data).first() if user is None: return redirect(url_for("main.index")) if user.reset_password(token, form.password.data): diff --git a/OpenOversight/app/commands.py b/OpenOversight/app/commands.py index 230d5f9ce..9e0d9cbd4 100644 --- a/OpenOversight/app/commands.py +++ b/OpenOversight/app/commands.py @@ -23,7 +23,7 @@ def make_admin_user(): """Add confirmed administrator account""" while True: username = input("Username: ") - user = User.query.filter_by(username=username).one_or_none() + user = User.by_username(username).one_or_none() if user: print("Username is already in use") else: @@ -31,7 +31,7 @@ def make_admin_user(): while True: email = input("Email: ") - user = User.query.filter_by(email=email).one_or_none() + user = User.by_email(email).one_or_none() if user: print("Email address already in use") else: diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index d578bd7b4..2428fb2c5 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -173,7 +173,7 @@ def get_ooid(): def get_started_labeling(): form = LoginForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() + user = User.by_email(form.email.data).first() if user is not None and user.verify_password(form.password.data): login_user(user, form.remember_me.data) return redirect(request.args.get("next") or url_for("main.index")) @@ -212,7 +212,7 @@ def get_tutorial(): @login_required def profile(username): if re.search("^[A-Za-z][A-Za-z0-9_.]*$", username): - user = User.query.filter_by(username=username).one() + user = User.by_username(username).one() else: abort(404) try: diff --git a/OpenOversight/app/models.py b/OpenOversight/app/models.py index 5eb15218f..693fa82bf 100644 --- a/OpenOversight/app/models.py +++ b/OpenOversight/app/models.py @@ -7,7 +7,7 @@ from flask_sqlalchemy.model import DefaultMeta from itsdangerous import BadData, BadSignature from itsdangerous import TimedJSONWebSignatureSerializer as Serializer -from sqlalchemy import CheckConstraint, UniqueConstraint +from sqlalchemy import CheckConstraint, UniqueConstraint, func from sqlalchemy.orm import validates from werkzeug.security import check_password_hash, generate_password_hash @@ -515,6 +515,18 @@ def password(self): def password(self, password): self.password_hash = generate_password_hash(password, method="pbkdf2:sha256") + @staticmethod + def _case_insensitive_equality(field, value): + return User.query.filter(func.lower(field) == func.lower(value)) + + @staticmethod + def by_email(email): + return User._case_insensitive_equality(User.email, email) + + @staticmethod + def by_username(username): + return User._case_insensitive_equality(User.username, username) + def verify_password(self, password): return check_password_hash(self.password_hash, password) diff --git a/OpenOversight/tests/routes/test_auth.py b/OpenOversight/tests/routes/test_auth.py index 5df19d00d..b67f3b7ef 100644 --- a/OpenOversight/tests/routes/test_auth.py +++ b/OpenOversight/tests/routes/test_auth.py @@ -60,6 +60,14 @@ def test_valid_user_can_login(mockdata, client, session): assert urlparse(rv.location).path == "/index" +def test_valid_user_can_login_with_email_differently_cased(mockdata, client, session): + with current_app.test_request_context(): + form = LoginForm(email="JEN@EXAMPLE.ORG", password="dog", remember_me=True) + rv = client.post(url_for("auth.login"), data=form.data, follow_redirects=False) + assert rv.status_code == 302 + assert urlparse(rv.location).path == "/index" + + def test_invalid_user_cannot_login(mockdata, client, session): with current_app.test_request_context(): form = LoginForm( @@ -94,6 +102,25 @@ def test_user_cannot_register_with_existing_email(mockdata, client, session): assert b"Email already registered" in rv.data +def test_user_cannot_register_with_existing_email_differently_cased( + mockdata, client, session +): + with current_app.test_request_context(): + form = RegistrationForm( + email="JEN@EXAMPLE.ORG", + username="redshiftzero", + password="dog", + password2="dog", + ) + rv = client.post( + url_for("auth.register"), data=form.data, follow_redirects=False + ) + + # Form will return 200 only if the form does not validate + assert rv.status_code == 200 + assert b"Email already registered" in rv.data + + def test_user_cannot_register_if_passwords_dont_match(mockdata, client, session): with current_app.test_request_context(): form = RegistrationForm( @@ -164,6 +191,21 @@ def test_user_can_get_password_reset_token_sent(mockdata, client, session): assert b"An email with instructions to reset your password" in rv.data +def test_user_can_get_password_reset_token_sent_with_differently_cased_email( + mockdata, client, session +): + with current_app.test_request_context(): + form = PasswordResetRequestForm(email="JEN@EXAMPLE.ORG") + + rv = client.post( + url_for("auth.password_reset_request"), + data=form.data, + follow_redirects=True, + ) + + assert b"An email with instructions to reset your password" in rv.data + + def test_user_can_get_reset_password_with_valid_token(mockdata, client, session): with current_app.test_request_context(): form = PasswordResetForm( @@ -181,6 +223,25 @@ def test_user_can_get_reset_password_with_valid_token(mockdata, client, session) assert b"Your password has been updated." in rv.data +def test_user_can_get_reset_password_with_valid_token_differently_cased( + mockdata, client, session +): + with current_app.test_request_context(): + form = PasswordResetForm( + email="JEN@EXAMPLE.ORG", password="catdog", password2="catdog" + ) + user = User.query.filter_by(email="jen@example.org").one() + token = user.generate_reset_token() + + rv = client.post( + url_for("auth.password_reset", token=token), + data=form.data, + follow_redirects=True, + ) + + assert b"Your password has been updated." in rv.data + + def test_user_cannot_reset_password_with_invalid_token(mockdata, client, session): with current_app.test_request_context(): form = PasswordResetForm( @@ -211,6 +272,34 @@ def test_user_cannot_get_email_reset_token_sent_without_valid_password( assert b"An email with instructions to confirm your new email" not in rv.data +def test_user_cannot_get_email_reset_token_sent_to_existing_email( + mockdata, client, session +): + with current_app.test_request_context(): + login_user(client) + form = ChangeEmailForm(email="freddy@example.org", password="dogdogdogdog") + + rv = client.post( + url_for("auth.change_email_request"), data=form.data, follow_redirects=True + ) + + assert b"An email with instructions to confirm your new email" not in rv.data + + +def test_user_cannot_get_email_reset_token_sent_to_existing_email_differently_cased( + mockdata, client, session +): + with current_app.test_request_context(): + login_user(client) + form = ChangeEmailForm(email="FREDDY@EXAMPLE.ORG", password="dogdogdogdog") + + rv = client.post( + url_for("auth.change_email_request"), data=form.data, follow_redirects=True + ) + + assert b"An email with instructions to confirm your new email" not in rv.data + + def test_user_can_get_email_reset_token_sent_with_password(mockdata, client, session): with current_app.test_request_context(): login_user(client) diff --git a/OpenOversight/tests/routes/test_other.py b/OpenOversight/tests/routes/test_other.py index 57b1158d7..b08844cc7 100644 --- a/OpenOversight/tests/routes/test_other.py +++ b/OpenOversight/tests/routes/test_other.py @@ -36,3 +36,18 @@ def test_user_can_access_profile(mockdata, client, session): assert "User Email" not in rv.data.decode("utf-8") # Toggle button should not appear for this non-admin user assert "Edit User" not in rv.data.decode("utf-8") + + +def test_user_can_access_profile_differently_cased(mockdata, client, session): + with current_app.test_request_context(): + login_user(client) + + rv = client.get( + url_for("main.profile", username="TEST_USER"), follow_redirects=True + ) + assert "test_user" in rv.data.decode("utf-8") + assert "User Email" not in rv.data.decode("utf-8") + assert "Edit User" not in rv.data.decode("utf-8") + + # Should use username in db + assert "TEST_USER" not in rv.data.decode("utf-8") From b7e1bf77f7944c81263c02d191c7ebc8dd67f6e7 Mon Sep 17 00:00:00 2001 From: sea-kelp <66500457+sea-kelp@users.noreply.github.com> Date: Wed, 5 Jan 2022 11:29:42 -0800 Subject: [PATCH 034/137] Automatically send confirmation email when a user is approved (OrcaCollective#103) * Automatically send confirmation email when a user is approved * Fixed bug incorrectly getting previous approval status * Update OpenOversight/tests/routes/test_user_api.py * Fixed tests for LPL instance. Co-authored-by: Madison Swain-Bowden Co-authored-by: abandoned-prototype <41744410+abandoned-prototype@users.noreply.github.com> --- OpenOversight/app/auth/forms.py | 2 +- OpenOversight/app/auth/views.py | 15 ++++-- OpenOversight/tests/routes/test_user_api.py | 53 +++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/OpenOversight/app/auth/forms.py b/OpenOversight/app/auth/forms.py index dbbbae343..9a7d5f072 100644 --- a/OpenOversight/app/auth/forms.py +++ b/OpenOversight/app/auth/forms.py @@ -129,7 +129,7 @@ class EditUserForm(Form): ) is_disabled = BooleanField("Disabled?", false_values={"False", "false", ""}) approved = BooleanField("Approved?", false_values={"False", "false", ""}) - confirmed = BooleanField("Confirmed?", false_values={"False,", "false", ""}) + confirmed = BooleanField("Confirmed?", false_values={"False", "false", ""}) submit = SubmitField(label="Update", false_values={"False", "false", ""}) resend = SubmitField(label="Resend", false_values={"False", "false", ""}) delete = SubmitField(label="Delete", false_values={"False", "false", ""}) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 16b7ca84a..83b88824c 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -316,17 +316,22 @@ def edit_user(user_id): flash("You cannot edit your own account!") form = EditUserForm(obj=user) return render_template("auth/user.html", user=user, form=form) + already_approved = user.approved + form.populate_obj(user) + db.session.add(user) + db.session.commit() + + # automatically send a confirmation email when approving an unconfirmed user if ( current_app.config["APPROVE_REGISTRATIONS"] - and form.approved.data - and not user.approved + and not already_approved + and user.approved and not user.confirmed ): admin_resend_confirmation(user) - form.populate_obj(user) - db.session.add(user) - db.session.commit() + flash("{} has been updated!".format(user.username)) + return redirect(url_for("auth.edit_user", user_id=user.id)) else: flash("Invalid entry") diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index 43eec9d3c..949fa5655 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -315,3 +315,56 @@ def test_admin_can_approve_user(mockdata, client, session): user = User.query.get(user_id) assert user.approved + + +@pytest.mark.parametrize( + "currently_approved, currently_confirmed, approve_registration_config, should_send_email", + [ + # Approving unconfirmed user sends email + (False, False, True, True), + # Approving unconfirmed user does not send email if approve_registration config is not set + (False, False, False, False), + # Updating approved user does not send email + (True, False, True, False), + # Approving confirmed user does not send email + (False, True, True, False), + ], +) +def test_admin_approval_sends_confirmation_email( + currently_approved, + currently_confirmed, + should_send_email, + approve_registration_config, + mockdata, + client, + session, +): + current_app.config["APPROVE_REGISTRATIONS"] = approve_registration_config + with current_app.test_request_context(): + login_admin(client) + + user = User.query.filter_by(is_administrator=False).first() + user_id = user.id + user.approved = currently_approved + user.confirmed = currently_confirmed + db.session.commit() + + user = User.query.get(user_id) + assert user.approved == currently_approved + assert user.confirmed == currently_confirmed + + form = EditUserForm(approved=True, submit=True, confirmed=currently_confirmed) + + rv = client.post( + url_for("auth.edit_user", user_id=user_id), + data=form.data, + follow_redirects=True, + ) + + assert ( + "new confirmation email" in rv.data.decode("utf-8") + ) == should_send_email + assert "updated!" in rv.data.decode("utf-8") + + user = User.query.get(user_id) + assert user.approved From 45e46616a2f67a96427179758037a626f05501ff Mon Sep 17 00:00:00 2001 From: sea-kelp <66500457+sea-kelp@users.noreply.github.com> Date: Sat, 8 Jan 2022 00:27:28 -0800 Subject: [PATCH 035/137] Replace itsdangerous JWS with authlib (OrcaCollective#106) * Replace itsdangerous JWS with authlib * Refactor SECRET_KEY into _jwt_encode/_jwt_decode --- OpenOversight/app/models.py | 50 ++++++++++++++++++------------ OpenOversight/tests/test_models.py | 27 ++++++++++++++++ requirements.txt | 1 + 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/OpenOversight/app/models.py b/OpenOversight/app/models.py index 693fa82bf..2e7fbf13e 100644 --- a/OpenOversight/app/models.py +++ b/OpenOversight/app/models.py @@ -1,12 +1,12 @@ import re +import time from datetime import date +from authlib.jose import JoseError, JsonWebToken from flask import current_app from flask_login import UserMixin from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy.model import DefaultMeta -from itsdangerous import BadData, BadSignature -from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from sqlalchemy import CheckConstraint, UniqueConstraint, func from sqlalchemy.orm import validates from werkzeug.security import check_password_hash, generate_password_hash @@ -16,6 +16,7 @@ db = SQLAlchemy() +jwt = JsonWebToken("HS512") BaseModel = db.Model # type: DefaultMeta @@ -507,6 +508,22 @@ class User(UserMixin, BaseModel): classifications = db.relationship("Image", backref="users") tags = db.relationship("Face", backref="users") + def _jwt_encode(self, payload, expiration): + secret = current_app.config["SECRET_KEY"] + header = {"alg": "HS512"} + + now = int(time.time()) + payload["iat"] = now + payload["exp"] = now + expiration + + return jwt.encode(header, payload, secret) + + def _jwt_decode(self, token): + secret = current_app.config["SECRET_KEY"] + token = jwt.decode(token, secret) + token.validate() + return token + @property def password(self): raise AttributeError("password is not a readable attribute") @@ -531,14 +548,13 @@ def verify_password(self, password): return check_password_hash(self.password_hash, password) def generate_confirmation_token(self, expiration=3600): - s = Serializer(current_app.config["SECRET_KEY"], expiration) - return s.dumps({"confirm": self.id}).decode("utf-8") + payload = {"confirm": self.id} + return self._jwt_encode(payload, expiration).decode("utf-8") def confirm(self, token): - s = Serializer(current_app.config["SECRET_KEY"]) try: - data = s.loads(token) - except (BadSignature, BadData) as e: + data = self._jwt_decode(token) + except JoseError as e: current_app.logger.warning("failed to decrypt token: %s", e) return False if data.get("confirm") != self.id: @@ -552,14 +568,13 @@ def confirm(self, token): return True def generate_reset_token(self, expiration=3600): - s = Serializer(current_app.config["SECRET_KEY"], expiration) - return s.dumps({"reset": self.id}).decode("utf-8") + payload = {"reset": self.id} + return self._jwt_encode(payload, expiration).decode("utf-8") def reset_password(self, token, new_password): - s = Serializer(current_app.config["SECRET_KEY"]) try: - data = s.loads(token) - except (BadSignature, BadData): + data = self._jwt_decode(token) + except JoseError: return False if data.get("reset") != self.id: return False @@ -568,16 +583,13 @@ def reset_password(self, token, new_password): return True def generate_email_change_token(self, new_email, expiration=3600): - s = Serializer(current_app.config["SECRET_KEY"], expiration) - return s.dumps({"change_email": self.id, "new_email": new_email}).decode( - "utf-8" - ) + payload = {"change_email": self.id, "new_email": new_email} + return self._jwt_encode(payload, expiration).decode("utf-8") def change_email(self, token): - s = Serializer(current_app.config["SECRET_KEY"]) try: - data = s.loads(token) - except (BadSignature, BadData): + data = self._jwt_decode(token) + except JoseError: return False if data.get("change_email") != self.id: return False diff --git a/OpenOversight/tests/test_models.py b/OpenOversight/tests/test_models.py index f5b31052f..531e5c102 100644 --- a/OpenOversight/tests/test_models.py +++ b/OpenOversight/tests/test_models.py @@ -158,6 +158,15 @@ def test_invalid_reset_token(mockdata): assert user2.verify_password("vegan bacon") is True +def test_expired_reset_token(mockdata): + user = User(password="bacon") + db.session.add(user) + db.session.commit() + token = user.generate_reset_token(expiration=-1) + assert user.reset_password(token, "tempeh") is False + assert user.verify_password("bacon") is True + + def test_valid_email_change_token(mockdata): user = User(email="brian@example.com", password="bacon") db.session.add(user) @@ -167,6 +176,15 @@ def test_valid_email_change_token(mockdata): assert user.email == "lucy@example.org" +def test_email_change_token_no_email(mockdata): + user = User(email="brian@example.com", password="bacon") + db.session.add(user) + db.session.commit() + token = user.generate_email_change_token(None) + assert user.change_email(token) is False + assert user.email == "brian@example.com" + + def test_invalid_email_change_token(mockdata): user1 = User(email="jen@example.com", password="cat") user2 = User(email="freddy@example.com", password="dog") @@ -178,6 +196,15 @@ def test_invalid_email_change_token(mockdata): assert user2.email == "freddy@example.com" +def test_expired_email_change_token(mockdata): + user = User(email="jen@example.com", password="cat") + db.session.add(user) + db.session.commit() + token = user.generate_email_change_token("mason@example.net", expiration=-1) + assert user.change_email(token) is False + assert user.email == "jen@example.com" + + def test_duplicate_email_change_token(mockdata): user1 = User(email="alice@example.com", password="cat") user2 = User(email="bob@example.org", password="dog") diff --git a/requirements.txt b/requirements.txt index e4f02a419..b770fa3f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +Authlib==0.15.5 bleach==3.3.1 bleach-allowlist==1.0.3 boto3==1.5.10 From c7bb5dd989518eb7210573d006b2069b67609209 Mon Sep 17 00:00:00 2001 From: sea-kelp <66500457+sea-kelp@users.noreply.github.com> Date: Sat, 8 Jan 2022 19:59:53 -0800 Subject: [PATCH 036/137] Display readable errors instead of html (OrcaCollective#107) * Display readable errors instead of html * Refactor Dropzone js into new file and add comments * Fix lint --- OpenOversight/app/__init__.py | 42 ++++++++++++------- OpenOversight/app/main/views.py | 11 +++-- OpenOversight/app/static/js/init-dropzone.js | 36 ++++++++++++++++ OpenOversight/app/templates/413.html | 12 ++++++ OpenOversight/app/templates/submit_image.html | 30 +++---------- .../app/templates/submit_officer_image.html | 23 +++------- 6 files changed, 95 insertions(+), 59 deletions(-) create mode 100644 OpenOversight/app/static/js/init-dropzone.js create mode 100644 OpenOversight/app/templates/413.html diff --git a/OpenOversight/app/__init__.py b/OpenOversight/app/__init__.py index d5154495f..08df20eff 100644 --- a/OpenOversight/app/__init__.py +++ b/OpenOversight/app/__init__.py @@ -6,7 +6,7 @@ import bleach import markdown as _markdown from bleach_allowlist import markdown_attrs, markdown_tags -from flask import Flask, render_template +from flask import Flask, jsonify, render_template, request from flask_bootstrap import Bootstrap from flask_limiter import Limiter from flask_limiter.util import get_remote_address @@ -77,21 +77,33 @@ def create_app(config_name="default"): # Also log when endpoints are getting hit hard limiter.logger.addHandler(file_handler) - @app.errorhandler(404) - def page_not_found(e): - return render_template("404.html"), 404 - - @app.errorhandler(403) - def forbidden(e): - return render_template("403.html"), 403 - - @app.errorhandler(500) - def internal_error(e): - return render_template("500.html"), 500 + # Define error handlers + def create_errorhandler(code, error, template): + """ + Create an error handler that returns a JSON or a template response + based on the request "Accept" header. + :param code: status code to handle + :param error: response error message, if JSON + :param template: template response + """ - @app.errorhandler(429) - def rate_exceeded(e): - return render_template("429.html"), 429 + def _handler_method(e): + if request.accept_mimetypes.best == "application/json": + return jsonify(error=error), code + return render_template(template), code + + return _handler_method + + error_handlers = [ + (403, "Forbidden", "403.html"), + (404, "Not found", "404.html"), + (413, "File too large", "413.html"), + (429, "Too many requests", "429.html"), + (500, "Internal Server Error", "500.html"), + ] + for code, error, template in error_handlers: + # Pass generated errorhandler function to @app.errorhandler decorator + app.errorhandler(code)(create_errorhandler(code, error, template)) # create jinja2 filter for titles with multiple capitals @app.template_filter("capfirst") diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 2428fb2c5..5bd28173a 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -1310,9 +1310,14 @@ def upload(department_id, officer_id=None): file_to_upload = request.files["file"] if not allowed_file(file_to_upload.filename): return jsonify(error="File type not allowed!"), 415 - image = upload_image_to_s3_and_store_in_db( - file_to_upload, current_user.get_id(), department_id=department_id - ) + + try: + image = upload_image_to_s3_and_store_in_db( + file_to_upload, current_user.get_id(), department_id=department_id + ) + except ValueError: + # Raised if MIME type not allowed + return jsonify(error="Invalid data type!"), 415 if image: db.session.add(image) diff --git a/OpenOversight/app/static/js/init-dropzone.js b/OpenOversight/app/static/js/init-dropzone.js new file mode 100644 index 000000000..a51e130bb --- /dev/null +++ b/OpenOversight/app/static/js/init-dropzone.js @@ -0,0 +1,36 @@ +/** + * Initialize dropzone component + * @param id element id + * @param url url to upload to + * @param csrf_token CSRF token + * @return the Dropzone object + */ +function init_dropzone(id, url, csrf_token) { + Dropzone.autoDiscover = false; + + let myDropzone = new Dropzone(id, { + url: url, + method: "POST", + uploadMultiple: false, + parallelUploads: 50, + acceptedFiles: "image/png, image/jpeg, image/gif, image/jpg, image/webp", + maxFiles: 50, + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrf_token + }, + init: function() { + this.on("error", function(file, response) { + if (typeof(response) == "object") { + response = response.error; + } + if (response.startsWith(" + +

    File Too Large

    +

    The file you are trying to upload is too large. Return to homepage.

    + +
    +{% endblock %} diff --git a/OpenOversight/app/templates/submit_image.html b/OpenOversight/app/templates/submit_image.html index 84ade1205..05f78b82b 100644 --- a/OpenOversight/app/templates/submit_image.html +++ b/OpenOversight/app/templates/submit_image.html @@ -39,8 +39,9 @@

    Drop images here to submit photos of officers:

    Drag photographs from your computer directly into the box above or click the box to launch a finder window. If you are on mobile, you can click the box above to select pictures from your photo library or camera roll.

    - - + + + + const getURL = (file) => "/upload/department/" + dept_id; + init_dropzone("#my-cop-dropzone", getURL, csrf_token); +

    diff --git a/OpenOversight/app/templates/submit_officer_image.html b/OpenOversight/app/templates/submit_officer_image.html index 1dda913fd..d47e3eafe 100644 --- a/OpenOversight/app/templates/submit_officer_image.html +++ b/OpenOversight/app/templates/submit_officer_image.html @@ -28,24 +28,13 @@

    Drop images here to submit photos of {{ officer.full_name() }} in {{ officer {% block js_footer %} + {% endblock %} From e40515efcdc734486308ef754fde2e49840e265d Mon Sep 17 00:00:00 2001 From: abandoned-prototype <41744410+abandoned-prototype@users.noreply.github.com> Date: Sat, 26 Nov 2022 18:31:16 -0600 Subject: [PATCH 037/137] Make markup not ignore new lines. --- OpenOversight/app/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/OpenOversight/app/__init__.py b/OpenOversight/app/__init__.py index 08df20eff..6c1b8d6ec 100644 --- a/OpenOversight/app/__init__.py +++ b/OpenOversight/app/__init__.py @@ -125,6 +125,7 @@ class which will render the field accordion open. @app.template_filter("markdown") def markdown(text): + text = text.replace("\n", " \n") # make markdown not ignore new lines. html = bleach.clean(_markdown.markdown(text), markdown_tags, markdown_attrs) return Markup(html) From 028ade5f32751632def2113e12cb722a2300ac89 Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sat, 8 Jan 2022 21:08:35 -0800 Subject: [PATCH 038/137] Incidents, improved detail formatting (OrcaCollective#99) * Documentation & comments * Rearrange partials, force column ordering * Improve long-form text layout * Give the incident detail page more padding on the bottom * Add ellipses to cut off text * Only show unique internal identifier if it exists * Add "number of known incidents" to general information section * Allow spaces in incident names (but no other special characters) * Add known incident count to officer list * Fix the functional tests based on new description cutoff * Remove conditional for known incidents --- OpenOversight/app/main/forms.py | 4 +- .../app/static/js/incidentDescription.js | 12 +- .../app/templates/incident_detail.html | 32 +-- OpenOversight/app/templates/list_officer.html | 2 + OpenOversight/app/templates/officer.html | 40 ++-- .../templates/partials/incident_fields.html | 2 +- .../partials/links_and_videos_row.html | 216 +++++++++--------- .../partials/officer_general_information.html | 6 + OpenOversight/tests/conftest.py | 5 +- OpenOversight/tests/description_overflow.txt | 6 + OpenOversight/tests/routes/test_incidents.py | 51 ++++- OpenOversight/tests/test_functional.py | 19 +- 12 files changed, 224 insertions(+), 171 deletions(-) create mode 100644 OpenOversight/tests/description_overflow.txt diff --git a/OpenOversight/app/main/forms.py b/OpenOversight/app/main/forms.py index 2d8fc45aa..c8c508d48 100644 --- a/OpenOversight/app/main/forms.py +++ b/OpenOversight/app/main/forms.py @@ -514,8 +514,8 @@ class IncidentForm(DateFieldForm): report_number = StringField( validators=[ Regexp( - r"^[a-zA-Z0-9-]*$", - message="Report numbers can contain letters, numbers, and dashes", + r"^[a-zA-Z0-9- ]*$", + message="Report cannot contain special characters (dashes permitted)", ) ], description="Incident number for the organization tracking incidents", diff --git a/OpenOversight/app/static/js/incidentDescription.js b/OpenOversight/app/static/js/incidentDescription.js index 23c5155f8..dfce6dc83 100644 --- a/OpenOversight/app/static/js/incidentDescription.js +++ b/OpenOversight/app/static/js/incidentDescription.js @@ -1,15 +1,17 @@ $(document).ready(function() { + let overflow_length = 500; $(".incident-description").each(function () { let description = this; let incidentId = $( this ).data("incident"); - if (description.innerText.length < 300) { + if (description.innerHTML.length < overflow_length) { $("#description-overflow-row_" + incidentId).hide(); } - if(description.innerText.length > 300) { - let originalDescription = description.innerText; - description.innerText = description.innerText.substring(0, 300); + if(description.innerHTML.length > overflow_length) { + let originalDescription = description.innerHTML; + description.innerHTML = description.innerHTML.substring(0, overflow_length) + "…"; $(`#description-overflow-button_${incidentId}`).on('click', function(event) { - description.innerText = originalDescription; + event.stopImmediatePropagation(); + description.innerHTML = originalDescription; $("#description-overflow-row_" + incidentId).hide(); }) } diff --git a/OpenOversight/app/templates/incident_detail.html b/OpenOversight/app/templates/incident_detail.html index 5e14e67a5..0949ca779 100644 --- a/OpenOversight/app/templates/incident_detail.html +++ b/OpenOversight/app/templates/incident_detail.html @@ -2,13 +2,13 @@ {% set incident = obj %} {% block title %}{{ incident.department.name }} incident{% if incident.report_number %} {{incident.report_number}}{% endif %} - OpenOversight{% endblock %} {% block meta %} -{% if incident.description != None %} - -{% else %} - -{% endif %} - - {% endblock %} {% block content %} -
    +
    All Incidents {% if incident.department %}

    @@ -58,12 +58,12 @@

    Incident Description

    {% include 'partials/links_and_videos_row.html' %} {% if current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} -
    -
    - Edit - Delete -
    -
    - {% endif %} -
    +
    +
    + Edit + Delete +
    +
    + {% endif %} +
    {% endblock %} diff --git a/OpenOversight/app/templates/list_officer.html b/OpenOversight/app/templates/list_officer.html index f6c3714d8..2946701e3 100644 --- a/OpenOversight/app/templates/list_officer.html +++ b/OpenOversight/app/templates/list_officer.html @@ -160,6 +160,8 @@

    {{ officer.unit_descrip()|default('Unknown') }}
    Currently on the Force
    {{ officer.currently_on_force() }}
    +
    Known incidents
    +
    {{ officer.incidents | length }}

    diff --git a/OpenOversight/app/templates/officer.html b/OpenOversight/app/templates/officer.html index 604377ff3..ef5053a3e 100644 --- a/OpenOversight/app/templates/officer.html +++ b/OpenOversight/app/templates/officer.html @@ -109,35 +109,27 @@

    Officer Detail: {{ officer.full_name() }}

    {% include "partials/officer_assignment_history.html" %} + {% if officer.descriptions or is_admin_or_coordinator %} + {% include "partials/officer_descriptions.html" %} + {% endif %} + {# Notes are for internal use #} + {% if is_admin_or_coordinator %} + {% include "partials/officer_notes.html" %} + {% endif %} + {% with obj=officer %} + {% include "partials/links_and_videos_row.html" %} + {% endwith %}
    {# end col #} - {% if officer.salaries or is_admin_or_coordinator %} -
    +
    + {% if officer.salaries or is_admin_or_coordinator %} {% include "partials/officer_salary.html" %} -
    {# end col #} - {% endif %} - - {% if officer.incidents or is_admin_or_coordinator %} -
    + {% endif %} + {% if officer.incidents or is_admin_or_coordinator %} {% include "partials/officer_incidents.html" %} -
    {# end col #} - {% endif %} - - {% if officer.descriptions or is_admin_or_coordinator %} -
    - {% include "partials/officer_descriptions.html" %} -
    {# end col #} - {% endif %} - - {# Notes are for internal use #} - {% if is_admin_or_coordinator %} -
    - {% include "partials/officer_notes.html" %} + {% endif %}
    {# end col #} - {% endif %} +
    {# end row #} - {% with obj=officer %} - {% include "partials/links_and_videos_row.html" %} - {% endwith %}
    {# end container #} {% endblock %} diff --git a/OpenOversight/app/templates/partials/incident_fields.html b/OpenOversight/app/templates/partials/incident_fields.html index eacd4d1b8..2fd9c1a73 100644 --- a/OpenOversight/app/templates/partials/incident_fields.html +++ b/OpenOversight/app/templates/partials/incident_fields.html @@ -94,5 +94,5 @@ {% block js_footer %} - + {% endblock %} diff --git a/OpenOversight/app/templates/partials/links_and_videos_row.html b/OpenOversight/app/templates/partials/links_and_videos_row.html index b29bc3de9..b18353015 100644 --- a/OpenOversight/app/templates/partials/links_and_videos_row.html +++ b/OpenOversight/app/templates/partials/links_and_videos_row.html @@ -1,125 +1,117 @@ {% if obj.links|length > 0 or is_admin_or_coordinator %} -
    -
    -

    Links

    - {% for type, list in obj.links|groupby('link_type') %} - {% if type == 'link' %} -
      - {% for link in list %} -
    • - {{ link.title or link.url }} - {% if officer and (is_admin_or_coordinator or link.creator_id == current_user.id) %} - {{ link.title or link.url }} + {% if officer and (is_admin_or_coordinator or link.creator_id == current_user.id) %} + - Edit - - - + - Delete - - - {% endif %} - {% if link.description or link.author %} -
      - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
      - {% endif %} -
    • - {% endfor %} -
    - {% endif %} - {% endfor %} - {% if officer and (current_user.is_administrator + Delete + + + {% endif %} + {% if link.description or link.author %} +
    + {% if link.description %} + {{ link.description }} + {% endif %} + {% if link.author %} + {% if link.description %}- {% endif %}{{ link.author }} + {% endif %} +
    + {% endif %} + + {% endfor %} + + {% endif %} + {% endfor %} + {% if officer and (current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id)) %} - New Link/Video - {% endif %} -
    - {% for type, list in obj.links|groupby('link_type') %} - {% if type == 'video' %} -
    -

    Videos

    -
      - {% for link in list %} - {% with link_url = link.url.split('v=')[1] %} -
    • - {% if link.title %} -
      {{ link.title }}
      - {% endif %} - {% if officer and (current_user.is_administrator + New Link/Video + {% endif %} + {% for type, list in obj.links|groupby('link_type') %} + {% if type == 'video' %} +

      Videos

      +
        + {% for link in list %} + {% with link_url = link.url.split('v=')[1] %} +
      • + {% if link.title %} +
        {{ link.title }}
        + {% endif %} + {% if officer and (current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) or link.creator_id == current_user.id) %} - - Edit - - - + - Delete - - - {% endif %} -
        - -
        - {% if link.description or link.author %} -
        - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
        - {% endif %} -
      • - {% endwith%} - {% endfor %} -
      -
    - {% endif %} - {% if type == 'other_video' %} -
    -

    Other videos

    -
      - {% for link in list %} -
    • - {{ link.title or link.url }} - {% if officer and (current_user.is_administrator + Delete + + + {% endif %} +
      + +
      + {% if link.description or link.author %} +
      + {% if link.description %} + {{ link.description }} + {% endif %} + {% if link.author %} + {% if link.description %}- {% endif %}{{ link.author }} + {% endif %} +
      + {% endif %} +
    • + {% endwith%} + {% endfor %} +
    + {% endif %} + {% if type == 'other_video' %} +

    Other videos

    +
      + {% for link in list %} +
    • + {{ link.title or link.url }} + {% if officer and (current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) or link.creator_id == current_user.id) %} - - Edit - - - + - Delete - - - {% endif %} - {% if link.description or link.author %} -
      - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
      - {% endif %} -
    • - {% endfor %} -
    -
    - {% endif %} - {% endfor %} -
    {# end row #} + Delete + + + {% endif %} + {% if link.description or link.author %} +
    + {% if link.description %} + {{ link.description }} + {% endif %} + {% if link.author %} + {% if link.description %}- {% endif %}{{ link.author }} + {% endif %} +
    + {% endif %} + + {% endfor %} + + {% endif %} + {% endfor %} {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_general_information.html b/OpenOversight/app/templates/partials/officer_general_information.html index ee42d4cb2..6269b3b41 100644 --- a/OpenOversight/app/templates/partials/officer_general_information.html +++ b/OpenOversight/app/templates/partials/officer_general_information.html @@ -18,10 +18,12 @@

    OpenOversight ID {{ officer.id }} + {% if officer.unique_internal_identifier %} Unique Internal Identifier {{ officer.unique_internal_identifier }} + {% endif %} Department {{ officer.department.name }} @@ -48,6 +50,10 @@

    First Employment Date {{ officer.employment_date }} + + Number of known incidents + {{ officer.incidents | length }} + Currently on the force {{ officer.currently_on_force() }} diff --git a/OpenOversight/tests/conftest.py b/OpenOversight/tests/conftest.py index e07316d74..03564d7a9 100644 --- a/OpenOversight/tests/conftest.py +++ b/OpenOversight/tests/conftest.py @@ -7,6 +7,7 @@ import time import uuid from io import BytesIO +from pathlib import Path from typing import List import pytest @@ -486,7 +487,9 @@ def add_mockdata(session): models.Incident( date=datetime.datetime(2019, 1, 15), report_number="39", - description="A test description that has over 300 chars. The purpose is to see how to display a larger descrption. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Descriptions can get lengthy. So lengthy. It is a description with a lot to say. Lengthy lengthy lengthy.", + description=( + Path(__file__).parent / "description_overflow.txt" + ).read_text(), department_id=2, address=test_addresses[1], license_plates=[test_license_plates[0]], diff --git a/OpenOversight/tests/description_overflow.txt b/OpenOversight/tests/description_overflow.txt new file mode 100644 index 000000000..6aa6bc19f --- /dev/null +++ b/OpenOversight/tests/description_overflow.txt @@ -0,0 +1,6 @@ +A test description that has over 500 chars. +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam gravida ante sed dui venenatis sollicitudin. Fusce id diam aliquam, tincidunt dolor sit amet, fermentum justo. In facilisis faucibus tempus. Ut in fermentum tellus, vitae consectetur risus. Vestibulum sodales lorem at augue congue, ac finibus magna consequat. Suspendisse odio nibh, sollicitudin ac nunc a, ultrices laoreet urna. Nulla hendrerit non enim eu viverra. Donec non est sagittis purus facilisis sodales. Quisque rutrum, tortor ut volutpat blandit, ipsum urna tempus mi, ut aliquam dui mauris a arcu. Mauris lectus tortor, volutpat a venenatis sit amet, scelerisque a eros. Fusce nulla elit, tempus et viverra in, interdum sit amet nisi. Sed dictum dolor nec lorem rhoncus, vel rhoncus tellus egestas. +Aenean sed arcu finibus, auctor lectus non, pharetra odio. Cras non hendrerit nisi. Integer auctor tortor lectus, in placerat massa scelerisque ut. Phasellus vestibulum tortor eget nisi ultrices, id aliquam nisl ultricies. Aliquam finibus, tellus facilisis hendrerit maximus, tellus tortor rhoncus turpis, ut fermentum felis felis vitae lectus. Nam condimentum, justo id feugiat porttitor, lorem erat pharetra massa, quis mattis sem orci sed sem. Quisque tempor viverra enim a scelerisque. Nam cursus facilisis orci, et accumsan eros dictum nec. Donec eleifend dui elit, vel laoreet neque sagittis et. Praesent at ante id turpis vulputate aliquet vel nec tortor. Proin at ante viverra, rhoncus elit at, efficitur mauris. Maecenas placerat neque nisi, volutpat dictum orci venenatis quis. Vestibulum rhoncus purus et libero ornare dapibus. +Aenean consequat libero vel metus ornare, eu fermentum mauris sagittis. Sed congue semper nulla, id hendrerit augue aliquam a. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc rhoncus risus in nibh tempor blandit. Donec consectetur accumsan purus id laoreet. Mauris elit libero, sodales sit amet dignissim eu, ultricies ut leo. Integer venenatis ligula id interdum vehicula. Nulla et enim hendrerit, molestie justo ut, condimentum nunc. Aenean vitae congue orci. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce felis elit, tristique eu fermentum sed, blandit a arcu. Nullam efficitur tellus ac eros molestie congue. Mauris in nisl est. Sed mi tortor, elementum vitae vehicula sit amet, ullamcorper eget ante. Integer laoreet vehicula ligula quis venenatis. +Duis eget semper libero, id aliquet ligula. Sed blandit faucibus sapien. Morbi placerat ultricies metus, non iaculis magna rutrum nec. Etiam id vestibulum mauris, et congue enim. Ut tincidunt malesuada viverra. Cras turpis nibh, faucibus a tellus non, consequat volutpat nibh. Praesent nunc dui, auctor in lacus nec, laoreet scelerisque sem. Quisque eleifend mattis massa, dictum lobortis risus faucibus ac. Pellentesque fermentum vel felis a eleifend. Pellentesque molestie consectetur augue, non tristique lectus gravida ac. Integer condimentum ligula non malesuada fringilla. Proin eget bibendum turpis. Mauris placerat condimentum cursus. Nunc sed consectetur nisi, vel feugiat lorem. Suspendisse vulputate fermentum odio, eget fermentum tellus tempor vel. Ut at fringilla sem. +Fusce tempus odio nec lectus mollis pulvinar. Mauris nec faucibus leo. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Sed malesuada tristique risus, et imperdiet odio scelerisque sit amet. Nam sapien urna, euismod nec elementum a, efficitur quis mi. Vestibulum mollis efficitur sagittis. Vestibulum sit amet accumsan tellus. Donec tempor elit ut orci facilisis, id mollis arcu lacinia. Donec tempor, purus eget facilisis vulputate, augue nisl euismod dui, vel ullamcorper metus turpis sagittis orci. Fusce sagittis, nibh ultrices hendrerit tempor, magna nulla efficitur sapien, vitae viverra magna ante a metus. Vivamus venenatis imperdiet hendrerit. Aenean ut lobortis magna. diff --git a/OpenOversight/tests/routes/test_incidents.py b/OpenOversight/tests/routes/test_incidents.py index 0f646ad61..f97cb4b7b 100644 --- a/OpenOversight/tests/routes/test_incidents.py +++ b/OpenOversight/tests/routes/test_incidents.py @@ -44,11 +44,19 @@ def test_route_admin_or_required(route, client, mockdata): assert rv.status_code == 403 -def test_admins_can_create_basic_incidents(mockdata, client, session): +@pytest.mark.parametrize( + "report_number", + [ + # Ensure different report number formats are accepted + "42", + "My-Special-Case", + "PPP Case 92", + ], +) +def test_admins_can_create_basic_incidents(report_number, mockdata, client, session): with current_app.test_request_context(): login_admin(client) date = datetime(2000, 5, 25, 1, 45) - report_number = "42" address_form = LocationForm( street_name="AAAAA", @@ -83,6 +91,45 @@ def test_admins_can_create_basic_incidents(mockdata, client, session): assert inc is not None +def test_admins_cannot_create_incident_with_invalid_report_number( + mockdata, client, session +): + with current_app.test_request_context(): + login_admin(client) + date = datetime(2000, 5, 25, 1, 45) + report_number = "Will Not Work! #45" + + address_form = LocationForm( + street_name="AAAAA", + cross_street1="BBBBB", + city="FFFFF", + state="IA", + zip_code="03435", + ) + # These have to have a dropdown selected because if not, an empty Unicode string is sent, which does not mach the '' selector. + link_form = LinkForm(link_type="video") + license_plates_form = LicensePlateForm(state="AZ") + form = IncidentForm( + date_field=str(date.date()), + time_field=str(date.time()), + report_number=report_number, + description="Something happened", + department="1", + address=address_form.data, + links=[link_form.data], + license_plates=[license_plates_form.data], + officers=[], + ) + data = process_form_data(form.data) + + rv = client.post( + url_for("main.incident_api") + "new", data=data, follow_redirects=True + ) + + assert rv.status_code == 200 + assert "Report cannot contain special characters" in rv.data.decode("utf-8") + + def test_admins_can_edit_incident_date_and_address(mockdata, client, session): with current_app.test_request_context(): login_admin(client) diff --git a/OpenOversight/tests/test_functional.py b/OpenOversight/tests/test_functional.py index 1cf266c44..7cb10a753 100644 --- a/OpenOversight/tests/test_functional.py +++ b/OpenOversight/tests/test_functional.py @@ -14,6 +14,9 @@ from OpenOversight.app.models import Department, Incident, Officer, Unit, db +DESCRIPTION_CUTOFF = 500 + + @contextmanager def wait_for_page_load(browser, timeout=10): old_page = browser.find_element_by_tag_name("html") @@ -150,14 +153,14 @@ def test_find_officer_cannot_see_uii_question_for_depts_without_uiis(mockdata, b assert len(results) == 0 -def test_incident_detail_display_read_more_button_for_descriptions_over_300_chars( +def test_incident_detail_display_read_more_button_for_descriptions_over_cutoff( mockdata, browser ): # Navigate to profile page for officer with short and long incident descriptions browser.get("http://localhost:5000/officer/1") incident_long_descrip = Incident.query.filter( - func.length(Incident.description) > 300 + func.length(Incident.description) > DESCRIPTION_CUTOFF ).one_or_none() incident_id = str(incident_long_descrip.id) @@ -165,13 +168,13 @@ def test_incident_detail_display_read_more_button_for_descriptions_over_300_char assert result.is_displayed() -def test_incident_detail_do_not_display_read_more_button_for_descriptions_under_300_chars( +def test_incident_detail_do_not_display_read_more_button_for_descriptions_under_cutoff( mockdata, browser ): # Navigate to profile page for officer with short and long incident descriptions browser.get("http://localhost:5000/officer/1") - # Select incident for officer that has description under 300 chars + # Select incident for officer that has description under cuttoff chars result = browser.find_element_by_id("description-overflow-row_1") assert not result.is_displayed() @@ -181,9 +184,9 @@ def test_click_to_read_more_displays_full_description(mockdata, browser): browser.get("http://localhost:5000/officer/1") incident_long_descrip = Incident.query.filter( - func.length(Incident.description) > 300 + func.length(Incident.description) > DESCRIPTION_CUTOFF ).one_or_none() - orig_descrip = incident_long_descrip.description + orig_descrip = incident_long_descrip.description.strip() incident_id = str(incident_long_descrip.id) button = browser.find_element_by_id("description-overflow-button_" + incident_id) @@ -191,7 +194,7 @@ def test_click_to_read_more_displays_full_description(mockdata, browser): description_text = browser.find_element_by_id( "incident-description_" + incident_id - ).text + ).text.strip() assert len(description_text) == len(orig_descrip) assert description_text == orig_descrip @@ -201,7 +204,7 @@ def test_click_to_read_more_hides_the_read_more_button(mockdata, browser): browser.get("http://localhost:5000/officer/1") incident_long_descrip = Incident.query.filter( - func.length(Incident.description) > 300 + func.length(Incident.description) > DESCRIPTION_CUTOFF ).one_or_none() incident_id = str(incident_long_descrip.id) From 2543a8b5638ecd810a47b70fe3c1db3e9aca87b0 Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sun, 9 Jan 2022 18:04:48 -0800 Subject: [PATCH 039/137] Move links section to after incidents (OrcaCollective#109) --- OpenOversight/app/templates/officer.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenOversight/app/templates/officer.html b/OpenOversight/app/templates/officer.html index ef5053a3e..b3d9ddaaf 100644 --- a/OpenOversight/app/templates/officer.html +++ b/OpenOversight/app/templates/officer.html @@ -116,9 +116,6 @@

    Officer Detail: {{ officer.full_name() }}

    {% if is_admin_or_coordinator %} {% include "partials/officer_notes.html" %} {% endif %} - {% with obj=officer %} - {% include "partials/links_and_videos_row.html" %} - {% endwith %}

    {# end col #}
    @@ -128,6 +125,9 @@

    Officer Detail: {{ officer.full_name() }}

    {% if officer.incidents or is_admin_or_coordinator %} {% include "partials/officer_incidents.html" %} {% endif %} + {% with obj=officer %} + {% include "partials/links_and_videos_row.html" %} + {% endwith %}
    {# end col #}
    {# end row #} From 9e08b8ae8fd9be541f87720b212229932be4ea05 Mon Sep 17 00:00:00 2001 From: abandoned-prototype <41744410+abandoned-prototype@users.noreply.github.com> Date: Sat, 25 Mar 2023 01:07:34 -0500 Subject: [PATCH 040/137] Format some templates. --- .../app/templates/incident_detail.html | 83 +++---- .../templates/partials/incident_fields.html | 183 +++++++-------- .../partials/links_and_videos_row.html | 209 +++++++++--------- .../partials/officer_descriptions.html | 53 ++--- .../templates/partials/officer_incidents.html | 33 +-- .../app/templates/partials/officer_notes.html | 37 ++-- 6 files changed, 300 insertions(+), 298 deletions(-) diff --git a/OpenOversight/app/templates/incident_detail.html b/OpenOversight/app/templates/incident_detail.html index 0949ca779..b345c6247 100644 --- a/OpenOversight/app/templates/incident_detail.html +++ b/OpenOversight/app/templates/incident_detail.html @@ -1,14 +1,16 @@ {% extends 'base.html' %} {% set incident = obj %} -{% block title %}{{ incident.department.name }} incident{% if incident.report_number %} {{incident.report_number}}{% endif %} - OpenOversight{% endblock %} +{% block title %}{{ incident.department.name }} incident{% if incident.report_number %} {{incident.report_number}}{% +endif %} - OpenOversight{% endblock %} {% block meta %} - {% if incident.description != None %} - - {% else %} - - {% endif %} - - {% endblock %} {% block content %} -
    - All Incidents - {% if incident.department %} -

    - More incidents in the {{ incident.department.name }} -

    - {% endif %} -
    +
    + All Incidents + {% if incident.department %} +

    + More incidents in the {{ + incident.department.name }} +

    + {% endif %} +

    Incident {% if incident.report_number %}{{incident.report_number}}{% endif %}

    -
    - - - {% with detail=True %} +
    +
    + + {% with detail=True %} {% include 'partials/incident_fields.html' %} - {% endwith %} - -
    -
    + {% endwith %} + +
    -
    -

    Incident Description

    - {{ incident.description | markdown}} -
    +
    +
    +

    Incident Description

    + {{ incident.description | markdown}} +
    - {% include 'partials/links_and_videos_row.html' %} - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} -
    -
    - Edit - Delete -
    -
    - {% endif %} -
    + {% include 'partials/links_and_videos_row.html' %} + {% if current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} +
    +
    + Edit + Delete +
    +
    + {% endif %} + {% endblock %} diff --git a/OpenOversight/app/templates/partials/incident_fields.html b/OpenOversight/app/templates/partials/incident_fields.html index 2fd9c1a73..7b36cf1d9 100644 --- a/OpenOversight/app/templates/partials/incident_fields.html +++ b/OpenOversight/app/templates/partials/incident_fields.html @@ -1,98 +1,99 @@ - - - - Date - {{ incident.date.strftime('%b %d, %Y') }} - - {% if incident.time %} - - Time - {{ incident.time.strftime('%l:%M %p') }} - - {% endif %} - {% if incident.report_number %} - - Report # - {{ incident.report_number }} - - {% endif %} - - Department - {{ incident.department.name }} - - {% if incident.officers %} - - Officers - - {% for officer in incident.officers %} - {{ officer.full_name()|title }}{% if not loop.last %}, {% endif %} - {% endfor %} - - - {% endif %} - {% if detail and incident.license_plates %} - - License Plates - - {% for plate in incident.license_plates %} - {{ plate.state }} {{ plate.number }}{% if not loop.last %}
    {% endif %} - {% endfor %} - - + + Date + {{ incident.date.strftime('%b %d, %Y') }} + +{% if incident.time %} + + Time + {{ incident.time.strftime('%l:%M %p') }} + +{% endif %} +{% if incident.report_number %} + + Report # + {{ incident.report_number }} + +{% endif %} + + Department + {{ incident.department.name }} + +{% if incident.officers %} + + Officers + + {% for officer in incident.officers %} + {{ officer.full_name()|title }}{% if + not loop.last %}, {% endif %} + {% endfor %} + + +{% endif %} +{% if detail and incident.license_plates %} + + License Plates + + {% for plate in incident.license_plates %} + {{ plate.state }} {{ plate.number }}{% if not loop.last %}
    {% endif %} + {% endfor %} + + +{% endif %} +{% if not detail %} + + Description + + {{ incident.description | markdown}} + + + + + + + + +{% endif %} +{% with address=incident.address %} +{% if address %} + + Address + + {% if address.street_name %} + {{ address.street_name }} + {% if address.cross_street1%} + {% if address.cross_street2%} + between {{ address.cross_street1 }} and {{ address.cross_street2 }} + {% else %} + near {{ address.cross_street1 }} {% endif %} - {% if not detail %} - - Description - - {{ incident.description | markdown}} - - - - - - - - {% endif %} - {% with address=incident.address %} - {% if address %} - - Address - - {% if address.street_name %} - {{ address.street_name }} - {% if address.cross_street1%} - {% if address.cross_street2%} - between {{ address.cross_street1 }} and {{ address.cross_street2 }} - {% else %} - near {{ address.cross_street1 }} - {% endif %} - {% endif %} -
    - {% endif %} - {{ address.city }}, {{ address.state }} {% if address.zipcode %} {{ address.zip_code }} {% endif %} - - - {% endif %} - {% endwith %} - {% if detail and current_user.is_administrator %} - {% if incident.creator %} - - Creator - {{ incident.creator.username }} - - {% endif %} - {% if incident.last_updated_by %} - - Last Edited By - {{ incident.last_updated_by.username }} - - {% endif %} +
    {% endif %} + {{ address.city }}, {{ address.state }} {% if address.zipcode %} {{ address.zip_code }} {% endif %} + + +{% endif %} +{% endwith %} +{% if detail and current_user.is_administrator %} +{% if incident.creator %} + + Creator + {{ incident.creator.username }} + + +{% endif %} +{% if incident.last_updated_by %} + + Last Edited By + {{ + incident.last_updated_by.username }} + +{% endif %} +{% endif %} {% block js_footer %} - + {% endblock %} diff --git a/OpenOversight/app/templates/partials/links_and_videos_row.html b/OpenOversight/app/templates/partials/links_and_videos_row.html index b18353015..60db37ff8 100644 --- a/OpenOversight/app/templates/partials/links_and_videos_row.html +++ b/OpenOversight/app/templates/partials/links_and_videos_row.html @@ -1,117 +1,118 @@ {% if obj.links|length > 0 or is_admin_or_coordinator %} -

    Links

    - {% for type, list in obj.links|groupby('link_type') %} - {% if type == 'link' %} -
      - {% for link in list %} -
    • - {{ link.title or link.url }} - {% if officer and (is_admin_or_coordinator or link.creator_id == current_user.id) %} - {{ link.title or link.url }} + {% if officer and (is_admin_or_coordinator or link.creator_id == current_user.id) %} + - Edit - - - + - Delete - - - {% endif %} - {% if link.description or link.author %} -
      - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
      - {% endif %} -
    • - {% endfor %} -
    + Delete + + {% endif %} + {% if link.description or link.author %} +
    + {% if link.description %} + {{ link.description }} + {% endif %} + {% if link.author %} + {% if link.description %}- {% endif %}{{ link.author }} + {% endif %} +
    + {% endif %} + {% endfor %} - {% if officer and (current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id)) %} - New Link/Video - {% endif %} - {% for type, list in obj.links|groupby('link_type') %} - {% if type == 'video' %} -

    Videos

    -
      - {% for link in list %} - {% with link_url = link.url.split('v=')[1] %} -
    • - {% if link.title %} -
      {{ link.title }}
      - {% endif %} - {% if officer and (current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) - or link.creator_id == current_user.id) %} - New Link/Video +{% endif %} +{% for type, list in obj.links|groupby('link_type') %} +{% if type == 'video' %} +

      Videos

      +
        + {% for link in list %} + {% with link_url = link.url.split('v=')[1] %} +
      • + {% if link.title %} +
        {{ link.title }}
        + {% endif %} + {% if officer and (current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) + or link.creator_id == current_user.id) %} + - Edit - - - + - Delete - - - {% endif %} -
        - -
        - {% if link.description or link.author %} -
        - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
        - {% endif %} -
      • - {% endwith%} - {% endfor %} -
      + Delete + + + {% endif %} +
      + +
      + {% if link.description or link.author %} +
      + {% if link.description %} + {{ link.description }} + {% endif %} + {% if link.author %} + {% if link.description %}- {% endif %}{{ link.author }} + {% endif %} +
      {% endif %} - {% if type == 'other_video' %} -

      Other videos

      -
        - {% for link in list %} -
      • - {{ link.title or link.url }} - {% if officer and (current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) - or link.creator_id == current_user.id) %} - {{ link.title or link.url }} + {% if officer and (current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) + or link.creator_id == current_user.id) %} + - Edit - - - + - Delete - - - {% endif %} - {% if link.description or link.author %} -
        - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
        - {% endif %} -
      • - {% endfor %} -
      + Delete + + {% endif %} + {% if link.description or link.author %} +
      + {% if link.description %} + {{ link.description }} + {% endif %} + {% if link.author %} + {% if link.description %}- {% endif %}{{ link.author }} + {% endif %} +
      + {% endif %} +
    • {% endfor %} +
    +{% endif %} +{% endfor %} {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_descriptions.html b/OpenOversight/app/templates/partials/officer_descriptions.html index 5f4a3d545..8a49e1a14 100644 --- a/OpenOversight/app/templates/partials/officer_descriptions.html +++ b/OpenOversight/app/templates/partials/officer_descriptions.html @@ -1,36 +1,29 @@

    Descriptions

    {% if officer.descriptions %} -
      - {% for description in officer.descriptions %} -
    • - {{ description.date_updated.strftime('%b %d, %Y')}}
      - {{ description.text_contents | markdown }} - {{ description.creator.username }} - {% if description.creator_id == current_user.get_id() or - current_user.is_administrator %} - - Edit - - - - Delete - - - {% endif %} -
    • - {% endfor %} -
    +
      + {% for description in officer.descriptions %} +
    • + {{ description.date_updated.strftime('%b %d, %Y')}}
      + {{ description.text_contents | markdown }} + {{ description.creator.username }} + {% if description.creator_id == current_user.get_id() or + current_user.is_administrator %} + + Edit + + + + Delete + + + {% endif %} +
    • + {% endfor %} +
    {% endif %} {% if is_admin_or_coordinator %} - - New description - + + New description + {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_incidents.html b/OpenOversight/app/templates/partials/officer_incidents.html index dd85226be..1c1e97b91 100644 --- a/OpenOversight/app/templates/partials/officer_incidents.html +++ b/OpenOversight/app/templates/partials/officer_incidents.html @@ -1,34 +1,39 @@

    Incidents

    {% if officer.incidents %} - - - {% for incident in officer.incidents %} +
    + + {% for incident in officer.incidents %} {% if not loop.first %} - + + + {% endif %} {% include 'partials/incident_fields.html' %} - {% endfor %} -
     
     

    - Incident - {% if incident.report_number %} + Incident + {% if incident.report_number %} {{ incident.report_number }} - {% else %} + {% else %} {{ incident.id}} - {% endif %} - - {% if current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} + {% endif %} + + {% if current_user.is_administrator or (current_user.is_area_coordinator and + current_user.ac_department_id == incident.department_id) %} - {% endif %} + {% endif %}

    + {% endfor %} + + {% endif %} {% if is_admin_or_coordinator %} - New Incident +New + Incident {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_notes.html b/OpenOversight/app/templates/partials/officer_notes.html index d573403c6..ba86905f9 100644 --- a/OpenOversight/app/templates/partials/officer_notes.html +++ b/OpenOversight/app/templates/partials/officer_notes.html @@ -2,28 +2,25 @@

    Notes

      {% for note in officer.notes %} -
    • - {{ note.date_updated.strftime('%b %d, %Y')}} -
      - {{ note.text_contents | markdown }} - {{ note.creator.username }} - {% if note.creator_id == current_user.get_id() or current_user.is_administrator %} - - Edit - - - - Delete - - - {% endif %} -
    • +
    • + {{ note.date_updated.strftime('%b %d, %Y')}} +
      + {{ note.text_contents | markdown }} + {{ note.creator.username }} + {% if note.creator_id == current_user.get_id() or current_user.is_administrator %} + + Edit + + + + Delete + + + {% endif %} +
    • {% endfor %}
    - + New Note From f14ffda66b4bae429e36d6d91b0d2bb31f5db30e Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sun, 9 Jan 2022 18:05:03 -0800 Subject: [PATCH 041/137] Improve cutoff for incident descriptions (OrcaCollective#110) --- OpenOversight/app/static/js/incidentDescription.js | 13 +++++++++++-- .../app/templates/partials/incident_fields.html | 5 ----- .../app/templates/partials/officer_incidents.html | 3 +++ OpenOversight/tests/test_functional.py | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/OpenOversight/app/static/js/incidentDescription.js b/OpenOversight/app/static/js/incidentDescription.js index dfce6dc83..70c8b39bc 100644 --- a/OpenOversight/app/static/js/incidentDescription.js +++ b/OpenOversight/app/static/js/incidentDescription.js @@ -1,5 +1,5 @@ $(document).ready(function() { - let overflow_length = 500; + let overflow_length = 700; $(".incident-description").each(function () { let description = this; let incidentId = $( this ).data("incident"); @@ -8,7 +8,16 @@ $(document).ready(function() { } if(description.innerHTML.length > overflow_length) { let originalDescription = description.innerHTML; - description.innerHTML = description.innerHTML.substring(0, overflow_length) + "…"; + // Convert the innerHTML into a string, and truncate it to overflow length + const sub = description.innerHTML.substring(0, overflow_length) + // In order to make the cutoff clean, we will want to truncate *after* + // the end of the last HTML tag. So first we need to find the last tag. + const cutoff = sub.lastIndexOf("" after the start of the closing bracket. + const lastTag = sub.substring(cutoff).indexOf(">") + // Lastly, trim the HTML to the end of the closing tag + description.innerHTML = sub.substring(0, cutoff + lastTag + 1) + "…"; $(`#description-overflow-button_${incidentId}`).on('click', function(event) { event.stopImmediatePropagation(); description.innerHTML = originalDescription; diff --git a/OpenOversight/app/templates/partials/incident_fields.html b/OpenOversight/app/templates/partials/incident_fields.html index 7b36cf1d9..eaccb3ae2 100644 --- a/OpenOversight/app/templates/partials/incident_fields.html +++ b/OpenOversight/app/templates/partials/incident_fields.html @@ -92,8 +92,3 @@ {% endif %} {% endif %} - - -{% block js_footer %} - -{% endblock %} diff --git a/OpenOversight/app/templates/partials/officer_incidents.html b/OpenOversight/app/templates/partials/officer_incidents.html index 1c1e97b91..c795a1436 100644 --- a/OpenOversight/app/templates/partials/officer_incidents.html +++ b/OpenOversight/app/templates/partials/officer_incidents.html @@ -32,6 +32,9 @@

    {% endfor %} + {% block js_footer %} + + {% endblock %} {% endif %} {% if is_admin_or_coordinator %} New diff --git a/OpenOversight/tests/test_functional.py b/OpenOversight/tests/test_functional.py index 7cb10a753..65879ed87 100644 --- a/OpenOversight/tests/test_functional.py +++ b/OpenOversight/tests/test_functional.py @@ -14,7 +14,7 @@ from OpenOversight.app.models import Department, Incident, Officer, Unit, db -DESCRIPTION_CUTOFF = 500 +DESCRIPTION_CUTOFF = 700 @contextmanager From 34c54371efc3bb000c67251169a417be7a86d7b0 Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sun, 9 Jan 2022 18:05:13 -0800 Subject: [PATCH 042/137] Add extra info for upload (OrcaCollective#111) --- OpenOversight/app/templates/submit_image.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/OpenOversight/app/templates/submit_image.html b/OpenOversight/app/templates/submit_image.html index 05f78b82b..a351085a0 100644 --- a/OpenOversight/app/templates/submit_image.html +++ b/OpenOversight/app/templates/submit_image.html @@ -35,9 +35,13 @@

    Select the department that the police officer in your image belongs to:

    Drop images here to submit photos of officers:

    -
    -
    -

    Drag photographs from your computer directly into the box above or click the box to launch a finder window. If you are on mobile, you can click the box above to select pictures from your photo library or camera roll.

    +
    +
    +

    + Drag photographs from your computer directly into the box above or click the box to launch a finder window. + If you are on mobile, you can click the box above to select pictures from your photo library or camera roll. + An image is successfully uploaded when you see a check mark over it. +

    From 398c5c24c6f7bd3e30d9c6da8275459dbb4d624a Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Sun, 9 Jan 2022 20:43:55 -0800 Subject: [PATCH 043/137] Sort incidents by date descending on officer page (OrcaCollective#112) --- OpenOversight/app/templates/partials/officer_incidents.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenOversight/app/templates/partials/officer_incidents.html b/OpenOversight/app/templates/partials/officer_incidents.html index c795a1436..2148b3735 100644 --- a/OpenOversight/app/templates/partials/officer_incidents.html +++ b/OpenOversight/app/templates/partials/officer_incidents.html @@ -2,7 +2,7 @@

    Incidents

    {% if officer.incidents %} - {% for incident in officer.incidents %} + {% for incident in officer.incidents | sort(attribute='date') | reverse %} {% if not loop.first %} From 076f38454637cde065879174f49d47438b31af33 Mon Sep 17 00:00:00 2001 From: sea-kelp <66500457+sea-kelp@users.noreply.github.com> Date: Tue, 17 May 2022 20:46:19 -0700 Subject: [PATCH 044/137] Provide better metadata for incidents page (OrcaCollective#121) --- OpenOversight/app/templates/incident_detail.html | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/OpenOversight/app/templates/incident_detail.html b/OpenOversight/app/templates/incident_detail.html index b345c6247..ad5e09eb0 100644 --- a/OpenOversight/app/templates/incident_detail.html +++ b/OpenOversight/app/templates/incident_detail.html @@ -3,12 +3,7 @@ {% block title %}{{ incident.department.name }} incident{% if incident.report_number %} {{incident.report_number}}{% endif %} - OpenOversight{% endblock %} {% block meta %} -{% if incident.description != None %} - -{% else %} - -{% endif %} + +{% endblock %} From f9d694020d9929bb7b941828bf730d44d3a2a6c6 Mon Sep 17 00:00:00 2001 From: sea-kelp <66500457+sea-kelp@users.noreply.github.com> Date: Tue, 17 May 2022 22:38:52 -0700 Subject: [PATCH 046/137] Add incident search (OrcaCollective#122) * Add incident search * Update report number query to strip input and do inclusive search --- OpenOversight/app/main/forms.py | 8 ++ OpenOversight/app/main/views.py | 76 +++++++++++++++---- .../app/templates/incident_list.html | 55 +++++++++++--- OpenOversight/tests/routes/test_incidents.py | 28 +++++++ 4 files changed, 143 insertions(+), 24 deletions(-) diff --git a/OpenOversight/app/main/forms.py b/OpenOversight/app/main/forms.py index c8c508d48..a85372d24 100644 --- a/OpenOversight/app/main/forms.py +++ b/OpenOversight/app/main/forms.py @@ -597,3 +597,11 @@ class BrowseForm(Form): validators=[AnyOf(allowed_values(AGE_CHOICES))], ) submit = SubmitField(label="Submit") + + +class IncidentListForm(Form): + department_id = HiddenField("Department Id") + report_number = StringField("Report Number") + occurred_before = DateField("Occurred Before") + occurred_after = DateField("Occurred After") + submit = SubmitField(label="Submit") diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 5bd28173a..34c359bc0 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -1,3 +1,4 @@ +import datetime import os import re import sys @@ -79,6 +80,7 @@ FindOfficerForm, FindOfficerIDForm, IncidentForm, + IncidentListForm, OfficerLinkForm, SalaryForm, TextForm, @@ -1371,26 +1373,72 @@ class IncidentApi(ModelView): department_check = True def get(self, obj_id): + if obj_id: + # Single-item view + return super(IncidentApi, self).get(obj_id) + + # List view if request.args.get("page"): page = int(request.args.get("page")) else: page = 1 - if request.args.get("department_id"): - department_id = request.args.get("department_id") + + form = IncidentListForm() + incidents = self.model.query + + dept = None + if department_id := request.args.get("department_id"): dept = Department.query.get_or_404(department_id) - obj = ( - self.model.query.filter_by(department_id=department_id) - .order_by(getattr(self.model, self.order_by).desc()) - .paginate(page, self.per_page, False) - ) - return render_template( - "{}_list.html".format(self.model_name), - objects=obj, - url="main.{}_api".format(self.model_name), - department=dept, + form.department_id.data = department_id + incidents = incidents.filter_by(department_id=department_id) + + if report_number := request.args.get("report_number"): + form.report_number.data = report_number + incidents = incidents.filter( + Incident.report_number.contains(report_number.strip()) ) - else: - return super(IncidentApi, self).get(obj_id) + + if occurred_before := request.args.get("occurred_before"): + before_date = datetime.datetime.strptime(occurred_before, "%Y-%m-%d").date() + form.occurred_before.data = before_date + incidents = incidents.filter(self.model.date < before_date) + + if occurred_after := request.args.get("occurred_after"): + after_date = datetime.datetime.strptime(occurred_after, "%Y-%m-%d").date() + form.occurred_after.data = after_date + incidents = incidents.filter(self.model.date > after_date) + + incidents = incidents.order_by( + getattr(self.model, self.order_by).desc() + ).paginate(page, self.per_page, False) + + url = "main.{}_api".format(self.model_name) + next_url = url_for( + url, + page=incidents.next_num, + department_id=department_id, + report_number=report_number, + occurred_after=occurred_after, + occurred_before=occurred_before, + ) + prev_url = url_for( + url, + page=incidents.prev_num, + department_id=department_id, + report_number=report_number, + occurred_after=occurred_after, + occurred_before=occurred_before, + ) + + return render_template( + "{}_list.html".format(self.model_name), + form=form, + incidents=incidents, + url=url, + next_url=next_url, + prev_url=prev_url, + department=dept, + ) def get_new_form(self): form = self.form() diff --git a/OpenOversight/app/templates/incident_list.html b/OpenOversight/app/templates/incident_list.html index 140f1294d..dc76ae2e6 100644 --- a/OpenOversight/app/templates/incident_list.html +++ b/OpenOversight/app/templates/incident_list.html @@ -1,22 +1,51 @@ -{% extends "list.html" %} +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} {% block title %}View incidents - OpenOversight{% endblock %} {% block meta %} - {% if objects.items|length > 0 %} - - {% else %} - - {% endif %} + {% if incidents.items|length > 0 %} + + {% else %} + + {% endif %} {% endblock %} -{% block list %} +{% block content %} +

    Incidents

    {% if department %}

    {{ department.name }}

    {% endif %} +
    +
    + +
    + {% if department %} + + {% endif %} +
    + {{ wtf.form_field(form.report_number) }} +
    +
    + {{ wtf.form_field(form.occurred_before) }} +
    +
    + {{ wtf.form_field(form.occurred_after) }} +
    +
    + {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }} +
    + +
    +
    + {% with paginate=incidents, location='top' %} + {% include "partials/paginate_nav.html" %} + {% endwith %}
      - {% if objects.items %} + {% if incidents.items %}
     
    - {% for incident in objects.items %} + {% for incident in incidents.items %} {% if not loop.first %} {% endif %} @@ -48,7 +77,13 @@

    Add New Incident {% endif %} -{% endblock list %} + {% with paginate=incidents, location='bottom' %} + {% include "partials/paginate_nav.html" %} +{% endwith %} + + + +{% endblock content %} {% block js_footer %} {% endblock %} diff --git a/OpenOversight/tests/routes/test_incidents.py b/OpenOversight/tests/routes/test_incidents.py index f97cb4b7b..ca0fbf4a8 100644 --- a/OpenOversight/tests/routes/test_incidents.py +++ b/OpenOversight/tests/routes/test_incidents.py @@ -749,3 +749,31 @@ def test_admins_cannot_inject_unsafe_html(mockdata, client, session): assert "successfully updated" in rv.data.decode("utf-8") assert " - - - {% block head %}{% endblock %} + {% block head %} + {% endblock head %} - - - {% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} -
    -
    - -
    -
    - {% endfor %} - {% endif %} + {% if messages %} + {% for message in messages %} +
    +
    + +
    +
    + {% endfor %} + {% endif %} {% endwith %} - {% block content %}{% endblock %} - -
    + {% block content %} + {% endblock content %} + - {% block js_footer %} - {% for item in jsloads %} - - {% endfor %} - {% endblock %} + {% for item in jsloads %}{% endfor %} + {% endblock js_footer %} diff --git a/OpenOversight/app/templates/browse.html b/OpenOversight/app/templates/browse.html index 2a51d75b3..17d9c3377 100644 --- a/OpenOversight/app/templates/browse.html +++ b/OpenOversight/app/templates/browse.html @@ -3,37 +3,35 @@ {% block title %}Browse OpenOversight{% endblock %} {% block meta %}{% endblock %} {% block content %} - -
    -
    -

    Browse a Department

    -
    - -
    - {% for department in departments %} -

    -

    -

    {{ department.name }} - {% if current_user.is_administrator %} - - - - {% endif %} -

    -

    - - Officers - - {% if department.incidents %} - - Incidents - +

    +
    +

    + Browse a Department +

    +
    +
    + {% for department in departments %} +

    +

    +

    + {{ department.name }} + {% if current_user.is_administrator %} + + + + {% endif %} +

    +

    + Officers + {% if department.incidents %} + Incidents +

    + {% endif %} +

    - {% endif %} -
    -

    - {% endfor %} + {% endfor %} +
    -
    - {% endblock %} diff --git a/OpenOversight/app/templates/complaint.html b/OpenOversight/app/templates/complaint.html index 6cc7925d1..48d3013d1 100644 --- a/OpenOversight/app/templates/complaint.html +++ b/OpenOversight/app/templates/complaint.html @@ -1,44 +1,44 @@ {% extends "base.html" %} {% block content %} - -
    -
    -
    -

    File a Complaint Using these officer details

    +
    +
    +
    +

    + File a Complaint Using these officer details +

    +
    -
    - -
    -
    -

    Now that you've found the offending officer, it's time to submit a complaint - to the relevant oversight body. For complaints regarding Chicago Police Department - officers, complaints are accepted through the link below. Clicking below will open - a new browser tab where you can copy the officer details from this page into the - complaint form when requested.

    - -
    -

    Warning! The following link will open an external site - in a new browser tab:

    - - File Complaint Online +
    +
    +

    + Now that you've found the offending officer, it's time to submit a complaint + to the relevant oversight body. For complaints regarding Chicago Police Department + officers, complaints are accepted through the link below. Clicking below will open + a new browser tab where you can copy the officer details from this page into the + complaint form when requested. +

    +
    +

    + Warning! The following link will open an external site + in a new browser tab: +

    + File Complaint Online
    -
    Officer face
    -

    Officer name: {{ officer_first_name.lower()|title }} - {% if officer_middle_initial %}{{ officer_middle_initial }}. - {% endif %} - {{ officer_last_name.lower()|title }} - {% if officer_suffix %}{{ officer_suffix }}. - {% endif %} +

    + Officer name: {{ officer_first_name.lower() |title }} + {% if officer_middle_initial %}{{ officer_middle_initial }}.{% endif %} + {{ officer_last_name.lower() |title }} + {% if officer_suffix %}{{ officer_suffix }}.{% endif %}

    Officer badge number: #{{ officer_star }}

    - -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/cop_face.html b/OpenOversight/app/templates/cop_face.html index a5887ee67..ef30c059e 100644 --- a/OpenOversight/app/templates/cop_face.html +++ b/OpenOversight/app/templates/cop_face.html @@ -1,151 +1,200 @@ {% extends "base.html" %} - {% block head %} - - + + {% endblock head %} - - {% block content %} -
    - +
    {% if current_user and current_user.is_authenticated %} {% if image and current_user.is_disabled == False %} -
    -
    -

    Select the face of each officer in the photo and identify them.

    -

    {{ department.name }}

    +
    +
    +

    + Select the face of each officer in the photo and identify them. +

    +

    + {{ department.name }} +

    +
    -
    - -
    +
    - Picture -
    -
    - -
    -
    -
    -
    + Picture
    - -
    -
    - {% if department %} -
    - {% else %} - - {% endif %} - {{ form.hidden_tag() }} -
    - -
    - {% for error in form.dataX.errors %} -

    [{{ error }}]

    - {% endfor %} - -
    - -
    -
    - -
    - {% for error in form.dataY.errors %} -

    [{{ error }}]

    - {% endfor %} - -
    - -
    - {% for error in form.dataWidth.errors %} -

    [{{ error }}]

    - {% endfor %} - -
    - +
    +
    +
    +
    +
    +
    + +
    +
    + {% if department %} + + {% else %} + + {% endif %} + {{ form.hidden_tag() }} +
    + +
    + {% for error in form.dataX.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + +
    +
    + {% for error in form.dataY.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + +
    + {% for error in form.dataWidth.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + +
    + {% for error in form.dataHeight.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + + +
    + {% for error in form.officer_id.errors %} +

    + [{{ error }}] +

    + {% endfor %} + + {% for error in form.image_id.errors %} +

    + Image: [{{ error }}] +

    + {% endfor %} +

    +

    + +
    +

    +
    +
    + Explanation: click this button to associate the selected image with the entered OpenOversight ID. +
    +
    + Explanation: after matching the officer's name, badge number, or face to the roster, enter the officer's OpenOversight ID here. +
    +
    +
    - {% for error in form.dataHeight.errors %} -

    [{{ error }}]

    - {% endfor %} - -
    - - + + +
    +
    +
    +
    +
    + Explanation: click this button ONLY when all officers in it have been identified. This will remove it from the identification queue for ALL users. +
    +
    + Explanation: click this button if you would like to move on to the next image, without saving any info about this image. +
    +
    + Explanation: click this button to open the police roster. Use the roster to find the officer's OpenOversight ID. +
    +
    +
    +
    - {% for error in form.officer_id.errors %} -

    [{{ error }}]

    - {% endfor %} - - - {% for error in form.image_id.errors %} -

    Image: [{{ error }}]

    - {% endfor %} - + {% elif current_user.is_disabled == True %} +

    Your account has been disabled due to too many incorrect classifications/tags!

    -

    - -
    + Mail us to get it enabled again

    - - -
    -
    Explanation: click this button to associate the selected image with the entered OpenOversight ID.
    -
    Explanation: after matching the officer's name, badge number, or face to the roster, enter the officer's OpenOversight ID here.
    -
    - - -
    - -
    - - -
    - -
    -
    -
    -
    Explanation: click this button ONLY when all officers in it have been identified. This will remove it from the identification queue for ALL users.
    -
    Explanation: click this button if you would like to move on to the next image, without saving any info about this image.
    -
    Explanation: click this button to open the police roster. Use the roster to find the officer's OpenOversight ID.
    -
    -
    -
    - - -
    - - - {% elif current_user.is_disabled == True %} -

    Your account has been disabled due to too many incorrect classifications/tags!

    -

    Mail us to get it enabled again

    - {% else %} -

    All images have been tagged!

    -

    {{ department.name }}

    -

    Submit officer pictures to us

    - {% endif %} - {% endif %} - -
    -{% endblock %} - -{% block footer_class %}bottom-10{% endblock %} + {% endblock %} + {% block footer_class %}bottom-10{% endblock %} diff --git a/OpenOversight/app/templates/description_delete.html b/OpenOversight/app/templates/description_delete.html index 1003a109a..f44c7c930 100644 --- a/OpenOversight/app/templates/description_delete.html +++ b/OpenOversight/app/templates/description_delete.html @@ -1,23 +1,18 @@ {% extends "base.html" %} - {% block content %} -
    - - -

    - Are you sure you want to delete this description? - This cannot be undone. - - - - -

    -
    +
    + +

    + Are you sure you want to delete this description? + This cannot be undone. +
    + + + +

    +
    {% endblock content %} diff --git a/OpenOversight/app/templates/description_edit.html b/OpenOversight/app/templates/description_edit.html index dc4aea945..1c6d09d91 100644 --- a/OpenOversight/app/templates/description_edit.html +++ b/OpenOversight/app/templates/description_edit.html @@ -1,18 +1,14 @@ {% extends "form.html" %} {% import "bootstrap/wtf.html" as wtf %} - - {% block page_title %} - Update Description + Update Description {% endblock page_title %} - - {% block form %} - {% if form.errors %} - {% set post_url=url_for('main.description_api', officer_id=obj.officer_id, obj_id=obj.id) %} - {% else %} - {% set post_url="{}/edit".format(url_for('main.description_api', officer_id=obj.officer_id, obj_id=obj.id)) %} - {% endif %} - {{ wtf.quick_form(form, action=post_url, method='post', button_map={'submit':'primary'}) }} -
    + {% if form.errors %} + {% set post_url = url_for('main.description_api', officer_id=obj.officer_id, obj_id=obj.id) %} + {% else %} + {% set post_url = "{}/edit".format(url_for('main.description_api', officer_id=obj.officer_id, obj_id=obj.id)) %} + {% endif %} + {{ wtf.quick_form(form, action=post_url, method='post', button_map={'submit':'primary'}) }} +
    {% endblock form %} diff --git a/OpenOversight/app/templates/description_new.html b/OpenOversight/app/templates/description_new.html index 6bd835675..b7abae46b 100644 --- a/OpenOversight/app/templates/description_new.html +++ b/OpenOversight/app/templates/description_new.html @@ -1,10 +1,12 @@ {% extends 'form.html' %} - {% block page_title %} - New Description + New Description {% endblock page_title %} - {% block form %} -

    For officer with OOID {{ form.officer_id.data }}.
    {{form.description}}

    - {{ super() }} +

    + For officer with OOID {{ form.officer_id.data }}. +
    + {{ form.description }} +

    + {{ super() }} {% endblock form %} diff --git a/OpenOversight/app/templates/edit_assignment.html b/OpenOversight/app/templates/edit_assignment.html index 317b2b3b7..30a9b0f0c 100644 --- a/OpenOversight/app/templates/edit_assignment.html +++ b/OpenOversight/app/templates/edit_assignment.html @@ -1,27 +1,26 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} {% block title %}OpenOversight Admin - Edit Officer Assignment{% endblock %} - {% block content %} -
    - - -
    -
    +
    + +
    + {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} {{ wtf.form_field(form.star_no) }} {{ wtf.form_field(form.job_title) }} {{ wtf.form_field(form.unit) }} -

    Don't see your unit? Add one!

    +

    + Don't see your unit? Add one! +

    {{ wtf.form_field(form.star_date) }} {{ wtf.form_field(form.resign_date) }} - -
    -
    - -
    + +
    +
    +
    {% endblock %} diff --git a/OpenOversight/app/templates/edit_officer.html b/OpenOversight/app/templates/edit_officer.html index c11f1c49f..20c378564 100644 --- a/OpenOversight/app/templates/edit_officer.html +++ b/OpenOversight/app/templates/edit_officer.html @@ -1,15 +1,13 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} {% block title %}OpenOversight Admin - Edit Officer{% endblock %} - {% block content %} -
    - - -
    -
    +
    + +
    + {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} {{ wtf.form_field(form.first_name, autofocus="autofocus") }} @@ -23,9 +21,8 @@

    Edit Officer

    {{ wtf.form_field(form.unique_internal_identifier) }} {{ wtf.form_field(form.department) }} {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }} - -
    -
    - -
    + +
    +
    +
    {% endblock %} diff --git a/OpenOversight/app/templates/form.html b/OpenOversight/app/templates/form.html index 7fe550b0e..7aca8880c 100644 --- a/OpenOversight/app/templates/form.html +++ b/OpenOversight/app/templates/form.html @@ -1,25 +1,19 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} - - {% block content %} -
    - - - {% block page_header %} - {% endblock %} - -
    - {% block form %} - {{ wtf.quick_form(form, action=post_url, method='post', button_map={'submit':'primary'}) }} -
    - {% endblock form %} -
    -
    +
    + + {% block page_header %}{% endblock %} +
    + {% block form %} + {{ wtf.quick_form(form, action=post_url, method='post', button_map={'submit':'primary'}) }} +
    + {% endblock form %} +
    +
    {% endblock content %} diff --git a/OpenOversight/app/templates/image.html b/OpenOversight/app/templates/image.html index 6321fcd0d..87db1d660 100644 --- a/OpenOversight/app/templates/image.html +++ b/OpenOversight/app/templates/image.html @@ -1,107 +1,120 @@ {% extends "base.html" %} {% block content %} - -
    - - - - -
    -
    - Submission +
    + -
    -
    -
    -

    Metadata

    -

     
    - - - - - - - - - - - - + + + + + + + + + + +
    Image ID{{ image.id }}
    Department{{ image.department.name }}
    Date image inserted{% if image.date_image_inserted %} - {{ image.date_image_inserted }} +
    +
    + Submission +
    +
    +
    +
    +

    Metadata

    + + + + + + + + + + + + + - - - - + + + + - - -
    + Image ID + {{ image.id }}
    + Department + {{ image.department.name }}
    + Date image inserted + + {% if image.date_image_inserted %} + {{ image.date_image_inserted }} {% else %} - Not provided - {% endif %}
    Date image taken{% if image.date_image_taken %} - {{ image.date_image_taken }} + Not provided + {% endif %} +
    + Date image taken + + {% if image.date_image_taken %} + {{ image.date_image_taken }} {% else %} - Not provided - {% endif %}
    - -

    Classification

    - - - - - + + +
    Contains cops?{% if image.contains_cops != None %} - {{ image.contains_cops }} + Not provided + {% endif %} +
    +

    Classification

    + + + + + - - - - - - - - - - -
    + Contains cops? + + {% if image.contains_cops != None %} + {{ image.contains_cops }} {% else %} - Not yet classified! - {% endif %}
    Has been tagged? {{ image.is_tagged }}
    Classified by user - {{ image.user.username }}
    - - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == image.department.id) %} -

    Classify Admin only

    -

    -

    - - -
    -

    -

    -

    - - -
    -

    -

    Identify Admin only

    -

    - - - - -

    + Not yet classified! + {% endif %} +
    + Has been tagged? + {{ image.is_tagged }}
    + Classified by user + + {{ image.user.username }} +
    + {% if current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == image.department.id) %} +

    + Classify Admin only +

    +

    +

    + + +
    +

    +

    +

    + + +
    +

    +

    + Identify Admin only +

    +

    + + + + +

    {% endif %}

    -
    - {% endblock %} diff --git a/OpenOversight/app/templates/incident_delete.html b/OpenOversight/app/templates/incident_delete.html index c0166f7f7..ba0c8b995 100644 --- a/OpenOversight/app/templates/incident_delete.html +++ b/OpenOversight/app/templates/incident_delete.html @@ -1,20 +1,17 @@ {% extends "base.html" %} - {% block content %} -
    - - -

    - Are you sure you want to delete this incident? - This cannot be undone. -

    - - -
    -

    -
    +
    + +

    + Are you sure you want to delete this incident? + This cannot be undone. +

    + + +
    +

    +
    {% endblock content %} diff --git a/OpenOversight/app/templates/incident_detail.html b/OpenOversight/app/templates/incident_detail.html index ad5e09eb0..41ec0b985 100644 --- a/OpenOversight/app/templates/incident_detail.html +++ b/OpenOversight/app/templates/incident_detail.html @@ -1,69 +1,78 @@ {% extends 'base.html' %} {% set incident = obj %} -{% block title %}{{ incident.department.name }} incident{% if incident.report_number %} {{incident.report_number}}{% -endif %} - OpenOversight{% endblock %} -{% block meta %} - - - -{% endblock %} -{% block content %} -
    - All Incidents - {% if incident.department %} -

    - More incidents in the {{ - incident.department.name }} -

    - {% endif %} -
    -

    Incident {% if incident.report_number %}{{incident.report_number}}{% endif %}

    -
    - - - {% with detail=True %} - {% include 'partials/incident_fields.html' %} - {% endwith %} - -
    -
    -
    -
    -

    Incident Description

    - {{ incident.description | markdown}} -
    - - {% include 'partials/links_and_videos_row.html' %} - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} -
    -
    - Edit - Delete -
    -
    - {% endif %} -
    -{% endblock %} +{% block title %} + {{ incident.department.name }} incident + {% if incident.report_number %} + {{ incident.report_number }}{% + endif %} - OpenOversight + {% endblock %} + {% block meta %} + + + + {% endblock %} + {% block content %} +
    + All Incidents + {% if incident.department %} +

    + More incidents in the {{ + incident.department.name }} +

    + {% endif %} +
    +

    + Incident + {% if incident.report_number %}{{ incident.report_number }}{% endif %} +

    +
    + + + {% with detail=True %} + {% include 'partials/incident_fields.html' %} + {% endwith %} + +
    +
    +
    +
    +

    Incident Description

    + {{ incident.description | markdown }} +
    + {% include 'partials/links_and_videos_row.html' %} + {% if current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} +
    +
    + Edit + Delete +
    +
    + {% endif %} +
    + {% endblock %} diff --git a/OpenOversight/app/templates/incident_edit.html b/OpenOversight/app/templates/incident_edit.html index b3984bfa9..c5744925a 100644 --- a/OpenOversight/app/templates/incident_edit.html +++ b/OpenOversight/app/templates/incident_edit.html @@ -1,21 +1,14 @@ {% extends "form.html" %} {% import "bootstrap/wtf.html" as wtf %} - - {% block page_title %} - Update Incident {{ obj.report_number }} + Update Incident {{ obj.report_number }} {% endblock page_title %} - - {% block form %} - {% if form.errors %} - {% set post_url=url_for('main.incident_api', obj_id=obj.id) %} - {% else %} - {% set post_url="{}/edit".format(url_for('main.incident_api', obj_id=obj.id)) %} - {% endif %} - {% include "partials/incident_form.html" %} + {% if form.errors %} + {% set post_url = url_for('main.incident_api', obj_id=obj.id) %} + {% else %} + {% set post_url = "{}/edit".format(url_for('main.incident_api', obj_id=obj.id)) %} + {% endif %} + {% include "partials/incident_form.html" %} {% endblock form %} - -{% block js_footer %} - -{% endblock %} +{% block js_footer %}{% endblock %} diff --git a/OpenOversight/app/templates/incident_list.html b/OpenOversight/app/templates/incident_list.html index dc76ae2e6..2b6a5b7a5 100644 --- a/OpenOversight/app/templates/incident_list.html +++ b/OpenOversight/app/templates/incident_list.html @@ -3,86 +3,81 @@ {% block title %}View incidents - OpenOversight{% endblock %} {% block meta %} {% if incidents.items|length > 0 %} - + {% else %} {% endif %} {% endblock %} {% block content %} -
    -

    Incidents

    - {% if department %} -

    {{ department.name }}

    - {% endif %} -
    -
    - -
    - {% if department %} - +
    +

    Incidents

    + {% if department %} +

    + {{ department.name }} +

    + {% endif %} +
    +
    + + + {% if department %}{% endif %} +
    {{ wtf.form_field(form.report_number) }}
    +
    {{ wtf.form_field(form.occurred_before) }}
    +
    {{ wtf.form_field(form.occurred_after) }}
    +
    {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }}
    + +
    +
    + {% with paginate=incidents, location='top' %} + {% include "partials/paginate_nav.html" %} + {% endwith %} + + {% if current_user.is_administrator or current_user.is_area_coordinator %} + + + Add New Incident + {% endif %} -
    - {{ wtf.form_field(form.report_number) }} -
    -
    - {{ wtf.form_field(form.occurred_before) }} -
    -
    - {{ wtf.form_field(form.occurred_after) }} -
    -
    - {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }} -
    - + {% with paginate=incidents, location='bottom' %} + {% include "partials/paginate_nav.html" %} + {% endwith %} +
    -
    - {% with paginate=incidents, location='top' %} - {% include "partials/paginate_nav.html" %} - {% endwith %} - - {% if current_user.is_administrator or current_user.is_area_coordinator %} - - - Add New Incident - - {% endif %} - {% with paginate=incidents, location='bottom' %} - {% include "partials/paginate_nav.html" %} -{% endwith %} -
    -
    -
    +
    {% endblock content %} {% block js_footer %} diff --git a/OpenOversight/app/templates/incident_new.html b/OpenOversight/app/templates/incident_new.html index eee63738f..ed9a2048c 100644 --- a/OpenOversight/app/templates/incident_new.html +++ b/OpenOversight/app/templates/incident_new.html @@ -1,13 +1,8 @@ {% extends 'form.html' %} - {% block page_title %} - New Incident + New Incident {% endblock page_title %} - {% block form %} - {% include "partials/incident_form.html" %} + {% include "partials/incident_form.html" %} {% endblock form %} - -{% block js_footer %} - -{% endblock %} +{% block js_footer %}{% endblock %} diff --git a/OpenOversight/app/templates/index.html b/OpenOversight/app/templates/index.html index 0b490d279..f71c89ab0 100644 --- a/OpenOversight/app/templates/index.html +++ b/OpenOversight/app/templates/index.html @@ -1,88 +1,67 @@ {% extends "base.html" %} {% block content %} - -
    - +
    -
    -
    Help Us Build Transparency
    -
    OpenOversight: A public, searchable database of law enforcement officers.
    +
    +
    Help Us Build Transparency
    +
    OpenOversight: A public, searchable database of law enforcement officers.
    +
    +
    +
    +
    +

    + Browse or Find a Law Enforcement Officer +

    +
    Search the database
    +

    + Search our public database for available information on officers in your city or to identify an officer with whom you have had a negative interaction. +

    + Browse officers +
    + Identify officers +
    +
    +

    + Submit an Image +

    +
    Contribute to the database
    +

    Have a photo containing uniformed law enforcement officers? Drop them here.

    + Submit Image +
    - -
    -
    -
    -

    - Browse or Find a Law Enforcement Officer -

    -
    Search the database
    - -

    - Search our public database for available information on officers in your city or to identify an officer with whom you have had a negative interaction. - -

    - - Browse officers - -
    - - Identify officers - -
    - -
    -

    - Submit an Image -

    -
    Contribute to the database
    -

    - Have a photo containing uniformed law enforcement officers? Drop them here. -

    - - Submit Image - -
    - -
    -
    -
    -
    - Want to help? Here are two ways... +
    - +
    Want to help? Here are two ways...
    -
    -
    -

    - Volunteer -

    -

    - We're looking for volunteers to help us sort through images. If you're interested in helping out, please - sign up! -

    - - Put me to work - -
    -
    -

    - Donate -

    -

    - - We are entirely volunteer funded and directed. Donations are greatly appreciated, and can be funded - through PayPal, YouCaring, or Bitcoin. -
    - Note: We are an IRS 501(c)3 not-for-profit and all donations are tax deductible! -

    - - Donate - -
    - +
    +
    +

    + Volunteer +

    +

    + We're looking for volunteers to help us sort through images. If you're interested in helping out, please + sign up! +

    + Put me to work +
    +
    +

    + Donate +

    +

    + + We are entirely volunteer funded and directed. Donations are greatly appreciated, and can be funded + through PayPal, YouCaring, or Bitcoin. +
    + Note: We are an IRS 501(c)3 not-for-profit and all donations are tax deductible! +

    + Donate
    +
    - -
    - +
    {% endblock %} diff --git a/OpenOversight/app/templates/input_find_officer.html b/OpenOversight/app/templates/input_find_officer.html index a4dbdf51b..899b4b5ba 100644 --- a/OpenOversight/app/templates/input_find_officer.html +++ b/OpenOversight/app/templates/input_find_officer.html @@ -1,210 +1,262 @@ {% extends "base.html" %} {% block title %}Find an officer - OpenOversight{% endblock %} -{% block meta %}{% endblock %} +{% block meta %} + +{% endblock %} {% block content %} -
    +
    -
    Find an Officer
    +
    Find an Officer
    -
    - -
    +
    +
    -
    - Use this form to generate a gallery of law enforcement officers. -
    -

    - Fill in the information you know about an officer you interacted with. Don't worry if you don't know or - have answers to every question. OpenOversight takes what you provide and generates a digital gallery of - officers who may be a match. -

    +
    Use this form to generate a gallery of law enforcement officers.
    +

    + Fill in the information you know about an officer you interacted with. Don't worry if you don't know or + have answers to every question. OpenOversight takes what you provide and generates a digital gallery of + officers who may be a match. +

    -
    - {{ form.hidden_tag() }} + + {{ form.hidden_tag() }} -
    +
    +
    +
    +
    +

    + Select Department +

    +
    {{ form.dept(class="form-control") }}
    + {% for error in form.dept.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    +
    + +
    +
    +
    +
    -
    -

    Select Department

    -
    - {{ form.dept(class="form-control") }} -
    - - {% for error in form.dept.errors %} +
    +

    + Do you remember any part of the Officer's last name? +

    +
    + {{ form.last_name(class="form-control") }} + {% for error in form.last_name.errors %}

    [{{ error }}]

    + {% endfor %} +
    +

    + Do you remember any part of the Officer's first name? +

    +
    + {{ form.first_name(class="form-control") }} + {% for error in form.first_name.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    +

    + Do you remember any part of the Officer's badge number? +

    +
    + {{ form.badge(class="form-control") }} + {% for error in form.badge.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    +
    +

    + Do you know any part of the Officer's unique internal identifier? +

    +
    + {{ form.unique_internal_identifier(class="form-control") }} + {% for error in form.unique_internal_identifier.errors %} +

    + [{{ error }}] +

    {% endfor %} - -

    - +
    -
    -
    -
    -
    -
    -

    Do you remember any part of the Officer's last name?

    -
    - {{ form.last_name(class="form-control") }} - {% for error in form.last_name.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - -

    Do you remember any part of the Officer's first name?

    -
    - {{ form.first_name(class="form-control") }} - {% for error in form.first_name.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - -

    Do you remember any part of the Officer's badge number?

    -
    - {{ form.badge(class="form-control") }} - {% for error in form.badge.errors %} -

    [{{ error }}]

    - {% endfor %} -
    -
    -

    Do you know any part of the Officer's unique internal identifier?

    -
    - {{ form.unique_internal_identifier(class="form-control") }} - {% for error in form.unique_internal_identifier.errors %} -

    [{{ error }}]

    - {% endfor %} -
    -
    - -

    - +
    +
    + +
    -
    -
    +
    +
    -
    -

    Officer Rank

    -
    - {{ form.rank(class="form-control") }} - {% for error in form.rank.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - - - - -

    Officer Unit

    -
    - {{ form.unit(class="form-control") }} - {% for error in form.unit.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - -

    Currently Employed

    - (in this unit and/or rank, if specified) -
    - {{ form.current_job(class="form-control", style="box-shadow: none") }} - {% for error in form.current_job.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - -

    - +
    +

    + Officer Rank +

    +
    + {{ form.rank(class="form-control") }} + {% for error in form.rank.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + + +

    + Officer Unit +

    +
    + {{ form.unit(class="form-control") }} + {% for error in form.unit.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    +

    + Currently Employed +

    + (in this unit and/or rank, if specified) +
    + {{ form.current_job(class="form-control", style="box-shadow: none") }} + {% for error in form.current_job.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    +
    +
    + +
    -
    -
    +
    +
    -
    -

    Race

    -
    - {{ form.race(class="form-control") }} - {% for error in form.race.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - -

    Gender

    -
    - {{ form.gender(class="form-control") }} - {% for error in form.gender.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - -

    Age

    -
    - {{ form.min_age(size=4, class="form-control") }} to {{ form.max_age(size=4, class="form-control") }} - {% for error in form.min_age.errors %} -

    [{{ error }}]

    - {% endfor %} - {% for error in form.max_age.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - -

    - +
    +

    + Race +

    +
    + {{ form.race(class="form-control") }} + {% for error in form.race.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    +

    + Gender +

    +
    + {{ form.gender(class="form-control") }} + {% for error in form.gender.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    +

    + Age +

    +
    + {{ form.min_age(size=4, class="form-control") }} to {{ form.max_age(size=4, class="form-control") }} + {% for error in form.min_age.errors %} +

    + [{{ error }}] +

    + {% endfor %} + {% for error in form.max_age.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    +
    +
    +
    + Previous Step +   + +
    +
    -
    - -
    +
    + +
    {% endblock %} diff --git a/OpenOversight/app/templates/label_data.html b/OpenOversight/app/templates/label_data.html index d934ac94c..f79aef695 100644 --- a/OpenOversight/app/templates/label_data.html +++ b/OpenOversight/app/templates/label_data.html @@ -2,167 +2,147 @@ {% import "bootstrap/wtf.html" as wtf %} {% block title %}Volunteer with OpenOversight{% endblock %} {% block meta %} - + {% endblock %} {% block content %} - -{% if current_user and current_user.is_authenticated %} -
    -
    -

    Volunteer

    -
    - -
    -

    - - - Tutorial - -

    -

    - New to working on images for OpenOversight? Or just need a refresher? -

    -

    - - - Leaderboard - -

    -
    - - {% for department in departments %} -
    -

    {{ department.name }} ({{department.short_name}})

    -

    - - - Image classification - - - - Officer identification - -

    -
    - {% endfor %} -
    - {% if current_user.is_administrator %} - + {% if current_user and current_user.is_authenticated %} +
    +
    +

    + Volunteer +

    +
    +
    +

    + + + Tutorial + +

    +

    New to working on images for OpenOversight? Or just need a refresher?

    +

    + + + Leaderboard + +

    +
    + {% for department in departments %} +
    +

    + {{ department.name }} ({{ department.short_name }}) +

    +

    + + + Image classification + + + + Officer identification + +

    +
    + {% endfor %} +
    + {% if current_user.is_administrator %} + Add New Department - - {% endif %} - {% if current_user.is_administrator or current_user.is_area_coordinator %} - + + {% endif %} + {% if current_user.is_administrator or current_user.is_area_coordinator %} + Add New Officer - - - + + Add New Unit - - - + + Add New Incident - - {% endif %} + + {% endif %} +
    -
    -{% else %} - -
    -
    + {% else %} +
    +
    -
    - Volunteer -
    -
    - Help OpenOversight create transparency with the first public, searchable - database of law enforcement officers. -
    +
    Volunteer
    +
    + Help OpenOversight create transparency with the first public, searchable + database of law enforcement officers. +
    -

    - How you can help -

    -
    -
    - -

    - Sort Images -

    -
    - Sort submitted images into those with and without officers. -
    -
    -
    - -

    - Identify Officers -

    -
    - Have a photo containing uniformed officers? -
    -
    -
    - -

    - Contact Us -

    -
    - Reach out with any questions, concerns, or ways you'd like to get involved. -
    -
    +

    How you can help

    +
    +
    + +

    Sort Images

    +
    Sort submitted images into those with and without officers.
    +
    +
    +

    Identify Officers

    +
    Have a photo containing uniformed officers?
    +
    +
    + +

    Contact Us

    +
    Reach out with any questions, concerns, or ways you'd like to get involved.
    +
    +
    +
    +
    +
    +
    +
    +

    + Log In +

    +
    +
    + {{ form.hidden_tag() }} + {{ wtf.form_errors(form, hiddens="only") }} + {{ wtf.form_field(form.email) }} + {{ wtf.form_field(form.password) }} +
    + {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'secondary btn-lg'}) }} + {% if error %}{{ error }}{% endif %}
    +
    +

    + Forgot Password +

    - -
    -
    -
    -
    -

    - Log In -

    -
    -
    - {{ form.hidden_tag() }} - {{ wtf.form_errors(form, hiddens="only") }} - - {{ wtf.form_field(form.email) }} - {{ wtf.form_field(form.password) }} -
    - {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'secondary btn-lg'}) }} - {% if error %} - {{ error }} - {% endif %} -
    -
    -

    - Forgot Password -

    -
    -
    -
    -

    - Interested in volunteering? -

    - -
    -
    +
    +
    +

    + Interested in volunteering? +

    + +
    +
    +
    {% endif %} - {% endblock %} diff --git a/OpenOversight/app/templates/leaderboard.html b/OpenOversight/app/templates/leaderboard.html index 8dd212e2c..9a58d2099 100644 --- a/OpenOversight/app/templates/leaderboard.html +++ b/OpenOversight/app/templates/leaderboard.html @@ -1,39 +1,36 @@ {% extends "base.html" %} {% block content %} - -
    - - -
    -

    Volunteer Leaderboard

    -
    - -
    +
    -

    Top Users by Number of Images Sorted

    - {% for sorter in top_sorters %} - {{ loop.index }}. - - {{ sorter[0].username }} - - {{ sorter[1] }} -
    - {% endfor %} +

    + Volunteer Leaderboard +

    -
    - -
    -
    -

    Top Users by Number of Officers Found

    - {% for tagger in top_taggers %} - {{ loop.index }}. - - {{ tagger[0].username }} - - {{ tagger[1] }} -
    - {% endfor %} +
    +
    +

    + Top Users by Number of Images Sorted +

    + {% for sorter in top_sorters %} + {{ loop.index }}. + {{ sorter[0].username }} + - {{ sorter[1] }} +
    + {% endfor %} +
    +
    +
    +
    +

    + Top Users by Number of Officers Found +

    + {% for tagger in top_taggers %} + {{ loop.index }}. + {{ tagger[0].username }} + - {{ tagger[1] }} +
    + {% endfor %} +
    - -
    - {% endblock %} diff --git a/OpenOversight/app/templates/link_delete.html b/OpenOversight/app/templates/link_delete.html index c516d7fb7..82a2691ae 100644 --- a/OpenOversight/app/templates/link_delete.html +++ b/OpenOversight/app/templates/link_delete.html @@ -1,33 +1,29 @@ {% extends "base.html" %} - {% block content %} -
    - - -

    - Are you sure you want to delete this link? - This cannot be undone. -

    - - -
    -

    -
    +
    + +

    + Are you sure you want to delete this link? + This cannot be undone. +

    + + +
    +

    +
    {% endblock content %} diff --git a/OpenOversight/app/templates/link_edit.html b/OpenOversight/app/templates/link_edit.html index 1f24d4ba9..bb0ada6b2 100644 --- a/OpenOversight/app/templates/link_edit.html +++ b/OpenOversight/app/templates/link_edit.html @@ -1,10 +1,8 @@ {% extends "form.html" %} - {% block page_title %} - Update Link + Update Link {% endblock page_title %} - {% block form %} -

    For officer with OOID {{ form.officer_id.data }}.

    - {{ super() }} +

    For officer with OOID {{ form.officer_id.data }}.

    + {{ super() }} {% endblock form %} diff --git a/OpenOversight/app/templates/link_new.html b/OpenOversight/app/templates/link_new.html index c52524b7e..66ce7dc47 100644 --- a/OpenOversight/app/templates/link_new.html +++ b/OpenOversight/app/templates/link_new.html @@ -1,10 +1,8 @@ {% extends 'form.html' %} - {% block page_title %} - New Link + New Link {% endblock page_title %} - {% block form %}

    For officer with OOID {{ form.officer_id.data }}.

    - {{ super() }} + {{ super() }} {% endblock form %} diff --git a/OpenOversight/app/templates/list.html b/OpenOversight/app/templates/list.html index a870718c7..2621baed9 100644 --- a/OpenOversight/app/templates/list.html +++ b/OpenOversight/app/templates/list.html @@ -1,14 +1,11 @@ {% extends "base.html" %} - {% block content %}
    {% with paginate=objects, next_url=url_for(url, page=objects.next_num), prev_url=url_for(url, page=objects.prev_num), location='top' %} {% include "partials/paginate_nav.html" %} {% endwith %} - {% block list %} - {% endblock list%} - + {% endblock list %} {% with paginate=objects, next_url=url_for(url, page=objects.next_num), prev_url=url_for(url, page=objects.prev_num), location='bottom' %} {% include "partials/paginate_nav.html" %} {% endwith %} diff --git a/OpenOversight/app/templates/note_delete.html b/OpenOversight/app/templates/note_delete.html index a4d0d85b3..eb3fe7983 100644 --- a/OpenOversight/app/templates/note_delete.html +++ b/OpenOversight/app/templates/note_delete.html @@ -1,23 +1,18 @@ {% extends "base.html" %} - {% block content %} -
    - - -

    - Are you sure you want to delete this note? - This cannot be undone. -

    - - -
    -

    -
    +
    + +

    + Are you sure you want to delete this note? + This cannot be undone. +

    + + +
    +

    +
    {% endblock content %} diff --git a/OpenOversight/app/templates/note_edit.html b/OpenOversight/app/templates/note_edit.html index a561b95f4..6c3f3aef5 100644 --- a/OpenOversight/app/templates/note_edit.html +++ b/OpenOversight/app/templates/note_edit.html @@ -1,18 +1,14 @@ {% extends "form.html" %} {% import "bootstrap/wtf.html" as wtf %} - - {% block page_title %} - Update Note + Update Note {% endblock page_title %} - - {% block form %} - {% if form.errors %} - {% set post_url=url_for('main.note_api', officer_id=obj.officer_id, obj_id=obj.id) %} - {% else %} - {% set post_url="{}/edit".format(url_for('main.note_api', officer_id=obj.officer_id, obj_id=obj.id)) %} - {% endif %} - {{ wtf.quick_form(form, action=post_url, method='post', button_map={'submit':'primary'}) }} -
    + {% if form.errors %} + {% set post_url = url_for('main.note_api', officer_id=obj.officer_id, obj_id=obj.id) %} + {% else %} + {% set post_url = "{}/edit".format(url_for('main.note_api', officer_id=obj.officer_id, obj_id=obj.id)) %} + {% endif %} + {{ wtf.quick_form(form, action=post_url, method='post', button_map={'submit':'primary'}) }} +
    {% endblock form %} diff --git a/OpenOversight/app/templates/note_new.html b/OpenOversight/app/templates/note_new.html index 0e40465e0..1e88a63bf 100644 --- a/OpenOversight/app/templates/note_new.html +++ b/OpenOversight/app/templates/note_new.html @@ -1,10 +1,12 @@ {% extends 'form.html' %} - {% block page_title %} - New Note + New Note {% endblock page_title %} - {% block form %} -

    For officer with OOID {{ form.officer_id.data }}.
    {{form.description}}

    - {{ super() }} +

    + For officer with OOID {{ form.officer_id.data }}. +
    + {{ form.description }} +

    + {{ super() }} {% endblock form %} diff --git a/OpenOversight/app/templates/officer.html b/OpenOversight/app/templates/officer.html index b3d9ddaaf..3853df8b6 100644 --- a/OpenOversight/app/templates/officer.html +++ b/OpenOversight/app/templates/officer.html @@ -15,121 +15,141 @@ {% if officer.image_width and officer.image_height %} - - + + {% elif not officer.image_url %} - - + + {% endif %} - + {% endblock %} {% block content %} - -{% set is_admin_or_coordinator = current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %} - -
    - - + {# end container #} {% endblock %} diff --git a/OpenOversight/app/templates/partials/incident_fields.html b/OpenOversight/app/templates/partials/incident_fields.html index eaccb3ae2..9444f9000 100644 --- a/OpenOversight/app/templates/partials/incident_fields.html +++ b/OpenOversight/app/templates/partials/incident_fields.html @@ -1,94 +1,121 @@ - Date - {{ incident.date.strftime('%b %d, %Y') }} + + Date + + {{ incident.date.strftime("%b %d, %Y") }} {% if incident.time %} - - Time - {{ incident.time.strftime('%l:%M %p') }} - + + + Time + + {{ incident.time.strftime("%l:%M %p") }} + {% endif %} {% if incident.report_number %} - - Report # - {{ incident.report_number }} - + + + Report # + + {{ incident.report_number }} + {% endif %} - Department - {{ incident.department.name }} + + Department + + {{ incident.department.name }} {% if incident.officers %} - - Officers - - {% for officer in incident.officers %} - {{ officer.full_name()|title }}{% if - not loop.last %}, {% endif %} - {% endfor %} - - + + + Officers + + + {% for officer in incident.officers %} + {{ officer.full_name() |title }} + {% if + not loop.last %} + , + {% endif %} + {% endfor %} + + {% endif %} {% if detail and incident.license_plates %} - - License Plates - - {% for plate in incident.license_plates %} - {{ plate.state }} {{ plate.number }}{% if not loop.last %}
    {% endif %} - {% endfor %} - - + + + License Plates + + + {% for plate in incident.license_plates %} + {{ plate.state }} {{ plate.number }} + {% if not loop.last %}
    {% endif %} + {% endfor %} + + {% endif %} {% if not detail %} - - Description - - {{ incident.description | markdown}} - - - - - - - - + + + Description + + {{ incident.description | markdown }} + + + + + + + {% endif %} {% with address=incident.address %} -{% if address %} - - Address - - {% if address.street_name %} - {{ address.street_name }} - {% if address.cross_street1%} - {% if address.cross_street2%} - between {{ address.cross_street1 }} and {{ address.cross_street2 }} - {% else %} - near {{ address.cross_street1 }} - {% endif %} - {% endif %} -
    - {% endif %} - {{ address.city }}, {{ address.state }} {% if address.zipcode %} {{ address.zip_code }} {% endif %} - - -{% endif %} + {% if address %} + + + Address + + + {% if address.street_name %} + {{ address.street_name }} + {% if address.cross_street1 %} + {% if address.cross_street2 %} + between {{ address.cross_street1 }} and {{ address.cross_street2 }} + {% else %} + near {{ address.cross_street1 }} + {% endif %} + {% endif %} +
    + {% endif %} + {{ address.city }}, {{ address.state }} + {% if address.zipcode %}{{ address.zip_code }}{% endif %} + + + {% endif %} {% endwith %} {% if detail and current_user.is_administrator %} -{% if incident.creator %} - - Creator - {{ incident.creator.username }} - - -{% endif %} -{% if incident.last_updated_by %} - - Last Edited By - {{ - incident.last_updated_by.username }} - -{% endif %} + {% if incident.creator %} + + + Creator + + + {{ incident.creator.username }} + + + {% endif %} + {% if incident.last_updated_by %} + + + Last Edited By + + + {{ + incident.last_updated_by.username }} + + + {% endif %} {% endif %} diff --git a/OpenOversight/app/templates/partials/incident_form.html b/OpenOversight/app/templates/partials/incident_form.html index 4853b13d2..07643cae3 100644 --- a/OpenOversight/app/templates/partials/incident_form.html +++ b/OpenOversight/app/templates/partials/incident_form.html @@ -1,42 +1,38 @@ {% import "bootstrap/wtf.html" as wtf %} -
    - {{ form.hidden_tag() }} -
    - {{ wtf.form_errors(form, hiddens="only") }} -
    - {{ wtf.form_field(form.date_field, autofocus="autofocus") }} - {{ wtf.form_field(form.time_field) }} - {{ wtf.form_field(form.report_number) }} - {{ wtf.form_field(form.department) }} - {{ wtf.form_field(form.description) }} - {% with subform=form.address, no_remove=True %} - {% include "partials/subform.html" %} - {% endwith %} -
    - {{ form.license_plates.label }} - {% for subform in form.license_plates %} - {% with id="js-license-plate", number=loop.index %} - {% include "partials/subform.html" %} - {% endwith %} - {% endfor %} - {# buttons are disabled until the DOM loads and click handlers are added #} - -
    - {{ form.officers.label }} - {% for subform in form.officers %} - {% with id="js-officer", number=loop.index %} - {% include "partials/subform.html" %} - {% endwith %} - {% endfor %} - -
    - {{ form.links.label }} - {% for subform in form.links %} - {% include "partials/subform.html" %} - {% endfor %} - -
    - - {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }} + {{ form.hidden_tag() }} +
    {{ wtf.form_errors(form, hiddens="only") }}
    + {{ wtf.form_field(form.date_field, autofocus="autofocus") }} + {{ wtf.form_field(form.time_field) }} + {{ wtf.form_field(form.report_number) }} + {{ wtf.form_field(form.department) }} + {{ wtf.form_field(form.description) }} + {% with subform=form.address, no_remove=True %} + {% include "partials/subform.html" %} + {% endwith %} +
    + {{ form.license_plates.label }} + {% for subform in form.license_plates %} + {% with id="js-license-plate", number=loop.index %} + {% include "partials/subform.html" %} + {% endwith %} + {% endfor %} + {# buttons are disabled until the DOM loads and click handlers are added #} + +
    + {{ form.officers.label }} + {% for subform in form.officers %} + {% with id="js-officer", number=loop.index %} + {% include "partials/subform.html" %} + {% endwith %} + {% endfor %} + +
    + {{ form.links.label }} + {% for subform in form.links %} + {% include "partials/subform.html" %} + {% endfor %} + +
    + {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }}
    diff --git a/OpenOversight/app/templates/partials/links_and_videos_row.html b/OpenOversight/app/templates/partials/links_and_videos_row.html index 60db37ff8..404484fb5 100644 --- a/OpenOversight/app/templates/partials/links_and_videos_row.html +++ b/OpenOversight/app/templates/partials/links_and_videos_row.html @@ -1,118 +1,112 @@ {% if obj.links|length > 0 or is_admin_or_coordinator %} -

    Links

    -{% for type, list in obj.links|groupby('link_type') %} -{% if type == 'link' %} -
      - {% for link in list %} -
    • - {{ link.title or link.url }} - {% if officer and (is_admin_or_coordinator or link.creator_id == current_user.id) %} - - Edit - - - - Delete - - +

      Links

      + {% for type, list in obj.links|groupby("link_type") %} + {% if type == "link" %} +
        + {% for link in list %} +
      • + {{ link.title or link.url }} + {% if officer and (is_admin_or_coordinator or link.creator_id == current_user.id) %} + + Edit + + + + Delete + + + {% endif %} + {% if link.description or link.author %} +
        + {% if link.description %}{{ link.description }}{% endif %} + {% if link.author %} + {% if link.description %}-{% endif %} + {{ link.author }} + {% endif %} +
        + {% endif %} +
      • + {% endfor %} +
      {% endif %} - {% if link.description or link.author %} -
      - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
      - {% endif %} -
    • {% endfor %} -
    -{% endif %} -{% endfor %} -{% if officer and (current_user.is_administrator -or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id)) %} -New Link/Video -{% endif %} -{% for type, list in obj.links|groupby('link_type') %} -{% if type == 'video' %} -

    Videos

    -
      - {% for link in list %} - {% with link_url = link.url.split('v=')[1] %} -
    • - {% if link.title %} -
      {{ link.title }}
      + {% if officer and (current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id)) %} + New Link/Video + {% endif %} + {% for type, list in obj.links|groupby("link_type") %} + {% if type == "video" %} +

      Videos

      +
        + {% for link in list %} + {% with link_url = link.url.split("v=")[1] %} +
      • + {% if link.title %}
        {{ link.title }}
        {% endif %} + {% if officer and (current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) + or link.creator_id == current_user.id) %} + + Edit + + + + Delete + + + {% endif %} +
        + +
        + {% if link.description or link.author %} +
        + {% if link.description %}{{ link.description }}{% endif %} + {% if link.author %} + {% if link.description %}-{% endif %} + {{ link.author }} + {% endif %} +
        + {% endif %} +
      • + {% endwith %} + {% endfor %} +
      {% endif %} - {% if officer and (current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) - or link.creator_id == current_user.id) %} - - Edit - - - - Delete - - + {% if type == "other_video" %} +

      Other videos

      +
        + {% for link in list %} +
      • + {{ link.title or link.url }} + {% if officer and (current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) + or link.creator_id == current_user.id) %} + + Edit + + + + Delete + + + {% endif %} + {% if link.description or link.author %} +
        + {% if link.description %}{{ link.description }}{% endif %} + {% if link.author %} + {% if link.description %}-{% endif %} + {{ link.author }} + {% endif %} +
        + {% endif %} +
      • + {% endfor %} +
      {% endif %} -
      - -
      - {% if link.description or link.author %} -
      - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
      - {% endif %} -
    • - {% endwith%} {% endfor %} -
    -{% endif %} -{% if type == 'other_video' %} -

    Other videos

    -
      - {% for link in list %} -
    • - {{ link.title or link.url }} - {% if officer and (current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) - or link.creator_id == current_user.id) %} - - Edit - - - - Delete - - - {% endif %} - {% if link.description or link.author %} -
      - {% if link.description %} - {{ link.description }} - {% endif %} - {% if link.author %} - {% if link.description %}- {% endif %}{{ link.author }} - {% endif %} -
      - {% endif %} -
    • - {% endfor %} -
    -{% endif %} -{% endfor %} {% endif %} diff --git a/OpenOversight/app/templates/partials/links_subform.html b/OpenOversight/app/templates/partials/links_subform.html index 5fb67aa0a..6730bcdd0 100644 --- a/OpenOversight/app/templates/partials/links_subform.html +++ b/OpenOversight/app/templates/partials/links_subform.html @@ -1,7 +1,7 @@
    - {{ form.links.label }} - {% for subform in form.links %} - {% include "partials/subform.html" %} - {% endfor %} - + {{ form.links.label }} + {% for subform in form.links %} + {% include "partials/subform.html" %} + {% endfor %} +
    diff --git a/OpenOversight/app/templates/partials/officer_add_photos.html b/OpenOversight/app/templates/partials/officer_add_photos.html index 3e859e9c0..a784829e5 100644 --- a/OpenOversight/app/templates/partials/officer_add_photos.html +++ b/OpenOversight/app/templates/partials/officer_add_photos.html @@ -1,3 +1,3 @@ {% if is_admin_or_coordinator %} - Add photos of this officer + Add photos of this officer {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_assignment_history.html b/OpenOversight/app/templates/partials/officer_assignment_history.html index fa48bbb4d..1a3d3aff4 100644 --- a/OpenOversight/app/templates/partials/officer_assignment_history.html +++ b/OpenOversight/app/templates/partials/officer_assignment_history.html @@ -1,150 +1,167 @@

    Assignment History

    - - - - - - - {% if is_admin_or_coordinator %} - - {% endif %} - - - {% for assignment in assignments|rejectattr('star_date','ne',None) %} - - - - - - - - - {% endfor %} - {% for assignment in assignments | rejectattr('star_date', 'none') | sort(attribute='star_date', reverse=True) %} - - - - - - - - - {% endfor %} - + + + + + + + {% if is_admin_or_coordinator %} + + {% endif %} + + + {% for assignment in assignments|rejectattr('star_date','ne',None) %} + + + + + + + + + {% endfor %} + {% for assignment in assignments | rejectattr('star_date', 'none') | sort(attribute='star_date', reverse=True) %} + + + + + + + + + {% endfor %} +
    Job TitleBadge No.UnitStart DateEnd DateEdit
    {{ assignment.job.job_title }}{{ assignment.star_no }} - {% if assignment.unit_id %}{{ assignment.unit.descrip }}{% endif %} - - {% if assignment.star_date %} - {{ assignment.star_date }} - {% else %} - Unknown - {% endif %} - - {% if assignment.resign_date %} {{ assignment.resign_date }} {% endif %} - - {% if is_admin_or_coordinator %} - - Edit - - - {% endif %} -
    {{ assignment.job.job_title }}{{ assignment.star_no }} - {% if assignment.unit_id %}{{ assignment.unit.descrip }}{% endif %} - - {% if assignment.star_date: %} - {{ assignment.star_date }} - {% else %} - Unknown - {% endif %} - - {% if assignment.resign_date %} {{ assignment.resign_date }} {% endif %} - - {% if is_admin_or_coordinator %} - - Edit - - - {% endif %} -
    + Job Title + + Badge No. + + Unit + + Start Date + + End Date + + Edit +
    {{ assignment.job.job_title }}{{ assignment.star_no }} + {% if assignment.unit_id %}{{ assignment.unit.descrip }}{% endif %} + + {% if assignment.star_date %} + {{ assignment.star_date }} + {% else %} + Unknown + {% endif %} + + {% if assignment.resign_date %}{{ assignment.resign_date }}{% endif %} + + {% if is_admin_or_coordinator %} + + Edit + + + {% endif %} +
    {{ assignment.job.job_title }}{{ assignment.star_no }} + {% if assignment.unit_id %}{{ assignment.unit.descrip }}{% endif %} + + {% if assignment.star_date: %} + {{ assignment.star_date }} + {% else %} + Unknown + {% endif %} + + {% if assignment.resign_date %}{{ assignment.resign_date }}{% endif %} + + {% if is_admin_or_coordinator %} + + Edit + + + {% endif %} +
    - {% if is_admin_or_coordinator %} -

    Add Assignment Admin only

    - +

    + Add AssignmentAdmin only +

    +
    - - {{ form.hidden_tag() }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + {{ form.hidden_tag() }} + + + + + + + + + + + + + + + + + + + + + + + + -
    New badge number: - {{ form.star_no }} - {% for error in form.star_no.errors %} -

    [{{ error }}]

    - {% endfor %} -
    New job title: - {{ form.job_title }} - {% for error in form.job_title.errors %} -

    [{{ error }}]

    - {% endfor %} -
    New unit: - {{ form.unit }} - {% for error in form.unit.errors %} -

    [{{ error }}]

    - {% endfor %} -
    Start date of new assignment: - {{ form.star_date }} - {% for error in form.star_date.errors %} -

    [{{ error }}]

    - {% endfor %} -
    End date of new assignment: - {{ form.resign_date }} - {% for error in form.resign_date.errors %} -

    [{{ error }}]

    - {% endfor %} -
    - -
    + New badge number: + + {{ form.star_no }} + {% for error in form.star_no.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + New job title: + + {{ form.job_title }} + {% for error in form.job_title.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + New unit: + + {{ form.unit }} + {% for error in form.unit.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + Start date of new assignment: + + {{ form.star_date }} + {% for error in form.star_date.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + End date of new assignment: + + {{ form.resign_date }} + {% for error in form.resign_date.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + +
    + {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_descriptions.html b/OpenOversight/app/templates/partials/officer_descriptions.html index 8f0ee71cd..ee6d80beb 100644 --- a/OpenOversight/app/templates/partials/officer_descriptions.html +++ b/OpenOversight/app/templates/partials/officer_descriptions.html @@ -1,31 +1,28 @@

    Descriptions

    {% if officer.descriptions %} -
      +
        {% for description in officer.descriptions %} -
      • - {{ description.date_updated.strftime('%b %d, %Y')}}
        +
      • + {{ description.date_updated.strftime("%b %d, %Y") }} +
        {{ description.text_contents | markdown }} - {% if current_user and not current_user.is_anonymous %} - {{ description.creator.username }} - {% endif %} + {% if current_user and not current_user.is_anonymous %}{{ description.creator.username }}{% endif %} {% if description.creator_id == current_user.get_id() or - current_user.is_administrator %} - + current_user.is_administrator %} + Edit - - + + Delete - + {% endif %} -
      • + {% endfor %} -
      +
    {% endif %} - {% if is_admin_or_coordinator %} - - New description - + New description {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_faces.html b/OpenOversight/app/templates/partials/officer_faces.html index 62481b235..130d4d7c7 100644 --- a/OpenOversight/app/templates/partials/officer_faces.html +++ b/OpenOversight/app/templates/partials/officer_faces.html @@ -1,14 +1,6 @@ {% for path in paths %} - -{# Don't try to link if only image is the placeholder #} -{% if faces %} - -{% endif %} - - Submission - -{% if faces %} - -{% endif %} - + {# Don't try to link if only image is the placeholder #} + {% if faces %}{% endif %} + Submission + {% if faces %}{% endif %} {% endfor %} diff --git a/OpenOversight/app/templates/partials/officer_general_information.html b/OpenOversight/app/templates/partials/officer_general_information.html index 6269b3b41..dc4760b9f 100644 --- a/OpenOversight/app/templates/partials/officer_general_information.html +++ b/OpenOversight/app/templates/partials/officer_general_information.html @@ -1,64 +1,84 @@
    -
    -

    - General Information - {% if is_admin_or_coordinator %} - - - +
    +

    + General Information + {% if is_admin_or_coordinator %} + + + + {% endif %} +

    + + + + + + + + + + + {% if officer.unique_internal_identifier %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + {{ officer.full_name() }}
    + OpenOversight ID + {{ officer.id }}
    + Unique Internal Identifier + {{ officer.unique_internal_identifier }}
    + Department + {{ officer.department.name }}
    + Race + {{ officer.race_label() }}
    + Gender + {{ officer.gender_label() }}
    + Birth Year (Age) + + {% if officer.birth_year %} + {{ officer.birth_year }} (~{{ officer.birth_year|get_age }} y/o) + {% else %} + Data Missing {% endif %} - - - - - - - - - - - - {% if officer.unique_internal_identifier %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Name{{ officer.full_name() }}
    OpenOversight ID{{ officer.id }}
    Unique Internal Identifier{{ officer.unique_internal_identifier }}
    Department{{ officer.department.name }}
    Race{{ officer.race_label() }}
    Gender{{ officer.gender_label() }}
    Birth Year (Age) - {% if officer.birth_year %} - {{ officer.birth_year }} (~{{ officer.birth_year|get_age }} y/o) - {% else %} - Data Missing - {% endif %} -
    First Employment Date{{ officer.employment_date }}
    Number of known incidents{{ officer.incidents | length }}
    Currently on the force{{ officer.currently_on_force() }}
    - +
    + First Employment Date + {{ officer.employment_date }}
    + Number of known incidents + {{ officer.incidents | length }}
    + Currently on the force + {{ officer.currently_on_force() }}
    +

    diff --git a/OpenOversight/app/templates/partials/officer_incidents.html b/OpenOversight/app/templates/partials/officer_incidents.html index 2148b3735..4984ab5a5 100644 --- a/OpenOversight/app/templates/partials/officer_incidents.html +++ b/OpenOversight/app/templates/partials/officer_incidents.html @@ -1,42 +1,43 @@

    Incidents

    {% if officer.incidents %} - +
    - {% for incident in officer.incidents | sort(attribute='date') | reverse %} + {% for incident in officer.incidents | sort(attribute='date') | reverse %} {% if not loop.first %} - + - + {% endif %} - + {% include 'partials/incident_fields.html' %} - {% endfor %} + {% endfor %} -
     
    -

    - - Incident - {% if incident.report_number %} - {{ incident.report_number }} - {% else %} - {{ incident.id}} - {% endif %} - - {% if current_user.is_administrator or (current_user.is_area_coordinator and - current_user.ac_department_id == incident.department_id) %} - - - - {% endif %} -

    -
    +

    + + Incident + {% if incident.report_number %} + {{ incident.report_number }} + {% else %} + {{ incident.id }} + {% endif %} + + {% if current_user.is_administrator or (current_user.is_area_coordinator and + current_user.ac_department_id == incident.department_id) %} + + + + {% endif %} +

    +
    + {% block js_footer %} {% endblock %} {% endif %} {% if is_admin_or_coordinator %} -New - Incident + New + Incident {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_notes.html b/OpenOversight/app/templates/partials/officer_notes.html index ba86905f9..b8b14de6e 100644 --- a/OpenOversight/app/templates/partials/officer_notes.html +++ b/OpenOversight/app/templates/partials/officer_notes.html @@ -1,26 +1,23 @@

    Notes

    -
      - {% for note in officer.notes %} + {% for note in officer.notes %}
    • - {{ note.date_updated.strftime('%b %d, %Y')}} -
      - {{ note.text_contents | markdown }} - {{ note.creator.username }} - {% if note.creator_id == current_user.get_id() or current_user.is_administrator %} + {{ note.date_updated.strftime("%b %d, %Y") }} +
      + {{ note.text_contents | markdown }} + {{ note.creator.username }} + {% if note.creator_id == current_user.get_id() or current_user.is_administrator %} - Edit - + Edit + - Delete - + Delete + - {% endif %} + {% endif %}
    • - {% endfor %} + {% endfor %}
    - - - New Note - +New Note diff --git a/OpenOversight/app/templates/partials/officer_salary.html b/OpenOversight/app/templates/partials/officer_salary.html index 115338eb1..d520356d1 100644 --- a/OpenOversight/app/templates/partials/officer_salary.html +++ b/OpenOversight/app/templates/partials/officer_salary.html @@ -1,54 +1,58 @@

    Salary

    {% if officer.salaries %} - +
    - - - - - {% if is_admin_or_coordinator %} - - {% endif %} + + + + + {% if is_admin_or_coordinator %} + + {% endif %} - {% for salary in officer.salaries %} - - - {% if salary.overtime_pay %} - {% if salary.overtime_pay > 0 %} - - {% else %} - - {% endif %} - - {% else %} - - - {% endif %} - - - - {% if is_admin_or_coordinator %} - - {% endif %} - - {% endfor %} + {% for salary in officer.salaries %} + + + {% if salary.overtime_pay %} + {% if salary.overtime_pay > 0 %} + + {% else %} + + {% endif %} + + {% else %} + + + {% endif %} + + {% if is_admin_or_coordinator %} + + {% endif %} + + {% endfor %} -
    Annual SalaryOvertimeTotal PayYearEdit + Annual Salary + + Overtime + + Total Pay + + Year + + Edit +
    {{ '${:,.2f}'.format(salary.salary) }}{{ '${:,.2f}'.format(salary.overtime_pay) }}{{ '${:,.2f}'.format(salary.salary + salary.overtime_pay) }}{% if salary.is_fiscal_year: %}FY {% endif %}{{ salary.year }} - - Edit - - -
    {{ '${:,.2f}'.format(salary.salary) }}{{ '${:,.2f}'.format(salary.overtime_pay) }}{{ '${:,.2f}'.format(salary.salary + salary.overtime_pay) }} + {% if salary.is_fiscal_year: %}FY{% endif %} + {{ salary.year }} + + + Edit + + +
    + {% endif %} - {% if is_admin_or_coordinator %} - New Salary + New Salary {% endif %} diff --git a/OpenOversight/app/templates/partials/paginate.html b/OpenOversight/app/templates/partials/paginate.html index 82ba0502a..9ba924d08 100644 --- a/OpenOversight/app/templates/partials/paginate.html +++ b/OpenOversight/app/templates/partials/paginate.html @@ -1,20 +1,22 @@
    - - {% if officers.has_prev %} -
    - {% include 'partials/officer_form_fields_hidden.html' %} - -
    - {% endif %} + {% if officers.has_prev %} +
    + {% include 'partials/officer_form_fields_hidden.html' %} + +
    + {% endif %} {% if officers.total > 0 %} -

    Gallery {{ officers.page }} of {{ officers.pages }}

    +

    + Gallery {{ officers.page }} of {{ officers.pages }} +

    + {% endif %} + {% if officers.has_next %} +
    + {% include 'partials/officer_form_fields_hidden.html' %} + +
    {% endif %} - {% if officers.has_next %} -
    - {% include 'partials/officer_form_fields_hidden.html' %} - -
    - {% endif %} -
    diff --git a/OpenOversight/app/templates/partials/paginate_nav.html b/OpenOversight/app/templates/partials/paginate_nav.html index fcce55215..377334c3d 100644 --- a/OpenOversight/app/templates/partials/paginate_nav.html +++ b/OpenOversight/app/templates/partials/paginate_nav.html @@ -1,27 +1,25 @@ + {% elif paginate.total == 0 %} + {% if location == 'top' %}Showing 0 of 0{% endif %} + {% else %} + Showing {{(paginate.page-1)*paginate.per_page + 1 }}-{{ paginate.total }} of {{ paginate.total }} + {% endif %} + + diff --git a/OpenOversight/app/templates/partials/subform.html b/OpenOversight/app/templates/partials/subform.html index 6ed3c9c41..d4ef6e28d 100644 --- a/OpenOversight/app/templates/partials/subform.html +++ b/OpenOversight/app/templates/partials/subform.html @@ -1,22 +1,19 @@ {% import "bootstrap/wtf.html" as wtf %} - -
    +
    {{ subform.hidden_tag() }} -
    - {{ wtf.form_errors(subform, hiddens="only") }} -
    +
    {{ wtf.form_errors(subform, hiddens="only") }}
    {% for subfield in subform %} {% if not bootstrap_is_hidden_field(subfield) %} {{ wtf.form_field(subfield, - form_type=form_type, - horizontal_columns=horizontal_columns, - button_map=button_map) }} + form_type=form_type, + horizontal_columns=horizontal_columns, + button_map=button_map) }} {% endif %} {% endfor %} {% if not no_remove %} - + {% endif %}
    diff --git a/OpenOversight/app/templates/privacy.html b/OpenOversight/app/templates/privacy.html index cdea90936..411b1ab85 100644 --- a/OpenOversight/app/templates/privacy.html +++ b/OpenOversight/app/templates/privacy.html @@ -1,68 +1,67 @@ {% extends "base.html" %} {% block title %}Privacy Policy - OpenOversight{% endblock %} -{% block meta %}{% endblock %} +{% block meta %} + +{% endblock %} {% block content %} - -
    +
    -
    Privacy Policy
    -
    September 1, 2016
    +
    Privacy Policy
    +
    September 1, 2016
    -

    - Lucy Parsons Labs takes your privacy and security very seriously. OpenOversight is hosted on its own - subdomain and web server, independent from the main Lucy Parsons Labs website. This Privacy Policy only - applies to OpenOversight. - -

    +

    + Lucy Parsons Labs takes your privacy and security very seriously. OpenOversight is hosted on its own + subdomain and web server, independent from the main Lucy Parsons Labs website. This Privacy Policy only + applies to OpenOversight. + +

    -
    -

    Logs and Security

    -

    - The Lucy Parsons Labs uses HTTPS on all of its web servers and on all of our subdomains. However, since - OpenOversight is hosted on its own subdomain and web server, it will be possible for employers, ISPs, or - anyone performing network-level traffic analysis to identify that you have visited OpenOversight. The - Lucy Parsons Labs does not maintain network logs on our web servers. We have explicitly disabled - nginx from maintaining access or error logs, an example configuration can be found here. We also do not block incoming connections from any regions or IP - block, so we welcome traffic from any anonymity network such as Tor. -

    -
    -
    -

    What Is Collected

    -

    - OpenOversight has a form for volunteers to submit data to us using Google Docs. This data is not - hosted by Lucy Parsons Labs and is outside of this Privacy Policy. The Submission Form does not require - you to provide a name and we will not reject any submissions without names. Please note that Google and - other third parties might collect your IPs and other identifying information. Google's Privacy Policy - can be found here and Google - Drive's Terms of Service may be found here.

    -
    +
    +

    Logs and Security

    +

    + The Lucy Parsons Labs uses HTTPS on all of its web servers and on all of our subdomains. However, since + OpenOversight is hosted on its own subdomain and web server, it will be possible for employers, ISPs, or + anyone performing network-level traffic analysis to identify that you have visited OpenOversight. The + Lucy Parsons Labs does not maintain network logs on our web servers. We have explicitly disabled + nginx from maintaining access or error logs, an example configuration can be found here. We also do not block incoming connections from any regions or IP + block, so we welcome traffic from any anonymity network such as Tor. +

    +
    +
    +

    What Is Collected

    +

    + OpenOversight has a form for volunteers to submit data to us using Google Docs. This data is not + hosted by Lucy Parsons Labs and is outside of this Privacy Policy. The Submission Form does not require + you to provide a name and we will not reject any submissions without names. Please note that Google and + other third parties might collect your IPs and other identifying information. Google's Privacy Policy + can be found here and Google + Drive's Terms of Service may be found here. +

    +
    -
    -
    -

    Legal Action

    -

    - A note to Illinois law enforcement: This project does not perform facial recognition and is thus - in compliance with the Biometric Information Privacy Act. Requests or questions regarding this project - from those affiliated with law enforcement must be directed to our legal representation. You may also review our Warrant Canary. -

    -
    -
    -
    -

    Future Changes

    +
    +

    Legal Action

    - In the case that the Lucy Parsons Labs has to update this Privacy Policy, we will update the date at - the top - of this webpage. - + A note to Illinois law enforcement: This project does not perform facial recognition and is thus + in compliance with the Biometric Information Privacy Act. Requests or questions regarding this project + from those affiliated with law enforcement must be directed to our legal representation. You may also review our Warrant Canary.

    +
    +
    +
    +

    Future Changes

    +

    + In the case that the Lucy Parsons Labs has to update this Privacy Policy, we will update the date at + the top + of this webpage. + +

    -
    +
    {% endblock %} diff --git a/OpenOversight/app/templates/profile.html b/OpenOversight/app/templates/profile.html index f85427f4d..f9506f594 100644 --- a/OpenOversight/app/templates/profile.html +++ b/OpenOversight/app/templates/profile.html @@ -1,116 +1,124 @@ {% extends "base.html" %} {% block content %} - -
    - - - - -
    -
    -
    -
    -

    User Statistics

    +
    + +
    +
    +
    +
    +

    User Statistics

    + + + + + + + + + + + +
    + Number of images classified + {{ user.classifications|length }}
    + Number of officers identified + {{ user.tags|length }}
    +

    + Show leaderboard +

    +

    Account Status

    + {% if user.is_disabled %} +

    Disabled

    + {% elif user.is_disabled == False %} +

    Enabled

    + {% endif %} + {% if current_user.is_administrator and user.is_administrator == False %} +

    + Edit user Admin only +

    + + {% endif %} + {% if current_user.is_administrator %} +

    User Email

    +

    + {{ user.email }} +

    + {% endif %} + {% if department is defined %} +

    Default Department

    +

    + {{ department }} +

    + {% endif %} +
    +
    +
    +
    + {% if user.classifications %} +
    +
    +

    Image classifications

    - + - - + + + + + {% for classification in user.classifications %} + + + + + {% endfor %} + +
    Number of images classified{{ user.classifications|length }} + Image ID + + Did the user find officers in the image? +
    + Image {{ classification.id }} + {{ classification.contains_cops }}
    +
    +
    + {% endif %} + {% if user.tags %} +
    +
    +

    Officer Identifications

    + + - - + + + + + {% for tag in user.tags %} + + + + + {% endfor %}
    Number of officers identified{{ user.tags|length }} + Tag ID + + Officer ID +
    + Tag {{ tag.id }} + + {{ tag.officer_id }} +
    - -

    Show leaderboard

    - -

    Account Status

    - {% if user.is_disabled %} -

    Disabled

    - {% elif user.is_disabled == False %} -

    Enabled

    - {% endif %} - - {% if current_user.is_administrator and user.is_administrator == False %} -

    Edit user Admin only

    - - {% endif %} - - {% if current_user.is_administrator %} -

    User Email

    -

    - {{ user.email }} -

    - {% endif %} - {% if department is defined %} -

    Default Department

    -

    - {{ department }} -

    - {% endif %}
    -
    -
    - -{% if user.classifications %} -
    -
    -

    Image classifications

    - - - - - - - - - {% for classification in user.classifications %} - - - - - {% endfor %} - -
    Image IDDid the user find officers in the image?
    - Image {{ classification.id }}{{ classification.contains_cops }}
    -
    -
    -{% endif %} - -{% if user.tags %} -
    -
    -

    Officer Identifications

    - - - - - - - - - {% for tag in user.tags %} - - - - - {% endfor %} - -
    Tag IDOfficer ID
    - Tag {{ tag.id }} - {{ tag.officer_id }}
    -
    + {% endif %}
    -{% endif %} - -
    - {% endblock %} diff --git a/OpenOversight/app/templates/sort.html b/OpenOversight/app/templates/sort.html index df43826a5..e4f871352 100644 --- a/OpenOversight/app/templates/sort.html +++ b/OpenOversight/app/templates/sort.html @@ -1,81 +1,95 @@ {% extends "base.html" %} - -{% block head %} - -{% endblock %} - - +{% block head %}{% endblock %} {% block js_footer %} - + $(document).bind('keydown', 's', function() { + window.location = $('#answer-skip').attr('href'); + }); + {% endblock %} - - {% block content %} - -
    - +
    {% if current_user and current_user.is_authenticated %} {% if image and current_user.is_disabled == False %} -
    -
    -

    Do you see uniformed law enforcement officers in the photo?

    -
    -
    - -
    -
    -
    - -
    -
    -
    - - Skip +
    +
    +

    + Do you see uniformed law enforcement officers in the photo? +

    +
    -
    -
    - - -
    +
    +
    +
    + +
    +
    +
    + + Skip +
    +
    +
    + + +
    +
    -
    - -
    -
    -
    - Picture to be sorted +
    +
    +
    + Picture to be sorted +
    -
    - {% elif current_user.is_disabled == True %} -

    Your account has been disabled due to too many incorrect classifications/tags!

    -

    Email us to get it enabled again

    +

    Your account has been disabled due to too many incorrect classifications/tags!

    +

    + Email us to get it enabled again +

    {% else %} -

    All images have been classfied!

    -

    Submit officer pictures to us

    -

    identify officers in the classified images

    +

    All images have been classfied!

    +

    + Submit officer pictures to us +

    +

    + identify officers in the classified images +

    {% endif %} {% endif %} - -
    +
    {% endblock %} diff --git a/OpenOversight/app/templates/submit_image.html b/OpenOversight/app/templates/submit_image.html index a351085a0..f6eed910e 100644 --- a/OpenOversight/app/templates/submit_image.html +++ b/OpenOversight/app/templates/submit_image.html @@ -2,75 +2,86 @@ {% import "bootstrap/wtf.html" as wtf %}¬ {% block title %}Submit images to OpenOversight{% endblock %} {% block meta %} - + {% endblock %} {% block head %} - - + + {% endblock %} - {% block content %} -
    - - - -
    +
    + +

    What happens when I submit a photograph?

    -

    The next step after a photograph of an officer has been submitted is to match it to the correct badge number, name, and OpenOversight ID (a unique identifier in our system). Volunteers sort through submitted images by first confirming that officers are present in each photograph, and then by matching each photograph to the officer it depicts.

    +

    + The next step after a photograph of an officer has been submitted is to match it to the correct badge number, name, and OpenOversight ID (a unique identifier in our system). Volunteers sort through submitted images by first confirming that officers are present in each photograph, and then by matching each photograph to the officer it depicts. +

    {% if not current_user.get_id() %} -

    Please consider creating an account and logging in so that we can keep track of the images you've submitted and contact you with any questions.

    +

    + Please consider creating an account and logging in so that we can keep track of the images you've submitted and contact you with any questions. +

    {% else %}

    Your user ID will be attached to all photo submissions while you are signed in.

    {% endif %} -
    -

    Select the department that the police officer in your image belongs to:

    -
    -
    - {{ wtf.form_field(form.department) }} +
    +

    Select the department that the police officer in your image belongs to:

    +
    + + {{ wtf.form_field(form.department) }}
    -
    - -

    Drop images here to submit photos of officers:

    - -
    +
    +

    Drop images here to submit photos of officers:

    +

    Drag photographs from your computer directly into the box above or click the box to launch a finder window. If you are on mobile, you can click the box above to select pictures from your photo library or camera roll. An image is successfully uploaded when you see a check mark over it.

    - + -
    - -
    -

    High Security Submissions

    -

    We do not log unique identifying information of visitors to our website, but if you have privacy concerns in submitting photographs, we recommend using Tor Browser.

    -
    - +
    +
    +

    High Security Submissions

    +

    + We do not log unique identifying information of visitors to our website, but if you have privacy concerns in submitting photographs, we recommend using Tor Browser. +

    +
    {% endblock %} diff --git a/OpenOversight/app/templates/submit_officer_image.html b/OpenOversight/app/templates/submit_officer_image.html index d47e3eafe..634360354 100644 --- a/OpenOversight/app/templates/submit_officer_image.html +++ b/OpenOversight/app/templates/submit_officer_image.html @@ -1,40 +1,42 @@ {% extends "base.html" %} - {% block head %} - - + + {% endblock %} - {% block content %} -
    - - - -
    -

    Drop images here to submit photos of {{ officer.full_name() }} in {{ officer.department.name }}:

    +
    + +
    +

    Drop images here to submit photos of {{ officer.full_name() }} in {{ officer.department.name }}:

    +
    +
    +
    +

    + Drag photographs from your computer directly into the box above or click the box to launch a finder window. If you are on mobile, you can click the box above to select pictures from your photo library or camera roll. +

    + Done uploading images
    -
    -
    -

    Drag photographs from your computer directly into the box above or click the box to launch a finder window. If you are on mobile, you can click the box above to select pictures from your photo library or camera roll.

    - - Done uploading images - -
    - {% endblock %} - {% block js_footer %} {% endblock %} diff --git a/OpenOversight/app/templates/tag.html b/OpenOversight/app/templates/tag.html index b04300d95..e8b834a8d 100644 --- a/OpenOversight/app/templates/tag.html +++ b/OpenOversight/app/templates/tag.html @@ -1,135 +1,147 @@ {% extends "base.html" %} {% block content %} - -
    - - - -
    - - -
    -
    -
    - You can download the full officer photo by clicking the image below: +
    +
    - -
    - -
    - - -
    - -{% if current_user and current_user.is_authenticated %} -
    -
    -
    -
    -
    -

    Image Cutout

    - - - - - - - - - - - - - - - - - - - -
    Cropped Image ID - {{ face.image.id }}
    Original Image ID - {{ face.original_image.id }}
    Department{{ face.image.department.name }}
    Featured{{ face.featured }}
    - -

    Officer

    - - - - - - - - - - - -
    OpenOversight ID - {{ face.officer_id }}
    Tagged by user - {{ face.user.username }}
    - - {% if current_user.is_administrator - or (current_user.is_area_coordinator and current_user.ac_department_id == face.image.department_id) %} -

    Remove tag Admin only

    -

    -

    - - -
    -

    -

    Add additional tags Admin only

    -

    - - - - -

    -

    Set as featured tag Admin only

    -

    -

    - - -
    -

    - {% endif %} +
    + {% if current_user and current_user.is_authenticated %} +
    +
    +
    +
    +
    +

    Image Cutout

    + + + + + + + + + + + + + + + + + + + +
    + Cropped Image ID + + {{ face.image.id }} +
    + Original Image ID + + {{ face.original_image.id }} +
    + Department + {{ face.image.department.name }}
    + Featured + {{ face.featured }}
    +

    Officer

    + + + + + + + + + + + +
    + OpenOversight ID + + {{ face.officer_id }} +
    + Tagged by user + + {{ face.user.username }} +
    + {% if current_user.is_administrator + or (current_user.is_area_coordinator and current_user.ac_department_id == face.image.department_id) %} +

    + Remove tag Admin only +

    +

    +

    + + +
    +

    +

    + Add additional tags Admin only +

    +

    + + + + +

    +

    + Set as featured tag Admin only +

    +

    +

    + + +
    +

    + {% endif %} +
    -
    {% endif %} {% endblock %} diff --git a/OpenOversight/app/templates/tutorial.html b/OpenOversight/app/templates/tutorial.html index 40a5a50d5..47ad5715a 100644 --- a/OpenOversight/app/templates/tutorial.html +++ b/OpenOversight/app/templates/tutorial.html @@ -1,107 +1,116 @@ {% extends "base.html" %} {% block title %}Tutorial - OpenOversight{% endblock %} -{% block meta %}{% endblock %} +{% block meta %} + +{% endblock %} {% block content %} - -
    - -
    -
    -

    Volunteer Tutorial

    -
    -

    We want you to help us go through submitted images of law enforcement. - There are two tasks we need help with: classifying images into those that contain officers and - those that do not, and identifying faces in the images to associate them with an officer in our roster. -

    -
    - -
    -

    In the Volunteer page, you will see under each law enforcement agency links to the two volunteer tasks when logged in:

    - -
    -
    - +
    +
    +
    +

    Volunteer Tutorial

    +
    +

    + We want you to help us go through submitted images of law enforcement. + There are two tasks we need help with: classifying images into those that contain officers and + those that do not, and identifying faces in the images to associate them with an officer in our roster. +

    -
    -
    - -
    -

    Task 1: Classify Images

    -

    When an image is displayed, look to see if any faces - of law enforcement officers are visible. If you do, simply click Yes. If you don't, click No. - A message will display letting you know that your classification has been saved. - If you prefer to skip an image, just click Next Photo.

    - -
    -
    - +
    +

    + In the Volunteer page, you will see under each law enforcement agency links to the two volunteer tasks when logged in: +

    +
    +
    + +
    -
    - -
    -

    Task 2: Identify Officers

    -

    In this task, you will map faces in each image to an entry in our roster.

    - -
    -
    - +
    +

    Task 1: Classify Images

    +

    + When an image is displayed, look to see if any faces + of law enforcement officers are visible. If you do, simply click Yes. If you don't, click No. + A message will display letting you know that your classification has been saved. + If you prefer to skip an image, just click Next Photo. +

    +
    +
    + +
    - -

    An image that has been flagged by volunteers as containing law enforcement - officers will be displayed. Select an officer face in the - image, and a preview of the area to be selected will be shown on the left. - Open the search tool by clicking Launch roster search form.

    - +
    +

    Task 2: Identify Officers

    +

    In this task, you will map faces in each image to an entry in our roster.

    - +
    - -

    Search for the officer using what you can see from their badge number and - name (if available), or by seeing if you can match the officer's face to an existing face in the dataset. - Once you find the officer, look at their OpenOversight ID number in green:

    - +

    + An image that has been flagged by volunteers as containing law enforcement + officers will be displayed. Select an officer face in the + image, and a preview of the area to be selected will be shown on the left. + Open the search tool by clicking Launch roster search form. +

    - +
    - -

    Type this ID into the form next to their selected face, and and click Add identified face:

    - +

    + Search for the officer using what you can see from their badge number and + name (if available), or by seeing if you can match the officer's face to an existing face in the dataset. + Once you find the officer, look at their OpenOversight ID number in green: +

    - +
    - -

    Follow this process for each officer in the image. Once you've identified every - officer from the image you are able to, click All officers have been identified:

    - +

    + Type this ID into the form next to their selected face, and and click Add identified face: +

    - + +
    +
    +

    + Follow this process for each officer in the image. Once you've identified every + officer from the image you are able to, click All officers have been identified: +

    +
    +
    +
    -
    - - -

    Checking Your Progress

    -

    As you contribute to OpenOversight, you can look at your classifications and tags - by going to your profile.

    - -

    You can also see how you are doing compared to other volunteers by - checking out the leaderboard.

    - -

    Ready to get started?

    - - -
    -
    - + +

    Checking Your Progress

    +

    + As you contribute to OpenOversight, you can look at your classifications and tags + by going to your profile. +

    +

    + You can also see how you are doing compared to other volunteers by + checking out the leaderboard. +

    +

    Ready to get started?

    + + + +
    +
    {% endblock %} diff --git a/requirements.txt b/requirements.txt index b7e9e7f92..71a10aa02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,18 +6,18 @@ Babel~=2.12.1 bleach==6.0.0 bleach-allowlist==1.0.3 blinker~=1.6.2 -boto3==1.27.0 -botocore==1.30.0 +boto3==1.28.1 +botocore==1.31.1 certifi~=2023.5.7 cffi~=1.15.1 -click~=8.1.3 +click==8.1.4 cryptography~=41.0.1 Deprecated~=1.2.14 dnspython~=2.3.0 docutils~=0.20.1 dominate~=2.8.0 email-validator==2.0.0.post2 -Faker~=18.11.2 +Faker==18.13.0 Flask==2.3.2 Flask-Bootstrap==3.3.7.1 Flask-Limiter==3.3.1 @@ -43,7 +43,7 @@ mypy~=1.4.1 packaging~=23.1 Pillow==10.0.0 pip==23.1.2 -platformdirs~=3.8.0 +platformdirs==3.8.1 psycopg2==2.9.6 pycparser~=2.21 Pygments~=2.15.1 @@ -60,7 +60,7 @@ sphinx~=7.0.1 SQLAlchemy==1.4.47 # Updating this breaks the build for python 3.9 tornado~=6.3.2 trio==0.22.1 -urllib3~=1.26.16 +urllib3~=1.26.16 # This version is required for other packages to run us==3.1.1 visitor~=0.1.3 webencodings~=0.5.1 @@ -69,4 +69,4 @@ wrapt~=1.15.0 wtforms==3.0.1 WTForms-SQLAlchemy==0.3.0 xvfbwrapper~=0.2.9 -zipp~=3.15.0 +zipp==3.16.0 From eb84c13114155bcdaf8b6c89416f45b67e44f3c6 Mon Sep 17 00:00:00 2001 From: Michael Plunkett <5885605+michplunkett@users.noreply.github.com> Date: Thu, 13 Jul 2023 14:00:29 -0500 Subject: [PATCH 104/137] Add HTML linting to `pre-commit` (#968) ## Fixes issue https://github.com/lucyparsons/OpenOversight/issues/239 ## Description of Changes I added `djlint` linting to the `pre-commit` script and made the corresponding changes. ## Tests and linting - [x] This branch is up-to-date with the `develop` branch. - [x] `pytest` passes on my local development environment. - [x] `pre-commit` passes on my local development environment. --- .pre-commit-config.yaml | 10 +- OpenOversight/app/main/views.py | 4 +- OpenOversight/app/templates/about.html | 4 +- OpenOversight/app/templates/base.html | 16 +- OpenOversight/app/templates/browse.html | 10 +- OpenOversight/app/templates/cop_face.html | 293 +++++++++--------- .../app/templates/description_new.html | 2 +- .../app/templates/edit_assignment.html | 6 +- OpenOversight/app/templates/edit_officer.html | 6 +- OpenOversight/app/templates/form.html | 3 +- OpenOversight/app/templates/image.html | 9 +- .../app/templates/incident_detail.html | 20 +- .../app/templates/incident_list.html | 18 +- OpenOversight/app/templates/incident_new.html | 2 +- OpenOversight/app/templates/index.html | 8 +- .../app/templates/input_find_officer.html | 8 +- OpenOversight/app/templates/label_data.html | 11 +- OpenOversight/app/templates/leaderboard.html | 2 +- OpenOversight/app/templates/link_new.html | 2 +- OpenOversight/app/templates/list.html | 4 +- OpenOversight/app/templates/note_new.html | 2 +- OpenOversight/app/templates/officer.html | 40 +-- .../app/templates/partials/incident_form.html | 2 +- .../partials/links_and_videos_row.html | 2 +- .../templates/partials/officer_incidents.html | 4 +- .../app/templates/partials/paginate.html | 5 +- .../app/templates/partials/paginate_nav.html | 8 +- OpenOversight/app/templates/privacy.html | 8 +- OpenOversight/app/templates/profile.html | 2 +- OpenOversight/app/templates/sort.html | 8 +- OpenOversight/app/templates/submit_image.html | 14 +- .../app/templates/submit_officer_image.html | 10 +- OpenOversight/app/templates/tag.html | 37 ++- OpenOversight/app/templates/tutorial.html | 8 +- 34 files changed, 305 insertions(+), 283 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d7aad0ea..065ea8b67 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -90,10 +90,18 @@ repos: rev: v1.31.1 hooks: - id: djlint-reformat + pass_filenames: false args: - OpenOversight/app/templates - - --format-js - --format-css - --profile=jinja - --indent=2 - --quiet + - id: djlint + require_serial: true + pass_filenames: false + args: + - OpenOversight/app/templates + - --profile=jinja + - --use-gitignore + - --ignore=H006,T028,H031,H021,H013,H011 diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 898e44713..bfc3ad75a 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -144,7 +144,7 @@ def browse(): @sitemap_include @main.route("/find", methods=[HTTPMethod.GET, HTTPMethod.POST]) def get_officer(): - jsloads = ["js/find_officer.js"] + js_loads = ["js/find_officer.js"] form = FindOfficerForm() departments_dict = [dept_choice.toCustomDict() for dept_choice in dept_choices()] @@ -180,7 +180,7 @@ def get_officer(): "input_find_officer.html", form=form, depts_dict=departments_dict, - jsloads=jsloads, + jsloads=js_loads, ) diff --git a/OpenOversight/app/templates/about.html b/OpenOversight/app/templates/about.html index 3ac8a0724..5318a92a0 100644 --- a/OpenOversight/app/templates/about.html +++ b/OpenOversight/app/templates/about.html @@ -20,7 +20,9 @@

    diff --git a/OpenOversight/app/templates/base.html b/OpenOversight/app/templates/base.html index ae9e206ff..00eef61e0 100644 --- a/OpenOversight/app/templates/base.html +++ b/OpenOversight/app/templates/base.html @@ -54,28 +54,28 @@ - OpenOversight + OpenOversight
    diff --git a/OpenOversight/app/templates/browse.html b/OpenOversight/app/templates/browse.html index 17d9c3377..558640e1a 100644 --- a/OpenOversight/app/templates/browse.html +++ b/OpenOversight/app/templates/browse.html @@ -1,7 +1,11 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}Browse OpenOversight{% endblock %} -{% block meta %}{% endblock %} +{% block title %} + Browse OpenOversight +{% endblock title %} +{% block meta %} + +{% endblock meta %} {% block content %}
    @@ -34,4 +38,4 @@

    {% endfor %}

    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/cop_face.html b/OpenOversight/app/templates/cop_face.html index ef30c059e..9aa226f23 100644 --- a/OpenOversight/app/templates/cop_face.html +++ b/OpenOversight/app/templates/cop_face.html @@ -45,156 +45,159 @@

    {% if department %}
    - {% else %} - - {% endif %} - {{ form.hidden_tag() }} -
    - -
    - {% for error in form.dataX.errors %} -

    - [{{ error }}] -

    - {% endfor %} -
    - -
    -
    - {% for error in form.dataY.errors %} -

    - [{{ error }}] -

    - {% endfor %} -
    - -
    - {% for error in form.dataWidth.errors %} -

    - [{{ error }}] -

    - {% endfor %} -
    - -
    - {% for error in form.dataHeight.errors %} -

    - [{{ error }}] -

    - {% endfor %} -
    - - -
    - {% for error in form.officer_id.errors %} -

    - [{{ error }}] -

    - {% endfor %} - - {% for error in form.image_id.errors %} -

    - Image: [{{ error }}] -

    - {% endfor %} -

    -

    - -
    -

    -
    -
    - Explanation: click this button to associate the selected image with the entered OpenOversight ID. -
    -
    - Explanation: after matching the officer's name, badge number, or face to the roster, enter the officer's OpenOversight ID here. -
    -
    -

    + + {% else %} +
    +
    + {% endif %} + {{ form.hidden_tag() }} +
    + +
    + {% for error in form.dataX.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + +
    +
    + {% for error in form.dataY.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + +
    + {% for error in form.dataWidth.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + +
    + {% for error in form.dataHeight.errors %} +

    + [{{ error }}] +

    + {% endfor %} +
    + +
    -
    - {% if department %} - - {% else %} - - {% endif %} - Next Photo + {% for error in form.officer_id.errors %} +

    + [{{ error }}] +

    + {% endfor %} + + {% for error in form.image_id.errors %} +

    + Image: [{{ error }}] +

    + {% endfor %} +

    +

    +
    -
    - - - All officers have been identified! - +

    +
    +
    + Explanation: click this button to associate the selected image with the entered OpenOversight ID.
    -
    -
    -
    -
    -
    - Explanation: click this button ONLY when all officers in it have been identified. This will remove it from the identification queue for ALL users. -
    -
    - Explanation: click this button if you would like to move on to the next image, without saving any info about this image. -
    -
    - Explanation: click this button to open the police roster. Use the roster to find the officer's OpenOversight ID. -
    -
    -
    +
    + Explanation: after matching the officer's name, badge number, or face to the roster, enter the officer's OpenOversight ID here.
    - {% elif current_user.is_disabled == True %} -

    Your account has been disabled due to too many incorrect classifications/tags!

    -

    - Mail us to get it enabled again -

    +
    +
    +
    + {% if department %} + Next Photo {% else %} -

    All images have been tagged!

    -

    - {{ department.name }} -

    -

    - Submit officer pictures to us -

    + Next Photo {% endif %} - {% endif %} +
    + +
    +
    +
    +
    +
    + Explanation: click this button ONLY when all officers in it have been identified. This will remove it from the identification queue for ALL users. +
    +
    + Explanation: click this button if you would like to move on to the next image, without saving any info about this image. +
    +
    + Explanation: click this button to open the police roster. Use the roster to find the officer's OpenOversight ID. +
    +
    +
    +
    - {% endblock %} - {% block footer_class %}bottom-10{% endblock %} + {% elif current_user.is_disabled == True %} +

    Your account has been disabled due to too many incorrect classifications/tags!

    +

    + Mail us to get it enabled again +

    + {% else %} +

    All images have been tagged!

    +

    + {{ department.name }} +

    +

    + Submit officer pictures to us +

    + {% endif %} + {% endif %} +
    +{% endblock content %} +{% block footer_class %} + bottom-10 +{% endblock footer_class %} diff --git a/OpenOversight/app/templates/description_new.html b/OpenOversight/app/templates/description_new.html index b7abae46b..73efab899 100644 --- a/OpenOversight/app/templates/description_new.html +++ b/OpenOversight/app/templates/description_new.html @@ -1,4 +1,4 @@ -{% extends 'form.html' %} +{% extends "form.html" %} {% block page_title %} New Description {% endblock page_title %} diff --git a/OpenOversight/app/templates/edit_assignment.html b/OpenOversight/app/templates/edit_assignment.html index 30a9b0f0c..604054b82 100644 --- a/OpenOversight/app/templates/edit_assignment.html +++ b/OpenOversight/app/templates/edit_assignment.html @@ -1,6 +1,8 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight Admin - Edit Officer Assignment{% endblock %} +{% block title %} + OpenOversight Admin - Edit Officer Assignment +{% endblock title %} {% block content %}
    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/edit_officer.html b/OpenOversight/app/templates/edit_officer.html index 20c378564..52200e267 100644 --- a/OpenOversight/app/templates/edit_officer.html +++ b/OpenOversight/app/templates/edit_officer.html @@ -1,6 +1,8 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight Admin - Edit Officer{% endblock %} +{% block title %} + OpenOversight Admin - Edit Officer +{% endblock title %} {% block content %}
    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/form.html b/OpenOversight/app/templates/form.html index 7aca8880c..5ecc44936 100644 --- a/OpenOversight/app/templates/form.html +++ b/OpenOversight/app/templates/form.html @@ -8,7 +8,8 @@

    {% endblock page_title %}

    - {% block page_header %}{% endblock %} + {% block page_header %} + {% endblock page_header %}
    {% block form %} {{ wtf.quick_form(form, action=post_url, method='post', button_map={'submit':'primary'}) }} diff --git a/OpenOversight/app/templates/image.html b/OpenOversight/app/templates/image.html index 87db1d660..d19e5979f 100644 --- a/OpenOversight/app/templates/image.html +++ b/OpenOversight/app/templates/image.html @@ -109,12 +109,11 @@

    - -

    - {% endif %} +

    + {% endif %} +

    -
    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/incident_detail.html b/OpenOversight/app/templates/incident_detail.html index 41ec0b985..4c855611b 100644 --- a/OpenOversight/app/templates/incident_detail.html +++ b/OpenOversight/app/templates/incident_detail.html @@ -1,11 +1,11 @@ -{% extends 'base.html' %} +{% extends "base.html" %} {% set incident = obj %} {% block title %} {{ incident.department.name }} incident {% if incident.report_number %} {{ incident.report_number }}{% endif %} - OpenOversight - {% endblock %} + {% endblock title %} {% block meta %} @@ -31,9 +31,9 @@ }] } - {% endblock %} + {% endblock meta %} {% block content %} -
    +
    All Incidents {% if incident.department %}

    @@ -47,10 +47,10 @@

    {% if incident.report_number %}{{ incident.report_number }}{% endif %}

    - +
    {% with detail=True %} - {% include 'partials/incident_fields.html' %} + {% include "partials/incident_fields.html" %} {% endwith %}
    @@ -60,19 +60,19 @@

    Incident Description

    {{ incident.description | markdown }}
    - {% include 'partials/links_and_videos_row.html' %} + {% include "partials/links_and_videos_row.html" %} {% if current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == incident.department_id) %} {% endif %}
    - {% endblock %} + {% endblock content %} diff --git a/OpenOversight/app/templates/incident_list.html b/OpenOversight/app/templates/incident_list.html index 2b6a5b7a5..eb29661fa 100644 --- a/OpenOversight/app/templates/incident_list.html +++ b/OpenOversight/app/templates/incident_list.html @@ -1,6 +1,8 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}View incidents - OpenOversight{% endblock %} +{% block title %} + View incidents - OpenOversight +{% endblock title %} {% block meta %} {% if incidents.items|length > 0 %} {% endif %} -{% endblock %} +{% endblock meta %} {% block content %}

    Incidents

    @@ -29,12 +31,12 @@
    - {% with paginate=incidents, location='top' %} + {% with paginate=incidents, location="top" %} {% include "partials/paginate_nav.html" %} {% endwith %}
      - {% if incidents.items %} - + {% if "incidents.items" %} +
      {% for incident in incidents.items %} {% if not loop.first %} @@ -56,7 +58,7 @@

      - {% include 'partials/incident_fields.html' %} + {% include "partials/incident_fields.html" %} {% endfor %}
      @@ -72,7 +74,7 @@

      Add New Incident {% endif %} - {% with paginate=incidents, location='bottom' %} + {% with paginate=incidents, location="bottom" %} {% include "partials/paginate_nav.html" %} {% endwith %}

    @@ -81,4 +83,4 @@

    {% endblock content %} {% block js_footer %} -{% endblock %} +{% endblock js_footer %} diff --git a/OpenOversight/app/templates/incident_new.html b/OpenOversight/app/templates/incident_new.html index ed9a2048c..655a52311 100644 --- a/OpenOversight/app/templates/incident_new.html +++ b/OpenOversight/app/templates/incident_new.html @@ -1,4 +1,4 @@ -{% extends 'form.html' %} +{% extends "form.html" %} {% block page_title %} New Incident {% endblock page_title %} diff --git a/OpenOversight/app/templates/index.html b/OpenOversight/app/templates/index.html index f71c89ab0..64bab3c90 100644 --- a/OpenOversight/app/templates/index.html +++ b/OpenOversight/app/templates/index.html @@ -16,9 +16,11 @@

    Search our public database for available information on officers in your city or to identify an officer with whom you have had a negative interaction.

    - Browse officers + Browse officers
    - Identify officers + Identify officers

    @@ -64,4 +66,4 @@

    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/input_find_officer.html b/OpenOversight/app/templates/input_find_officer.html index 899b4b5ba..a1a0821bb 100644 --- a/OpenOversight/app/templates/input_find_officer.html +++ b/OpenOversight/app/templates/input_find_officer.html @@ -1,9 +1,11 @@ {% extends "base.html" %} -{% block title %}Find an officer - OpenOversight{% endblock %} +{% block title %} + Find an officer - OpenOversight +{% endblock title %} {% block meta %} -{% endblock %} +{% endblock meta %} {% block content %}
    @@ -259,4 +261,4 @@

    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/label_data.html b/OpenOversight/app/templates/label_data.html index f79aef695..53a390def 100644 --- a/OpenOversight/app/templates/label_data.html +++ b/OpenOversight/app/templates/label_data.html @@ -1,10 +1,12 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}Volunteer with OpenOversight{% endblock %} +{% block title %} + Volunteer with OpenOversight +{% endblock title %} {% block meta %} -{% endblock %} +{% endblock meta %} {% block content %} {% if current_user and current_user.is_authenticated %}
    @@ -23,7 +25,8 @@

    New to working on images for OpenOversight? Or just need a refresher?

    - + Leaderboard @@ -145,4 +148,4 @@

    {% endif %} -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/leaderboard.html b/OpenOversight/app/templates/leaderboard.html index 9a58d2099..889aedd44 100644 --- a/OpenOversight/app/templates/leaderboard.html +++ b/OpenOversight/app/templates/leaderboard.html @@ -33,4 +33,4 @@

    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/link_new.html b/OpenOversight/app/templates/link_new.html index 66ce7dc47..f0c881016 100644 --- a/OpenOversight/app/templates/link_new.html +++ b/OpenOversight/app/templates/link_new.html @@ -1,4 +1,4 @@ -{% extends 'form.html' %} +{% extends "form.html" %} {% block page_title %} New Link {% endblock page_title %} diff --git a/OpenOversight/app/templates/list.html b/OpenOversight/app/templates/list.html index 2621baed9..aed88d2a3 100644 --- a/OpenOversight/app/templates/list.html +++ b/OpenOversight/app/templates/list.html @@ -1,12 +1,12 @@ {% extends "base.html" %} {% block content %}
    - {% with paginate=objects, next_url=url_for(url, page=objects.next_num), prev_url=url_for(url, page=objects.prev_num), location='top' %} + {% with paginate=objects, next_url=url_for(url, page=objects.next_num), prev_url=url_for(url, page=objects.prev_num), location="top" %} {% include "partials/paginate_nav.html" %} {% endwith %} {% block list %} {% endblock list %} - {% with paginate=objects, next_url=url_for(url, page=objects.next_num), prev_url=url_for(url, page=objects.prev_num), location='bottom' %} + {% with paginate=objects, next_url=url_for(url, page=objects.next_num), prev_url=url_for(url, page=objects.prev_num), location="bottom" %} {% include "partials/paginate_nav.html" %} {% endwith %}
    diff --git a/OpenOversight/app/templates/note_new.html b/OpenOversight/app/templates/note_new.html index 1e88a63bf..881c103f9 100644 --- a/OpenOversight/app/templates/note_new.html +++ b/OpenOversight/app/templates/note_new.html @@ -1,4 +1,4 @@ -{% extends 'form.html' %} +{% extends "form.html" %} {% block page_title %} New Note {% endblock page_title %} diff --git a/OpenOversight/app/templates/officer.html b/OpenOversight/app/templates/officer.html index 3853df8b6..65c8566fb 100644 --- a/OpenOversight/app/templates/officer.html +++ b/OpenOversight/app/templates/officer.html @@ -1,5 +1,7 @@ {% extends "base.html" %} -{% block title %}{{ officer.full_name() }} - OpenOversight{% endblock %} +{% block title %} + {{ officer.full_name() }} - OpenOversight +{% endblock title %} {% block meta %} {% set job_title = officer.job_title() if officer.job_title() and officer.job_title() != 'Not Sure' else 'Employee' %} {% set description = 'See detailed information about ' ~ officer.full_name() ~ ', ' ~ job_title ~ ' of the ' ~ officer.department.name ~ '.' %} @@ -34,41 +36,24 @@ "@context": "https://schema.org/", "@type": "Person", "name": "{{ officer.full_name() }}", - { - % - if officer.birth_year % - } + {% if officer.birth_year %} "birthDate": "{{ officer.birth_year }}", - { - % - endif % - } + {% endif %} "gender": "{{ officer.gender_label() }}", "jobTitle": "{{ job_title }}", "worksFor": { "@type": "Organization", "name": "{{ officer.department.name | title }}" }, - { - % - if officer.unique_internal_identifier % - } + {% if officer.unique_internal_identifier %} "identifier": "{{ officer.unique_internal_identifier }}", - { - % - endif % - } { - % - if officer.image_url % - } + {%endif %} + {% if officer.image_url %} "image": { "@type": "URL", "url": "{{ officer.image_url }}" }, - { - % - endif % - } + {% endif %} "url": { "@type": "URL", "url": "{{ url_for(request.endpoint, officer_id=officer.id, _external=True) }}" @@ -98,7 +83,7 @@ }] } -{% endblock %} +{% endblock meta %} {% block content %} {% set is_admin_or_coordinator = current_user.is_administrator or (current_user.is_area_coordinator and current_user.ac_department_id == officer.department_id) %}
    @@ -136,7 +121,7 @@

    {% endif %}

    {# end col #} -
    +
    {% if officer.salaries or is_admin_or_coordinator %} {% include "partials/officer_salary.html" %} {% endif %} @@ -151,5 +136,4 @@

    {# end row #}
    - {# end container #} -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/partials/incident_form.html b/OpenOversight/app/templates/partials/incident_form.html index 07643cae3..bf9300c9b 100644 --- a/OpenOversight/app/templates/partials/incident_form.html +++ b/OpenOversight/app/templates/partials/incident_form.html @@ -1,7 +1,7 @@ {% import "bootstrap/wtf.html" as wtf %}
    {{ form.hidden_tag() }} -
    {{ wtf.form_errors(form, hiddens="only") }}
    +
    {{ wtf.form_errors(form, hiddens="only") }}
    {{ wtf.form_field(form.date_field, autofocus="autofocus") }} {{ wtf.form_field(form.time_field) }} {{ wtf.form_field(form.report_number) }} diff --git a/OpenOversight/app/templates/partials/links_and_videos_row.html b/OpenOversight/app/templates/partials/links_and_videos_row.html index 404484fb5..2ac622ca9 100644 --- a/OpenOversight/app/templates/partials/links_and_videos_row.html +++ b/OpenOversight/app/templates/partials/links_and_videos_row.html @@ -35,7 +35,7 @@

    Links

    New Link/Video {% endif %} - {% for type, list in obj.links|groupby("link_type") %} + {% for type, list in obj.links | groupby("link_type") %} {% if type == "video" %}

    Videos

    - diff --git a/OpenOversight/app/templates/partials/paginate_nav.html b/OpenOversight/app/templates/partials/paginate_nav.html index 377334c3d..1a6615395 100644 --- a/OpenOversight/app/templates/partials/paginate_nav.html +++ b/OpenOversight/app/templates/partials/paginate_nav.html @@ -3,23 +3,23 @@ {% if paginate.has_prev %} {% endif %} {% if paginate.has_next %} - Showing {{(paginate.page-1)*paginate.per_page + 1 }}-{{(paginate.page)*paginate.per_page}} of {{ paginate.total }} + Showing {{ (paginate.page-1) * paginate.per_page + 1 }}-{{ (paginate.page) * paginate.per_page }} of {{ paginate.total }} {% elif paginate.total == 0 %} {% if location == 'top' %}Showing 0 of 0{% endif %} {% else %} - Showing {{(paginate.page-1)*paginate.per_page + 1 }}-{{ paginate.total }} of {{ paginate.total }} + Showing {{ (paginate.page-1) * paginate.per_page + 1 }}-{{ paginate.total }} of {{ paginate.total }} {% endif %} diff --git a/OpenOversight/app/templates/privacy.html b/OpenOversight/app/templates/privacy.html index 411b1ab85..73199763b 100644 --- a/OpenOversight/app/templates/privacy.html +++ b/OpenOversight/app/templates/privacy.html @@ -1,9 +1,11 @@ {% extends "base.html" %} -{% block title %}Privacy Policy - OpenOversight{% endblock %} +{% block title %} + Privacy Policy - OpenOversight +{% endblock title %} {% block meta %} -{% endblock %} +{% endblock meta %} {% block content %}
    @@ -64,4 +66,4 @@

    Future Changes

    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/profile.html b/OpenOversight/app/templates/profile.html index f9506f594..23782b993 100644 --- a/OpenOversight/app/templates/profile.html +++ b/OpenOversight/app/templates/profile.html @@ -121,4 +121,4 @@

    Officer Identifications

    {% endif %}
    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/sort.html b/OpenOversight/app/templates/sort.html index e4f871352..4bf6dcac3 100644 --- a/OpenOversight/app/templates/sort.html +++ b/OpenOversight/app/templates/sort.html @@ -1,5 +1,7 @@ {% extends "base.html" %} -{% block head %}{% endblock %} +{% block head %} + +{% endblock head %} {% block js_footer %} -{% endblock %} +{% endblock js_footer %} {% block content %}
    {% if current_user and current_user.is_authenticated %} @@ -92,4 +94,4 @@

    All images have been classfied!

    {% endif %} {% endif %}
    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/submit_image.html b/OpenOversight/app/templates/submit_image.html index f6eed910e..ef3d040b5 100644 --- a/OpenOversight/app/templates/submit_image.html +++ b/OpenOversight/app/templates/submit_image.html @@ -1,16 +1,18 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %}¬ -{% block title %}Submit images to OpenOversight{% endblock %} +{% block title %} + Submit images to OpenOversight +{% endblock title %} {% block meta %} -{% endblock %} +{% endblock meta %} {% block head %} -{% endblock %} +{% endblock head %} {% block content %}

    Drop images here to submit photos of officers:

    -
    +

    Drag photographs from your computer directly into the box above or click the box to launch a finder window. @@ -84,4 +88,4 @@

    High Security Submissions

    We do not log unique identifying information of visitors to our website, but if you have privacy concerns in submitting photographs, we recommend using Tor Browser.

    -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/submit_officer_image.html b/OpenOversight/app/templates/submit_officer_image.html index 634360354..67785418c 100644 --- a/OpenOversight/app/templates/submit_officer_image.html +++ b/OpenOversight/app/templates/submit_officer_image.html @@ -4,7 +4,7 @@ rel="stylesheet"> -{% endblock %} +{% endblock head %} {% block content %}
    -{% endblock %} +{% endblock content %} {% block js_footer %} -{% endblock %} +{% endblock js_footer %} diff --git a/OpenOversight/app/templates/tag.html b/OpenOversight/app/templates/tag.html index e8b834a8d..59304812b 100644 --- a/OpenOversight/app/templates/tag.html +++ b/OpenOversight/app/templates/tag.html @@ -122,26 +122,25 @@

    - -

    -

    - Set as featured tag Admin only -

    -

    -

    - - -
    -

    - {% endif %} +

    +

    + Set as featured tag Admin only +

    +

    +

    + + +
    +

    + {% endif %} +
    -
    -{% endif %} -{% endblock %} + {% endif %} +{% endblock content %} diff --git a/OpenOversight/app/templates/tutorial.html b/OpenOversight/app/templates/tutorial.html index 47ad5715a..673ef0920 100644 --- a/OpenOversight/app/templates/tutorial.html +++ b/OpenOversight/app/templates/tutorial.html @@ -1,9 +1,11 @@ {% extends "base.html" %} -{% block title %}Tutorial - OpenOversight{% endblock %} +{% block title %} + Tutorial - OpenOversight +{% endblock title %} {% block meta %} -{% endblock %} +{% endblock meta %} {% block content %}
    @@ -113,4 +115,4 @@

    Ready to get started?

    -{% endblock %} +{% endblock content %} From b462294fdc8dc249e7ec20570641467eed79e266 Mon Sep 17 00:00:00 2001 From: Michael Plunkett <5885605+michplunkett@users.noreply.github.com> Date: Mon, 17 Jul 2023 13:41:13 -0500 Subject: [PATCH 105/137] Center and size officer profile pictures (#974) ## Fixes issue https://github.com/lucyparsons/OpenOversight/issues/931 ## Description of Changes I centered and addressed sizing issues with the officer profile pictures in the profile page, officer list page, and officer image page. ## Screenshots (if appropriate) Located in the comments. ## Tests and linting - [x] This branch is up-to-date with the `develop` branch. - [x] `pytest` passes on my local development environment. - [x] `pre-commit` passes on my local development environment. --- .../app/static/css/openoversight.css | 32 +++++++++++++++++++ .../app/templates/partials/officer_faces.html | 4 ++- OpenOversight/app/templates/tag.html | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/OpenOversight/app/static/css/openoversight.css b/OpenOversight/app/static/css/openoversight.css index 91fd01e0d..637f32e29 100644 --- a/OpenOversight/app/static/css/openoversight.css +++ b/OpenOversight/app/static/css/openoversight.css @@ -585,10 +585,42 @@ tr:hover .row-actions { } .officer-face { + border: none; height: 300px; margin: auto; } +.officer-face.officer-profile { + display: block; +} + +@media (min-width: 992px) { + .officer-face.officer-profile { + height: 510px; + } +} + +@media (min-width: 768px) and (max-width: 991px) { + .officer-face.officer-profile { + height: 590px; + } +} + +@media (max-width: 767px) { + .officer-face.officer-profile { + height: 460px; + padding-bottom: 10px; + } +} + +#face-img { + border: none; + display: block; + margin: auto; + max-height: 500px; + padding-bottom: 10px; +} + .face-wrap { margin: auto; position: relative; diff --git a/OpenOversight/app/templates/partials/officer_faces.html b/OpenOversight/app/templates/partials/officer_faces.html index 130d4d7c7..6e2010609 100644 --- a/OpenOversight/app/templates/partials/officer_faces.html +++ b/OpenOversight/app/templates/partials/officer_faces.html @@ -1,6 +1,8 @@ {% for path in paths %} {# Don't try to link if only image is the placeholder #} {% if faces %}{% endif %} - Submission + Submission {% if faces %}{% endif %} {% endfor %} diff --git a/OpenOversight/app/templates/tag.html b/OpenOversight/app/templates/tag.html index 59304812b..75ed6cf81 100644 --- a/OpenOversight/app/templates/tag.html +++ b/OpenOversight/app/templates/tag.html @@ -16,7 +16,7 @@

    Tag {{ face.id }} Detail

    -
    You can download the full officer photo by clicking the image below:
    +
    You can download the full officer photo by clicking the image below.

    From 3606e67c1d812b4c2045f40b3d38eceb31ca188c Mon Sep 17 00:00:00 2001 From: Michael Plunkett <5885605+michplunkett@users.noreply.github.com> Date: Mon, 17 Jul 2023 15:28:11 -0500 Subject: [PATCH 106/137] Add password change confirmation email (#971) ## Fixes issue https://github.com/lucyparsons/OpenOversight/issues/947 ## Description of Changes Added an email action that lets the user know that their password has changed and added validation that emails were being sent in the tests. ## Notes for Deployment - The `OO_ADMIN_EMAIL` environment variable needs to be added to the production `.env` file. ## Screenshots (if appropriate) Screenshot 2023-07-13 at 1 44 06 PM ## Tests and linting - [x] This branch is up-to-date with the `develop` branch. - [x] `pytest` passes on my local development environment. - [x] `pre-commit` passes on my local development environment. --- CONTRIB.md | 8 ++++ OpenOversight/app/auth/views.py | 4 ++ OpenOversight/app/models/config.py | 3 ++ OpenOversight/app/models/emails.py | 13 ++++++ .../templates/auth/email/change_email.html | 2 +- .../templates/auth/email/change_password.html | 11 +++++ .../app/templates/auth/email/confirm.html | 2 +- .../auth/email/new_confirmation.html | 2 +- .../auth/email/new_registration.html | 2 +- .../templates/auth/email/reset_password.html | 2 +- OpenOversight/tests/routes/test_auth.py | 43 ++++++++++++++++--- docker-compose.yml | 1 + 12 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 OpenOversight/app/templates/auth/email/change_password.html diff --git a/CONTRIB.md b/CONTRIB.md index dafd23881..742a65eb3 100644 --- a/CONTRIB.md +++ b/CONTRIB.md @@ -66,6 +66,14 @@ Example `.env` variable: OO_SERVICE_EMAIL="sample_email@domain.com" ``` +In addition to needing a service account email, you also need an admin email address so that users have someone to reach out to if an action is taken on their account that needs to be reversed or addressed. +For production, save the email address associated with your admin account to a variable named `OO_HELP_EMAIL` in a `.env` file in the base directory of this repository. For development and testing, update the `OO_HELP_EMAIL` variable in the `docker-compose.yml` file. + +Example `.env` variable: +```bash +OO_HELP_EMAIL="sample_admin_email@domain.com" +``` + ## Testing S3 Functionality We use an S3 bucket for image uploads. If you are working on functionality involving image uploads, then you should follow the "S3 Image Hosting" section in [DEPLOY.md](/DEPLOY.md) to make a test S3 bucket diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 0fd020769..fbd1c02e4 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -28,6 +28,7 @@ from OpenOversight.app.models.emails import ( AdministratorApprovalEmail, ChangeEmailAddressEmail, + ChangePasswordEmail, ConfirmAccountEmail, ConfirmedUserEmail, ResetPasswordEmail, @@ -175,6 +176,9 @@ def change_password(): db.session.add(current_user) db.session.commit() flash("Your password has been updated.") + EmailClient.send_email( + ChangePasswordEmail(current_user.email, user=current_user) + ) return redirect(url_for("main.index")) else: flash("Invalid password.") diff --git a/OpenOversight/app/models/config.py b/OpenOversight/app/models/config.py index c44c3f929..f1de76e05 100644 --- a/OpenOversight/app/models/config.py +++ b/OpenOversight/app/models/config.py @@ -37,6 +37,9 @@ def __init__(self): "OO_MAIL_SUBJECT_PREFIX", "[OpenOversight]" ) self.OO_SERVICE_EMAIL = os.environ.get("OO_SERVICE_EMAIL") + # TODO: Remove the default once we are able to update the production .env file + # TODO: Once that is done, we can re-alpha sort these variables. + self.OO_HELP_EMAIL = os.environ.get("OO_HELP_EMAIL", self.OO_SERVICE_EMAIL) # AWS Settings self.AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") diff --git a/OpenOversight/app/models/emails.py b/OpenOversight/app/models/emails.py index 6ca205c6a..6ddc711e8 100644 --- a/OpenOversight/app/models/emails.py +++ b/OpenOversight/app/models/emails.py @@ -39,6 +39,19 @@ def __init__(self, receiver: str, user, token: str): super().__init__(body, subject, receiver) +class ChangePasswordEmail(Email): + def __init__(self, receiver: str, user): + subject = ( + f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Your Password Has Changed" + ) + body = render_template( + "auth/email/change_password.html", + user=user, + help_email=current_app.config["OO_HELP_EMAIL"], + ) + super().__init__(body, subject, receiver) + + class ConfirmAccountEmail(Email): def __init__(self, receiver: str, user, token: str): subject = f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Confirm Your Account" diff --git a/OpenOversight/app/templates/auth/email/change_email.html b/OpenOversight/app/templates/auth/email/change_email.html index 300bd295c..681083b70 100644 --- a/OpenOversight/app/templates/auth/email/change_email.html +++ b/OpenOversight/app/templates/auth/email/change_email.html @@ -7,5 +7,5 @@

    Sincerely,

    The OpenOversight Team

    - Note: replies to this email address are not monitored. + Please note that we may not monitor replies to this email address.

    diff --git a/OpenOversight/app/templates/auth/email/change_password.html b/OpenOversight/app/templates/auth/email/change_password.html new file mode 100644 index 000000000..8e4987ff0 --- /dev/null +++ b/OpenOversight/app/templates/auth/email/change_password.html @@ -0,0 +1,11 @@ +

    Dear {{ user.username }},

    +

    Your password has just been changed.

    +

    If you initiated this change to your password, you can ignore this email.

    +

    + If you did not reset your password, please contact the OpenOversight help account; they will help you address this issue. +

    +

    Sincerely,

    +

    The OpenOversight Team

    +

    + Please note that we may not monitor replies to this email address. +

    diff --git a/OpenOversight/app/templates/auth/email/confirm.html b/OpenOversight/app/templates/auth/email/confirm.html index 9e09ad756..14a89e677 100644 --- a/OpenOversight/app/templates/auth/email/confirm.html +++ b/OpenOversight/app/templates/auth/email/confirm.html @@ -10,5 +10,5 @@

    Sincerely,

    The OpenOversight Team

    - Note: replies to this email address are not monitored. + Please note that we may not monitor replies to this email address.

    diff --git a/OpenOversight/app/templates/auth/email/new_confirmation.html b/OpenOversight/app/templates/auth/email/new_confirmation.html index 6af487b7a..e50475a32 100644 --- a/OpenOversight/app/templates/auth/email/new_confirmation.html +++ b/OpenOversight/app/templates/auth/email/new_confirmation.html @@ -12,5 +12,5 @@

    Sincerely,

    The OpenOversight Team

    - Note: replies to this email address are not monitored. + Please note that we may not monitor replies to this email address.

    diff --git a/OpenOversight/app/templates/auth/email/new_registration.html b/OpenOversight/app/templates/auth/email/new_registration.html index 63deb48a4..8128359d7 100644 --- a/OpenOversight/app/templates/auth/email/new_registration.html +++ b/OpenOversight/app/templates/auth/email/new_registration.html @@ -12,5 +12,5 @@

    Sincerely,

    The OpenOversight Team

    - Note: replies to this email address are not monitored. + Please note that we may not monitor replies to this email address.

    diff --git a/OpenOversight/app/templates/auth/email/reset_password.html b/OpenOversight/app/templates/auth/email/reset_password.html index 84dd9fd5b..ce67a476c 100644 --- a/OpenOversight/app/templates/auth/email/reset_password.html +++ b/OpenOversight/app/templates/auth/email/reset_password.html @@ -8,5 +8,5 @@

    Sincerely,

    The OpenOversight Team

    - Note: replies to this email address are not monitored. + Please note that we may not monitor replies to this email address.

    diff --git a/OpenOversight/tests/routes/test_auth.py b/OpenOversight/tests/routes/test_auth.py index f738299da..a6f5e0d82 100644 --- a/OpenOversight/tests/routes/test_auth.py +++ b/OpenOversight/tests/routes/test_auth.py @@ -1,5 +1,6 @@ # Routing and view tests from http import HTTPStatus +from unittest import TestCase from urllib.parse import urlparse import pytest @@ -141,8 +142,10 @@ def test_user_cannot_register_if_passwords_dont_match(mockdata, client, session) def test_user_can_register_with_legit_credentials(mockdata, client, session): - with current_app.test_request_context(): - diceware_password = "operative hamster perservere verbalize curling" + with current_app.test_request_context(), TestCase.assertLogs( + current_app.logger + ) as log: + diceware_password = "operative hamster persevere verbalize curling" form = RegistrationForm( email="jen@example.com", username="redshiftzero", @@ -154,6 +157,10 @@ def test_user_can_register_with_legit_credentials(mockdata, client, session): ) assert b"A confirmation email has been sent to you." in rv.data + assert ( + f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Confirm Your Account" + in str(log.output) + ) def test_user_cannot_register_with_weak_password(mockdata, client, session): @@ -172,16 +179,24 @@ def test_user_cannot_register_with_weak_password(mockdata, client, session): def test_user_can_get_a_confirmation_token_resent(mockdata, client, session): - with current_app.test_request_context(): + with current_app.test_request_context(), TestCase.assertLogs( + current_app.logger + ) as log: login_user(client) rv = client.get(url_for("auth.resend_confirmation"), follow_redirects=True) assert b"A new confirmation email has been sent to you." in rv.data + assert ( + f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Confirm Your Account" + in str(log.output) + ) def test_user_can_get_password_reset_token_sent(mockdata, client, session): - with current_app.test_request_context(): + with current_app.test_request_context(), TestCase.assertLogs( + current_app.logger + ) as log: form = PasswordResetRequestForm(email="jen@example.org") rv = client.post( @@ -191,12 +206,18 @@ def test_user_can_get_password_reset_token_sent(mockdata, client, session): ) assert b"An email with instructions to reset your password" in rv.data + assert ( + f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Reset Your Password" + in str(log.output) + ) def test_user_can_get_password_reset_token_sent_with_differently_cased_email( mockdata, client, session ): - with current_app.test_request_context(): + with current_app.test_request_context(), TestCase.assertLogs( + current_app.logger + ) as log: form = PasswordResetRequestForm(email="JEN@EXAMPLE.ORG") rv = client.post( @@ -206,6 +227,10 @@ def test_user_can_get_password_reset_token_sent_with_differently_cased_email( ) assert b"An email with instructions to reset your password" in rv.data + assert ( + f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Reset Your Password" + in str(log.output) + ) def test_user_can_get_reset_password_with_valid_token(mockdata, client, session): @@ -361,7 +386,9 @@ def test_user_can_not_confirm_account_with_invalid_token(mockdata, client, sessi def test_user_can_change_password_if_they_match(mockdata, client, session): - with current_app.test_request_context(): + with current_app.test_request_context(), TestCase.assertLogs( + current_app.logger + ) as log: login_user(client) form = ChangePasswordForm( old_password="dog", password="validpasswd", password2="validpasswd" @@ -372,6 +399,10 @@ def test_user_can_change_password_if_they_match(mockdata, client, session): ) assert b"Your password has been updated." in rv.data + assert ( + f"{current_app.config['OO_MAIL_SUBJECT_PREFIX']} Your Password Has Changed" + in str(log.output) + ) def test_unconfirmed_user_redirected_to_confirm_account(mockdata, client, session): diff --git a/docker-compose.yml b/docker-compose.yml index a204c82f9..36ce3c23d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}" ENV: "${ENV:-development}" FLASK_APP: OpenOversight.app + OO_HELP_EMAIL: "info@lucyparsonslabs.com" OO_SERVICE_EMAIL: "openoversightchi@lucyparsonslabs.com" S3_BUCKET_NAME: "${S3_BUCKET_NAME}" volumes: From a5981c43d5171ca306dda4b7a4221ad20be626bc Mon Sep 17 00:00:00 2001 From: Michael Plunkett <5885605+michplunkett@users.noreply.github.com> Date: Tue, 18 Jul 2023 15:12:24 -0500 Subject: [PATCH 107/137] Update data-migration documentation (#975) ## Fixes issue https://github.com/lucyparsons/OpenOversight/issues/930 ## Description of Changes Add documentation to make data-migrations a more transparent process. ## Tests and linting - [x] This branch is up-to-date with the `develop` branch. - [x] `pytest` passes on my local development environment. - [x] `pre-commit` passes on my local development environment. --- CONTRIB.md | 71 +++++++++++++++++----------- OpenOversight/migrations/README | 1 - OpenOversight/migrations/README.md | 4 ++ OpenOversight/migrations/alembic.ini | 13 ++--- OpenOversight/migrations/env.py | 14 +----- database/README.md | 2 - tasks.py | 4 +- 7 files changed, 54 insertions(+), 55 deletions(-) delete mode 100644 OpenOversight/migrations/README create mode 100644 OpenOversight/migrations/README.md diff --git a/CONTRIB.md b/CONTRIB.md index 742a65eb3..4b20ecf83 100644 --- a/CONTRIB.md +++ b/CONTRIB.md @@ -11,8 +11,8 @@ To submit your changes for review you have to fork the repository, push your new Use [pull_request_template.md](/.github/pull_request_template.md) to create the description for your PR! (The template should populate automatically when you go to open the pull request.) ### Recommended privacy settings -Whenever you make a commit with `git` the name and email saved locally is stored with that commit and will become part of the public history of the project. This can be an unwanted, for example when using a work computer. We recommond changing the email-settings in the github account at https://github.com/settings/emails and selecting "Keep my email addresses private" as well as "Block command line pushes that expose my email". Also find your github-email address of the form `+@users.noreply.github.com` in that section. Then you can change the email and username stored with your commits by running the following commands -``` +Whenever you make a commit with `git` the name and email saved locally is stored with that commit and will become part of the public history of the project. This can be an unwanted, for example when using a work computer. We recommend changing the email-settings in the github account at https://github.com/settings/emails and selecting "Keep my email addresses private" as well as "Block command line pushes that expose my email". Also find your github-email address of the form `+@users.noreply.github.com` in that section. Then you can change the email and username stored with your commits by running the following commands +```shell git config user.email "" git config user.name "" ``` @@ -34,8 +34,8 @@ Tests are executed via `make test`. If you're switching between the Docker and V To hop into the postgres container, you can do the following: -``` -$ docker exec -it openoversight_postgres_1 /bin/bash +```shell +$ docker exec -it openoversight-postgres-1 bash # psql -d openoversight-dev -U openoversight ``` @@ -43,8 +43,8 @@ or run `make attach`. Similarly to hop into the web container: -``` -$ docker exec -it openoversight_web_1 /bin/bash +```shell +$ docker exec -it openoversight-web-1 bash ``` Once you're done, `make stop` and `make clean` to stop and remove the containers respectively. @@ -62,7 +62,7 @@ You will need to do these two things for the service account to work as a Gmail 4. For production, save the email address associated with your service account to a variable named `OO_SERVICE_EMAIL` in a `.env` file in the base directory of this repository. For development and testing, update the `OO_SERVICE_EMAIL` variable in the `docker-compose.yml` file. Example `.env` variable: -```bash +```shell OO_SERVICE_EMAIL="sample_email@domain.com" ``` @@ -70,7 +70,7 @@ In addition to needing a service account email, you also need an admin email add For production, save the email address associated with your admin account to a variable named `OO_HELP_EMAIL` in a `.env` file in the base directory of this repository. For development and testing, update the `OO_HELP_EMAIL` variable in the `docker-compose.yml` file. Example `.env` variable: -```bash +```shell OO_HELP_EMAIL="sample_admin_email@domain.com" ``` @@ -81,7 +81,7 @@ on Amazon Web Services. Once you have done this, you can put your AWS credentials in the following environmental variables: -```sh +```shell $ export S3_BUCKET_NAME=openoversight-test $ export AWS_ACCESS_KEY_ID=testtest $ export AWS_SECRET_ACCESS_KEY=testtest @@ -96,7 +96,7 @@ Running `make dev` will create the database and persist it into your local files You can access your PostgreSQL development database via psql using: -```sh +```shell psql -h localhost -d openoversight-dev -U openoversight --password ``` @@ -108,71 +108,88 @@ or `$ python test_data.py --cleanup` to delete the data ### Migrating the Database -If you e.g. add a new column or table, you'll need to migrate the database using the Flask CLI. First we need to 'stamp' the current version of the database: +You'll first have to start the Docker instance for the OpenOversight app using the command `make start`. To do this, you'll need to be in the base folder of the repository (the one that houses the `Makefile`). + +```shell +$ make start +docker-compose build +... +docker-compose up -d +[+] Running 2/0 + ✔ Container openoversight-postgres-1 Running 0.0s + ✔ Container openoversight-web-1 Running +``` -```sh -$ cd OpenOversight/ # change directory to source dir +From here on out, we'll be using the Flask CLI. First we need to 'stamp' the current version of the database: + +```shell +$ docker exec -it openoversight-web-1 bash # 'openoversight-web-1' is the name of the app container seen in the step above $ flask db stamp head +$ flask db migrate -m "[THE NAME OF YOUR MIGRATION]" # NOTE: Slugs are limited to 40 characters and will be truncated after the limit ``` (Hint: If you get errors when running `flask` commands, e.g. because of differing Python versions, you may need to run the commands in the docker container by prefacing them as so: `docker exec -it openoversight_web_1 flask db stamp head`) -Next make your changes to the database models in `models.py`. You'll then generate the migrations: +Next make your changes to the database models in `OpenOversight/app/models/database.py`. You'll then generate the migrations: -```sh +```shell $ flask db migrate ``` And then you should inspect/edit the migrations. You can then apply the migrations: -```sh +```shell $ flask db upgrade ``` -You can also downgrade the database using `flask db downgrade`. +You can also downgrade the database using: + +```shell +flask db downgrade +``` ## Using a Virtual Environment One way to avoid hitting version incompatibility errors when running `flask` commands is to use a virtualenv. See [Python Packaging user guidelines](https://packaging.python.org/guides/installing-using-pip-and-virtualenv/) for instructions on installing virtualenv. After installing virtualenv, you can create a virtual environment by navigating to the OpenOversight directory and running the below -```bash +```shell python3 -m virtualenv env ``` Confirm you're in the virtualenv by running -```bash +```shell which python ``` The response should point to your `env` directory. If you want to exit the virtualenv, run -```bash +```shell deactivate ``` To reactivate the virtualenv, run -```bash +```shell source env/bin/activate ``` While in the virtualenv, you can install project dependencies by running -```bash +```shell pip install -r requirements.txt ``` and -```bash +```shell pip install -r dev-requirements.txt ``` ## OpenOversight Management Interface In addition to generating database migrations, the Flask CLI can be used to run additional commands: -```sh +```shell $ flask --help Usage: flask [OPTIONS] COMMAND [ARGS]... @@ -204,7 +221,7 @@ Commands: In development, you can make an administrator account without having to confirm your email: -```sh +```shell $ flask make-admin-user Username: redshiftzero Email: jen@redshiftzero.com @@ -230,7 +247,7 @@ Next, in your terminal run `docker ps` to find the container id of the `openover ## Debugging OpenOversight - Use pdb with a test If you want to run an individual test in debug mode, use the below command. -```bash +```shell docker-compose run --rm web pytest --pdb -v tests/ -k ``` @@ -238,7 +255,7 @@ where `` is the name of a single test function, such as `test_ac Similarly, you can run all the tests in a file by specifying the file path: -```bash +```shell docker-compose run --rm web pytest --pdb -v path/to/test/file ``` diff --git a/OpenOversight/migrations/README b/OpenOversight/migrations/README deleted file mode 100644 index 2500aa1bc..000000000 --- a/OpenOversight/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. diff --git a/OpenOversight/migrations/README.md b/OpenOversight/migrations/README.md new file mode 100644 index 000000000..a905a93b1 --- /dev/null +++ b/OpenOversight/migrations/README.md @@ -0,0 +1,4 @@ +## General Alembic Information +Alembic provides for the creation, management, and invocation of change management scripts for a relational database, using SQLAlchemy as the underlying engine. + +For information on how to execute DB migrations, please visit this section in the `CONTRIB.md` file: [Link](../../CONTRIB.md#migrating-the-database) diff --git a/OpenOversight/migrations/alembic.ini b/OpenOversight/migrations/alembic.ini index f8ed4801f..adcf2dfcf 100644 --- a/OpenOversight/migrations/alembic.ini +++ b/OpenOversight/migrations/alembic.ini @@ -1,15 +1,8 @@ -# A generic, single database configuration. - [alembic] -# template used to generate migration files -# file_template = %%(rev)s_%%(slug)s - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - +script_location = ./OpenOversight/migrations/ +file_template = %%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s +truncate_slug_length = 40 -# Logging configuration [loggers] keys = root,sqlalchemy,alembic diff --git a/OpenOversight/migrations/env.py b/OpenOversight/migrations/env.py index 89e0726c6..7168e12c3 100644 --- a/OpenOversight/migrations/env.py +++ b/OpenOversight/migrations/env.py @@ -2,6 +2,7 @@ from logging.config import fileConfig from alembic import context +from flask import current_app from sqlalchemy import engine_from_config, pool @@ -9,28 +10,15 @@ # access to the values within the .ini file in use. config = context.config -# Interpret the config file for Python logging. -# This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger("alembic.env") -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -from flask import current_app # noqa: E402 - config.set_main_option( "sqlalchemy.url", current_app.config.get("SQLALCHEMY_DATABASE_URI") ) target_metadata = current_app.extensions["migrate"].db.metadata -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - def run_migrations_offline(): """Run migrations in 'offline' mode. diff --git a/database/README.md b/database/README.md index e6c3fe567..6f0ac76ee 100644 --- a/database/README.md +++ b/database/README.md @@ -1,11 +1,9 @@ # Database Setup ## Schema/Table Creation - Running `make dev` in the docker environment will create and persist the database. ## Database Diagram - ![](relationships.real.large.png) See more detailed database schema information [here](https://disman.tl/oo-docs/). diff --git a/tasks.py b/tasks.py index aa3f70093..e65c2215a 100644 --- a/tasks.py +++ b/tasks.py @@ -36,12 +36,12 @@ # make sure we are logging in UTC and have millisecond granularity -def _formatTime(record, _): +def _format_time(record, _): return str(datetime.datetime.utcfromtimestamp(record.created)) + "Z" for h in logging.getLogger().handlers: - h.formatter.formatTime = _formatTime + h.formatter.formatTime = _format_time @dataclass From d592a1f6e27918407a38611ce4ea16ed2c16bdc3 Mon Sep 17 00:00:00 2001 From: Michael Plunkett <5885605+michplunkett@users.noreply.github.com> Date: Tue, 18 Jul 2023 22:30:44 -0500 Subject: [PATCH 108/137] Rename `star_date` and `descrip` (#977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fixes issues https://github.com/lucyparsons/OpenOversight/issues/941 https://github.com/lucyparsons/OpenOversight/issues/427 ## Description of Changes Changed the following column names: - `star_date` -> `start_date` - `descrip` -> `description` ## Tests and linting - [x] This branch is up-to-date with the `develop` branch. - [x] `pytest` passes on my local development environment. - [x] `pre-commit` passes on my local development environment. - [x] Data-migration output: ```shell $ docker exec -it openoversight-web-1 bash $ flask db stamp head /usr/local/lib/python3.11/site-packages/flask_limiter/extension.py:293: UserWarning: Using the in-memory storage for tracking rate limits as no storage was explicitly specified. This is not recommended for production use. See: https://flask-limiter.readthedocs.io#configuring-a-storage-backend for documentation about configuring the storage backend. warnings.warn( [2023-07-18 17:16:06,001] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running stamp_revision -> 93fc3e074dcc $ flask db migrate -m "rename 'star_date'" /usr/local/lib/python3.11/site-packages/flask_limiter/extension.py:293: UserWarning: Using the in-memory storage for tracking rate limits as no storage was explicitly specified. This is not recommended for production use. See: https://flask-limiter.readthedocs.io#configuring-a-storage-backend for documentation about configuring the storage backend. warnings.warn( [2023-07-18 17:17:01,906] INFO in __init__: OpenOversight startup ... Generating /usr/src/app/OpenOversight/migrations/versions/2023-07-18-1717_9ce70d7ebd56_rename_star_date.py ... done $ flask db upgrade [2023-07-18 17:18:49,546] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running upgrade 93fc3e074dcc -> 9ce70d7ebd56, rename 'star_date' ... (env) % make start docker-compose build ... [+] Running 2/2 ✔ Container openoversight-postgres-1 Started 0.2s ✔ Container openoversight-web-1 Started 0.3s (env) % docker exec -it openoversight-web-1 bash $ flask db stamp head /usr/local/lib/python3.11/site-packages/flask_limiter/extension.py:293: UserWarning: Using the in-memory storage for tracking rate limits as no storage was explicitly specified. This is not recommended for production use. See: https://flask-limiter.readthedocs.io#configuring-a-storage-backend for documentation about configuring the storage backend. warnings.warn( [2023-07-18 19:18:26,742] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. $ flask db migrate -m "rename 'descrip' to 'description'" ... INFO [alembic.autogenerate.compare] Detected added column 'unit_types.description' INFO [alembic.autogenerate.compare] Detected removed index 'ix_unit_types_descrip' on 'unit_types' INFO [alembic.autogenerate.compare] Detected added index 'ix_unit_types_description' on '['description']' INFO [alembic.autogenerate.compare] Detected removed column 'unit_types.descrip' Generating /usr/src/app/OpenOversight/migrations/versions/2023-07-18-1921_eb0266dc8588_rename_descrip_to_description.py ... done $ flask db upgrade /usr/local/lib/python3.11/site-packages/flask_limiter/extension.py:293: UserWarning: Using the in-memory storage for tracking rate limits as no storage was explicitly specified. This is not recommended for production use. See: https://flask-limiter.readthedocs.io#configuring-a-storage-backend for documentation about configuring the storage backend. warnings.warn( [2023-07-18 19:33:12,354] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running upgrade 9ce70d7ebd56 -> eb0266dc8588, rename 'descrip' to 'description' $ ``` --- OpenOversight/app/commands.py | 6 +-- OpenOversight/app/csv_imports.py | 22 ++++---- OpenOversight/app/main/downloads.py | 6 +-- OpenOversight/app/main/forms.py | 16 +++--- OpenOversight/app/main/views.py | 16 +++--- OpenOversight/app/models/database.py | 20 ++++---- OpenOversight/app/models/database_imports.py | 6 +-- OpenOversight/app/templates/add_unit.html | 2 +- .../app/templates/edit_assignment.html | 2 +- OpenOversight/app/templates/list_officer.html | 2 +- .../partials/officer_assignment_history.html | 20 ++++---- OpenOversight/app/utils/db.py | 8 +-- OpenOversight/app/utils/forms.py | 8 +-- ...7-18-1717_9ce70d7ebd56_rename_star_date.py | 35 +++++++++++++ ...266dc8588_rename_descrip_to_description.py | 37 ++++++++++++++ OpenOversight/tests/conftest.py | 16 +++--- .../routes/test_officer_and_department.py | 50 ++++++++++--------- OpenOversight/tests/test_commands.py | 12 ++--- OpenOversight/tests/test_functional.py | 12 ++--- OpenOversight/tests/test_models.py | 2 +- OpenOversight/tests/test_utils.py | 2 +- docs/bulk_upload.rst | 6 +-- 22 files changed, 192 insertions(+), 114 deletions(-) create mode 100644 OpenOversight/migrations/versions/2023-07-18-1717_9ce70d7ebd56_rename_star_date.py create mode 100644 OpenOversight/migrations/versions/2023-07-18-1921_eb0266dc8588_rename_descrip_to_description.py diff --git a/OpenOversight/app/commands.py b/OpenOversight/app/commands.py index b953cccc6..07266028b 100644 --- a/OpenOversight/app/commands.py +++ b/OpenOversight/app/commands.py @@ -320,7 +320,7 @@ def try_else_false(comparable): def process_assignment(row, officer, compare=False): assignment_fields = { "required": [], - "optional": ["job_title", "star_no", "unit_id", "star_date", "resign_date"], + "optional": ["job_title", "star_no", "unit_id", "start_date", "resign_date"], } # See if the row has assignment data @@ -338,7 +338,7 @@ def process_assignment(row, officer, compare=False): assignment_fieldnames = [ "star_no", "unit_id", - "star_date", + "start_date", "resign_date", ] i = 0 @@ -389,7 +389,7 @@ def process_assignment(row, officer, compare=False): assignment.job_id = job.id set_field_from_row(row, assignment, "star_no") set_field_from_row(row, assignment, "unit_id") - set_field_from_row(row, assignment, "star_date", allow_blank=False) + set_field_from_row(row, assignment, "start_date", allow_blank=False) set_field_from_row(row, assignment, "resign_date", allow_blank=False) db.session.add(assignment) db.session.flush() diff --git a/OpenOversight/app/csv_imports.py b/OpenOversight/app/csv_imports.py index 799b8009f..3d07f66ad 100644 --- a/OpenOversight/app/csv_imports.py +++ b/OpenOversight/app/csv_imports.py @@ -115,7 +115,8 @@ def _handle_officers_csv( "birth_year", "unique_internal_identifier", "department_name", - # the following are unused, but allowed since they are included in the csv output + # the following are unused, but allowed since they are included in the + # csv output "badge_number", "unique_identifier", "job_title", @@ -162,7 +163,7 @@ def _handle_assignments_csv( with _csv_reader(assignments_csv) as csv_reader: field_names = csv_reader.fieldnames if "start_date" in field_names: - field_names[field_names.index("start_date")] = "star_date" + field_names[field_names.index("start_date")] = "start_date" if "badge_number" in field_names: field_names[field_names.index("badge_number")] = "star_no" if "end_date" in field_names: @@ -181,7 +182,7 @@ def _handle_assignments_csv( "star_no", "unit_id", "unit_name", - "star_date", + "start_date", "resign_date", "officer_unique_identifier", ], @@ -193,8 +194,8 @@ def _handle_assignments_csv( job_title_to_id = { job.job_title.strip().lower(): job.id for job in jobs_for_department } - unit_descrip_to_id = { - unit.descrip.strip().lower(): unit.id + unit_description_to_id = { + unit.description.strip().lower(): unit.id for unit in Unit.query.filter_by(department_id=department_id).all() } if overwrite_assignments: @@ -211,7 +212,8 @@ def _handle_assignments_csv( ) if len(wrong_department) > 0: raise Exception( - "Referenced {} officers in assignment csv that belong to different department. Example ids: {}".format( + "Referenced {} officers in assignment csv that belong to different " + "department. Example ids: {}".format( len(wrong_department), ", ".join(map(str, list(wrong_department)[:3])), ) @@ -253,17 +255,17 @@ def _handle_assignments_csv( ) elif row.get("unit_name"): unit_name = row["unit_name"].strip() - descrip = unit_name.lower() - unit_id = unit_descrip_to_id.get(descrip) + description = unit_name.lower() + unit_id = unit_description_to_id.get(description) if unit_id is None: unit = Unit( - descrip=unit_name, + description=unit_name, department_id=officer.department_id, ) db.session.add(unit) db.session.flush() unit_id = unit.id - unit_descrip_to_id[descrip] = unit_id + unit_description_to_id[description] = unit_id row["unit_id"] = unit_id job_title = row["job_title"].strip().lower() job_id = job_title_to_id.get(job_title) diff --git a/OpenOversight/app/main/downloads.py b/OpenOversight/app/main/downloads.py index ddff09f3b..dc689f3cc 100644 --- a/OpenOversight/app/main/downloads.py +++ b/OpenOversight/app/main/downloads.py @@ -84,7 +84,7 @@ def salary_record_maker(salary: Salary) -> _Record: def officer_record_maker(officer: Officer) -> _Record: if officer.assignments_lazy: most_recent_assignment = max( - officer.assignments_lazy, key=lambda a: a.star_date or date.min + officer.assignments_lazy, key=lambda a: a.start_date or date.min ) most_recent_title = most_recent_assignment.job and check_output( most_recent_assignment.job.job_title @@ -121,10 +121,10 @@ def assignment_record_maker(assignment: Assignment) -> _Record: "officer unique identifier": officer and officer.unique_internal_identifier, "badge number": assignment.star_no, "job title": assignment.job and check_output(assignment.job.job_title), - "start date": assignment.star_date, + "start date": assignment.start_date, "end date": assignment.resign_date, "unit id": assignment.unit and assignment.unit.id, - "unit description": assignment.unit and assignment.unit.descrip, + "unit description": assignment.unit and assignment.unit.description, } diff --git a/OpenOversight/app/main/forms.py b/OpenOversight/app/main/forms.py index 19a2ff205..f0759b02a 100644 --- a/OpenOversight/app/main/forms.py +++ b/OpenOversight/app/main/forms.py @@ -62,8 +62,8 @@ def validate_money(form, field): def validate_end_date(form, field): - if form.data["star_date"] and field.data: - if form.data["star_date"] > field.data: + if form.data["start_date"] and field.data: + if form.data["start_date"] > field.data: raise ValidationError("End date must come after start date.") @@ -153,11 +153,11 @@ class AssignmentForm(Form): "Unit", validators=[Optional()], query_factory=unit_choices, - get_label="descrip", + get_label="description", allow_blank=True, blank_text="None", ) - star_date = DateField("Assignment start date", validators=[Optional()]) + start_date = DateField("Assignment start date", validators=[Optional()]) resign_date = DateField( "Assignment end date", validators=[Optional(), validate_end_date] ) @@ -319,7 +319,7 @@ class AddOfficerForm(Form): "Unit", validators=[Optional()], query_factory=unit_choices, - get_label="descrip", + get_label="description", allow_blank=True, blank_text="None", ) @@ -399,7 +399,7 @@ class EditOfficerForm(Form): class AddUnitForm(Form): - descrip = StringField( + description = StringField( "Unit name or description", default="", validators=[Regexp(r"\w*"), Length(max=120), DataRequired()], @@ -561,8 +561,8 @@ class BrowseForm(Form): unit = QuerySelectField( "unit", validators=[Optional()], - get_label="descrip", - get_pk=lambda unit: unit.descrip, + get_label="description", + get_pk=lambda unit: unit.description, ) current_job = BooleanField("current_job", default=None, validators=[Optional()]) name = StringField("Last name") diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index bfc3ad75a..388a0f2f8 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -712,9 +712,9 @@ def list_officer( unit_selections = ["Not Sure"] + [ uc[0] - for uc in db.session.query(Unit.descrip) + for uc in db.session.query(Unit.description) .filter_by(department_id=department_id) - .order_by(Unit.descrip.asc()) + .order_by(Unit.description.asc()) .all() ] rank_selections = [ @@ -849,13 +849,13 @@ def get_dept_units(department_id=None): if department_id: units = Unit.query.filter_by(department_id=department_id) - units = units.order_by(Unit.descrip).all() - unit_list = [(unit.id, unit.descrip) for unit in units] + units = units.order_by(Unit.description).all() + unit_list = [(unit.id, unit.description) for unit in units] else: units = Unit.query.all() # Prevent duplicate units unit_list = sorted( - set((unit.id, unit.descrip) for unit in units), + set((unit.id, unit.description) for unit in units), key=lambda x: x[1], ) @@ -933,10 +933,12 @@ def add_unit(): set_dynamic_default(form.department, current_user.dept_pref_rel) if form.validate_on_submit(): - unit = Unit(descrip=form.descrip.data, department_id=form.department.data.id) + unit = Unit( + description=form.description.data, department_id=form.department.data.id + ) db.session.add(unit) db.session.commit() - flash("New unit {} added to OpenOversight".format(unit.descrip)) + flash("New unit {} added to OpenOversight".format(unit.description)) return redirect(url_for("main.get_started_labeling")) else: current_app.logger.info(form.errors) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index 71fc60c58..3b80c5189 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -185,26 +185,26 @@ def gender_label(self): def job_title(self): if self.assignments_lazy: return max( - self.assignments_lazy, key=lambda x: x.star_date or date.min + self.assignments_lazy, key=lambda x: x.start_date or date.min ).job.job_title - def unit_descrip(self): + def unit_description(self): if self.assignments_lazy: unit = max( - self.assignments_lazy, key=lambda x: x.star_date or date.min + self.assignments_lazy, key=lambda x: x.start_date or date.min ).unit - return unit.descrip if unit else None + return unit.description if unit else None def badge_number(self): if self.assignments_lazy: return max( - self.assignments_lazy, key=lambda x: x.star_date or date.min + self.assignments_lazy, key=lambda x: x.start_date or date.min ).star_no def currently_on_force(self): if self.assignments_lazy: most_recent = max( - self.assignments_lazy, key=lambda x: x.star_date or date.min + self.assignments_lazy, key=lambda x: x.start_date or date.min ) return "Yes" if most_recent.resign_date is None else "No" return "Uncertain" @@ -250,7 +250,7 @@ class Assignment(BaseModel): job = db.relationship("Job") unit_id = db.Column(db.Integer, db.ForeignKey("unit_types.id"), nullable=True) unit = db.relationship("Unit") - star_date = db.Column(db.Date, index=True, unique=False, nullable=True) + start_date = db.Column(db.Date, index=True, unique=False, nullable=True) resign_date = db.Column(db.Date, index=True, unique=False, nullable=True) def __repr__(self): @@ -261,14 +261,14 @@ class Unit(BaseModel): __tablename__ = "unit_types" id = db.Column(db.Integer, primary_key=True) - descrip = db.Column(db.String(120), index=True, unique=False) + description = db.Column(db.String(120), index=True, unique=False) department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) department = db.relationship( - "Department", backref="unit_types", order_by="Unit.descrip.asc()" + "Department", backref="unit_types", order_by="Unit.description.asc()" ) def __repr__(self): - return "Unit: {}".format(self.descrip) + return "Unit: {}".format(self.description) class Face(BaseModel): diff --git a/OpenOversight/app/models/database_imports.py b/OpenOversight/app/models/database_imports.py index 6ea60208a..f669e0169 100644 --- a/OpenOversight/app/models/database_imports.py +++ b/OpenOversight/app/models/database_imports.py @@ -128,7 +128,7 @@ def create_assignment_from_dict( star_no=parse_str(data.get("star_no"), None), job_id=int(data["job_id"]), unit_id=parse_int(data.get("unit_id")), - star_date=parse_date(data.get("star_date")), + start_date=parse_date(data.get("start_date")), resign_date=parse_date(data.get("resign_date")), ) if force_id and data.get("id"): @@ -149,8 +149,8 @@ def update_assignment_from_dict( assignment.job_id = int(data["job_id"]) if "unit_id" in data.keys(): assignment.unit_id = parse_int(data.get("unit_id")) - if "star_date" in data.keys(): - assignment.star_date = parse_date(data.get("star_date")) + if "start_date" in data.keys(): + assignment.start_date = parse_date(data.get("start_date")) if "resign_date" in data.keys(): assignment.resign_date = parse_date(data.get("resign_date")) db.session.flush() diff --git a/OpenOversight/app/templates/add_unit.html b/OpenOversight/app/templates/add_unit.html index 0717fe2c9..6e497f795 100644 --- a/OpenOversight/app/templates/add_unit.html +++ b/OpenOversight/app/templates/add_unit.html @@ -12,7 +12,7 @@

    Add Unit

    {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} - {{ wtf.form_field(form.descrip, autofocus="autofocus") }} + {{ wtf.form_field(form.description, autofocus="autofocus") }} {{ wtf.form_field(form.department) }} {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }}
    diff --git a/OpenOversight/app/templates/edit_assignment.html b/OpenOversight/app/templates/edit_assignment.html index 604054b82..19694bc31 100644 --- a/OpenOversight/app/templates/edit_assignment.html +++ b/OpenOversight/app/templates/edit_assignment.html @@ -18,7 +18,7 @@

    Edit Officer Assignment

    Don't see your unit? Add one!

    - {{ wtf.form_field(form.star_date) }} + {{ wtf.form_field(form.start_date) }} {{ wtf.form_field(form.resign_date) }} diff --git a/OpenOversight/app/templates/list_officer.html b/OpenOversight/app/templates/list_officer.html index 7b01a4dbb..3c19ce962 100644 --- a/OpenOversight/app/templates/list_officer.html +++ b/OpenOversight/app/templates/list_officer.html @@ -285,7 +285,7 @@

    Unit
    - {{ officer.unit_descrip() | default('Unknown') }} + {{ officer.unit_description() | default('Unknown') }}
    Currently on the Force
    diff --git a/OpenOversight/app/templates/partials/officer_assignment_history.html b/OpenOversight/app/templates/partials/officer_assignment_history.html index 1a3d3aff4..ec4096dc0 100644 --- a/OpenOversight/app/templates/partials/officer_assignment_history.html +++ b/OpenOversight/app/templates/partials/officer_assignment_history.html @@ -23,16 +23,16 @@

    Assignment History

    {% endif %} - {% for assignment in assignments|rejectattr('star_date','ne',None) %} + {% for assignment in assignments|rejectattr('start_date','ne',None) %} {{ assignment.job.job_title }} {{ assignment.star_no }} - {% if assignment.unit_id %}{{ assignment.unit.descrip }}{% endif %} + {% if assignment.unit_id %}{{ assignment.unit.description }}{% endif %} - {% if assignment.star_date %} - {{ assignment.star_date }} + {% if assignment.start_date %} + {{ assignment.start_date }} {% else %} Unknown {% endif %} @@ -50,16 +50,16 @@

    Assignment History

    {% endfor %} - {% for assignment in assignments | rejectattr('star_date', 'none') | sort(attribute='star_date', reverse=True) %} + {% for assignment in assignments | rejectattr('start_date', 'none') | sort(attribute='start_date', reverse=True) %} {{ assignment.job.job_title }} {{ assignment.star_no }} - {% if assignment.unit_id %}{{ assignment.unit.descrip }}{% endif %} + {% if assignment.unit_id %}{{ assignment.unit.description }}{% endif %} - {% if assignment.star_date: %} - {{ assignment.star_date }} + {% if assignment.start_date %} + {{ assignment.start_date }} {% else %} Unknown {% endif %} @@ -132,8 +132,8 @@

    Start date of new assignment: - {{ form.star_date }} - {% for error in form.star_date.errors %} + {{ form.start_date }} + {% for error in form.start_date.errors %}

    [{{ error }}]

    diff --git a/OpenOversight/app/utils/db.py b/OpenOversight/app/utils/db.py index fc068dd37..87c2402bb 100644 --- a/OpenOversight/app/utils/db.py +++ b/OpenOversight/app/utils/db.py @@ -26,9 +26,9 @@ def add_unit_query(form, current_user): if not current_user.is_administrator: form.unit.query = Unit.query.filter_by( department_id=current_user.ac_department_id - ).order_by(Unit.descrip.asc()) + ).order_by(Unit.description.asc()) else: - form.unit.query = Unit.query.order_by(Unit.descrip.asc()).all() + form.unit.query = Unit.query.order_by(Unit.description.asc()).all() def compute_leaderboard_stats(select_top=25): @@ -82,7 +82,7 @@ def unit_choices(department_id: Optional[int] = None): return ( db.session.query(Unit) .filter_by(department_id=department_id) - .order_by(Unit.descrip.asc()) + .order_by(Unit.description.asc()) .all() ) - return db.session.query(Unit).order_by(Unit.descrip.asc()).all() + return db.session.query(Unit).order_by(Unit.description.asc()).all() diff --git a/OpenOversight/app/utils/forms.py b/OpenOversight/app/utils/forms.py index 1eeba9ed8..42ba4142f 100644 --- a/OpenOversight/app/utils/forms.py +++ b/OpenOversight/app/utils/forms.py @@ -39,7 +39,7 @@ def add_new_assignment(officer_id, form): star_no=form.star_no.data, job_id=job.id, unit_id=unit_id, - star_date=form.star_date.data, + start_date=form.start_date.data, resign_date=form.resign_date.data, ) db.session.add(new_assignment) @@ -71,7 +71,7 @@ def add_officer_profile(form, current_user): star_no=form.star_no.data, job_id=form.job_id.data, unit=officer_unit, - star_date=form.employment_date.data, + start_date=form.employment_date.data, ) db.session.add(assignment) if form.links.data: @@ -207,7 +207,7 @@ def edit_existing_assignment(assignment, form): officer_unit = None assignment.unit_id = officer_unit - assignment.star_date = form.star_date.data + assignment.start_date = form.start_date.data assignment.resign_date = form.resign_date.data db.session.add(assignment) db.session.commit() @@ -290,7 +290,7 @@ def filter_by_form(form_data, officer_query, department_id=None): unit_ids = [ unit.id for unit in Unit.query.filter_by(department_id=department_id) - .filter(Unit.descrip.in_(form_data.get("unit"))) + .filter(Unit.description.in_(form_data.get("unit"))) .all() ] diff --git a/OpenOversight/migrations/versions/2023-07-18-1717_9ce70d7ebd56_rename_star_date.py b/OpenOversight/migrations/versions/2023-07-18-1717_9ce70d7ebd56_rename_star_date.py new file mode 100644 index 000000000..0c00be403 --- /dev/null +++ b/OpenOversight/migrations/versions/2023-07-18-1717_9ce70d7ebd56_rename_star_date.py @@ -0,0 +1,35 @@ +"""rename 'star_date' + +Revision ID: 9ce70d7ebd56 +Revises: 93fc3e074dcc +Create Date: 2023-07-18 17:17:02.018209 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "9ce70d7ebd56" +down_revision = "93fc3e074dcc" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("assignments", schema=None) as batch_op: + batch_op.drop_index("ix_assignments_star_date") + batch_op.alter_column("star_date", nullable=True, new_column_name="start_date") + batch_op.create_index("ix_assignments_start_date", ["start_date"], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("assignments", schema=None) as batch_op: + batch_op.drop_index("ix_assignments_start_date") + batch_op.alter_column("start_date", nullable=True, new_column_name="star_date") + batch_op.create_index("ix_assignments_star_date", ["star_date"], unique=False) + + # ### end Alembic commands ### diff --git a/OpenOversight/migrations/versions/2023-07-18-1921_eb0266dc8588_rename_descrip_to_description.py b/OpenOversight/migrations/versions/2023-07-18-1921_eb0266dc8588_rename_descrip_to_description.py new file mode 100644 index 000000000..cece50e13 --- /dev/null +++ b/OpenOversight/migrations/versions/2023-07-18-1921_eb0266dc8588_rename_descrip_to_description.py @@ -0,0 +1,37 @@ +"""rename 'descrip' to 'description' + +Revision ID: eb0266dc8588 +Revises: 9ce70d7ebd56 +Create Date: 2023-07-18 19:21:43.632936 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "eb0266dc8588" +down_revision = "9ce70d7ebd56" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("unit_types", schema=None) as batch_op: + batch_op.drop_index("ix_unit_types_descrip") + batch_op.alter_column("descrip", nullable=True, new_column_name="description") + batch_op.create_index( + "ix_unit_types_description", ["description"], unique=False + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("unit_types", schema=None) as batch_op: + batch_op.drop_index("ix_unit_types_description") + batch_op.alter_column("description", nullable=True, new_column_name="descrip") + batch_op.create_index("ix_unit_types_descrip", ["descrip"], unique=False) + + # ### end Alembic commands ### diff --git a/OpenOversight/tests/conftest.py b/OpenOversight/tests/conftest.py index ee391f675..d4827bee8 100644 --- a/OpenOversight/tests/conftest.py +++ b/OpenOversight/tests/conftest.py @@ -162,7 +162,7 @@ def build_assignment(officer: Officer, units: List[Optional[Unit]], jobs: Job): job_id=random.choice(jobs).id, officer=officer, unit_id=unit_id, - star_date=pick_date(officer.full_name().encode(ENCODING_UTF_8)), + start_date=pick_date(officer.full_name().encode(ENCODING_UTF_8)), resign_date=pick_date(officer.full_name().encode(ENCODING_UTF_8)), ) @@ -344,11 +344,11 @@ def add_mockdata(session): random.seed(current_app.config["SEED"]) test_units = [ - Unit(descrip="test", department_id=1), - Unit(descrip="District 13", department_id=1), - Unit(descrip="Donut Devourers", department_id=1), - Unit(descrip="Bureau of Organized Crime", department_id=2), - Unit(descrip="Porky's BBQ: Rub Division", department_id=2), + Unit(description="test", department_id=1), + Unit(description="District 13", department_id=1), + Unit(description="Donut Devourers", department_id=1), + Unit(description="Bureau of Organized Crime", department_id=2), + Unit(description="Porky's BBQ: Rub Division", department_id=2), ] session.add_all(test_units) session.commit() @@ -671,7 +671,7 @@ def teardown(): "star_no", "job_title", "unit_id", - "star_date", + "start_date", "resign_date", "salary", "salary_year", @@ -702,7 +702,7 @@ def teardown(): if assignment.job else None, "unit_id": assignment.unit_id, - "star_date": assignment.star_date, + "start_date": assignment.start_date, "resign_date": assignment.resign_date, }, ) diff --git a/OpenOversight/tests/routes/test_officer_and_department.py b/OpenOversight/tests/routes/test_officer_and_department.py index 31dac39ed..aab4848a9 100644 --- a/OpenOversight/tests/routes/test_officer_and_department.py +++ b/OpenOversight/tests/routes/test_officer_and_department.py @@ -173,7 +173,7 @@ def test_admin_can_add_assignment(mockdata, client, session): form = AssignmentForm( star_no="1234", job_title=job.id, - star_date=date(2019, 1, 1), + start_date=date(2019, 1, 1), resign_date=date(2019, 12, 31), ) @@ -187,7 +187,7 @@ def test_admin_can_add_assignment(mockdata, client, session): assert "2019-01-01" in rv.data.decode(ENCODING_UTF_8) assert "2019-12-31" in rv.data.decode(ENCODING_UTF_8) assignment = Assignment.query.filter_by(star_no="1234", job_id=job.id).first() - assert assignment.star_date == date(2019, 1, 1) + assert assignment.start_date == date(2019, 1, 1) assert assignment.resign_date == date(2019, 12, 31) @@ -201,7 +201,7 @@ def test_admin_add_assignment_validation_error(mockdata, client, session): form = AssignmentForm( star_no="1234", job_title=job.id, - star_date=date(2020, 1, 1), + start_date=date(2020, 1, 1), resign_date=date(2019, 12, 31), ) @@ -226,7 +226,7 @@ def test_ac_can_add_assignment_in_their_dept(mockdata, client, session): form = AssignmentForm( star_no="S1234", job_title=job.id, - star_date=date(2019, 1, 1), + start_date=date(2019, 1, 1), resign_date=date(2019, 12, 31), ) @@ -244,7 +244,7 @@ def test_ac_can_add_assignment_in_their_dept(mockdata, client, session): assignment = Assignment.query.filter_by( star_no="S1234", officer_id=officer.id ).first() - assert assignment.star_date == date(2019, 1, 1) + assert assignment.start_date == date(2019, 1, 1) assert assignment.resign_date == date(2019, 12, 31) @@ -282,7 +282,7 @@ def test_admin_can_edit_assignment(mockdata, client, session): form = AssignmentForm( star_no="1234", job_title=job.id, - star_date=date(2019, 1, 1), + start_date=date(2019, 1, 1), resign_date=date(2019, 12, 31), ) @@ -298,7 +298,7 @@ def test_admin_can_edit_assignment(mockdata, client, session): assert "2019-12-31" in rv.data.decode(ENCODING_UTF_8) assignment = Assignment.query.filter_by(star_no="1234", job_id=job.id).first() - assert assignment.star_date == date(2019, 1, 1) + assert assignment.start_date == date(2019, 1, 1) assert assignment.resign_date == date(2019, 12, 31) job = Job.query.filter_by( @@ -307,7 +307,7 @@ def test_admin_can_edit_assignment(mockdata, client, session): form = AssignmentForm( star_no="12345", job_title=job.id, - star_date=date(2019, 2, 1), + start_date=date(2019, 2, 1), resign_date=date(2019, 11, 30), ) officer = Officer.query.filter_by(id=3).one() @@ -328,7 +328,7 @@ def test_admin_can_edit_assignment(mockdata, client, session): assert "2019-11-30" in rv.data.decode(ENCODING_UTF_8) assignment = Assignment.query.filter_by(star_no="12345", job_id=job.id).first() - assert assignment.star_date == date(2019, 2, 1) + assert assignment.start_date == date(2019, 2, 1) assert assignment.resign_date == date(2019, 11, 30) @@ -345,7 +345,7 @@ def test_admin_edit_assignment_validation_error( form = AssignmentForm( star_no="1234", job_title=job.id, - star_date=date(2019, 1, 1), + start_date=date(2019, 1, 1), resign_date=date(2019, 12, 31), ) @@ -370,7 +370,7 @@ def test_admin_edit_assignment_validation_error( ) assignment = Assignment.query.filter_by(star_no="1234", job_id=job.id).first() assert "End date must come after start date." in rv.data.decode(ENCODING_UTF_8) - assert assignment.star_date == date(2019, 1, 1) + assert assignment.start_date == date(2019, 1, 1) assert assignment.resign_date == date(2019, 12, 31) @@ -387,7 +387,7 @@ def test_ac_can_edit_officer_in_their_dept_assignment(mockdata, client, session) form = AssignmentForm( star_no=star_no, job_title=job.id, - star_date=date(2019, 1, 1), + start_date=date(2019, 1, 1), resign_date=date(2019, 12, 31), ) @@ -404,7 +404,7 @@ def test_ac_can_edit_officer_in_their_dept_assignment(mockdata, client, session) assert "2019-01-01" in rv.data.decode(ENCODING_UTF_8) assert "2019-12-31" in rv.data.decode(ENCODING_UTF_8) assert officer.assignments[0].star_no == star_no - assert officer.assignments[0].star_date == date(2019, 1, 1) + assert officer.assignments[0].start_date == date(2019, 1, 1) assert officer.assignments[0].resign_date == date(2019, 12, 31) officer = Officer.query.filter_by(id=officer.id).one() @@ -414,7 +414,7 @@ def test_ac_can_edit_officer_in_their_dept_assignment(mockdata, client, session) form = AssignmentForm( star_no=new_star_no, job_title=job.id, - star_date=date(2019, 2, 1), + start_date=date(2019, 2, 1), resign_date=date(2019, 11, 30), ) @@ -433,7 +433,7 @@ def test_ac_can_edit_officer_in_their_dept_assignment(mockdata, client, session) assert "2019-02-01" in rv.data.decode(ENCODING_UTF_8) assert "2019-11-30" in rv.data.decode(ENCODING_UTF_8) assert officer.assignments[0].star_no == new_star_no - assert officer.assignments[0].star_date == date(2019, 2, 1) + assert officer.assignments[0].start_date == date(2019, 2, 1) assert officer.assignments[0].resign_date == date(2019, 11, 30) @@ -1326,7 +1326,7 @@ def test_admin_can_add_new_unit(mockdata, client, session, department): with current_app.test_request_context(): login_admin(client) - form = AddUnitForm(descrip="Test", department=department.id) + form = AddUnitForm(description="Test", department=department.id) rv = client.post( url_for("main.add_unit"), data=form.data, follow_redirects=True @@ -1335,7 +1335,7 @@ def test_admin_can_add_new_unit(mockdata, client, session, department): assert "New unit" in rv.data.decode(ENCODING_UTF_8) # Check the unit was added to the database - unit = Unit.query.filter_by(descrip="Test").one() + unit = Unit.query.filter_by(description="Test").one() assert unit.department_id == department.id @@ -1344,7 +1344,7 @@ def test_ac_can_add_new_unit_in_their_dept(mockdata, client, session): login_ac(client) department = Department.query.filter_by(id=AC_DEPT).first() - form = AddUnitForm(descrip="Test", department=department.id) + form = AddUnitForm(description="Test", department=department.id) rv = client.post( url_for("main.add_unit"), data=form.data, follow_redirects=True @@ -1353,7 +1353,7 @@ def test_ac_can_add_new_unit_in_their_dept(mockdata, client, session): assert "New unit" in rv.data.decode(ENCODING_UTF_8) # Check the unit was added to the database - unit = Unit.query.filter_by(descrip="Test").one() + unit = Unit.query.filter_by(description="Test").one() assert unit.department_id == department.id @@ -1364,12 +1364,12 @@ def test_ac_cannot_add_new_unit_not_in_their_dept(mockdata, client, session): department = Department.query.except_( Department.query.filter_by(id=AC_DEPT) ).first() - form = AddUnitForm(descrip="Test", department=department.id) + form = AddUnitForm(description="Test", department=department.id) client.post(url_for("main.add_unit"), data=form.data, follow_redirects=True) # Check the unit was not added to the database - unit = Unit.query.filter_by(descrip="Test").first() + unit = Unit.query.filter_by(description="Test").first() assert unit is None @@ -1485,7 +1485,7 @@ def test_assignments_csv(mockdata, client, session, department): .first() ) form = AssignmentForm( - star_no="9181", job_title=job, star_date=date(2020, 6, 16) + star_no="9181", job_title=job, start_date=date(2020, 6, 16) ) add_new_assignment(officer.id, form) rv = client.get( @@ -1511,7 +1511,7 @@ def test_assignments_csv(mockdata, client, session, department): row for row in lines if row["badge number"] == form.star_no.data ] assert len(new_assignment) == 1 - assert new_assignment[0]["start date"] == str(form.star_date.data) + assert new_assignment[0]["start date"] == str(form.start_date.data) assert new_assignment[0]["job title"] == job.job_title @@ -1691,7 +1691,9 @@ def normalize_tokens_for_comparison(html_str: str, split_str: str): ) filter_list = normalize_tokens_for_comparison(rv, "
    Unit
    ") - assert any("
    {}
    ".format(unit.descrip) in token for token in filter_list) + assert any( + "
    {}
    ".format(unit.description) in token for token in filter_list + ) filter_list = normalize_tokens_for_comparison(rv, "
    Race
    ") assert any("
    White
    " in token for token in filter_list) diff --git a/OpenOversight/tests/test_commands.py b/OpenOversight/tests/test_commands.py index bfd5f9190..1795ffb70 100644 --- a/OpenOversight/tests/test_commands.py +++ b/OpenOversight/tests/test_commands.py @@ -367,7 +367,7 @@ def test_csv_new_officer(csvfile, monkeypatch): "star_no": 666, "job_title": "CAPTAIN", "unit": None, - "star_date": None, + "start_date": None, "resign_date": None, "salary": 1.23, "salary_year": 2019, @@ -789,7 +789,7 @@ def test_advanced_csv_import__success(session, department, test_csv_dir): id=77021, officer_id=officer.id, star_no="4567", - star_date=datetime.date(2020, 1, 1), + start_date=datetime.date(2020, 1, 1), job_id=department.jobs[0].id, ) session.add(assignment) @@ -863,10 +863,10 @@ def test_advanced_csv_import__success(session, department, test_csv_dir): assert salary_2019.salary == 10001 assignment_po, assignment_cap = sorted( - cop1.assignments, key=operator.attrgetter("star_date") + cop1.assignments, key=operator.attrgetter("start_date") ) assert assignment_po.star_no == "1234" - assert assignment_po.star_date == datetime.date(2019, 7, 12) + assert assignment_po.start_date == datetime.date(2019, 7, 12) assert assignment_po.resign_date == datetime.date(2020, 1, 1) assert assignment_po.job.job_title == "Police Officer" assert assignment_po.unit_id is None @@ -901,13 +901,13 @@ def test_advanced_csv_import__success(session, department, test_csv_dir): assert len(cop4.assignments.all()) == 2 updated_assignment, new_assignment = sorted( - cop4.assignments, key=operator.attrgetter("star_date") + cop4.assignments, key=operator.attrgetter("start_date") ) assert updated_assignment.job.job_title == "Police Officer" assert updated_assignment.resign_date == datetime.date(2020, 7, 10) assert updated_assignment.star_no == "4567" assert new_assignment.job.job_title == "Captain" - assert new_assignment.star_date == datetime.date(2020, 7, 10) + assert new_assignment.start_date == datetime.date(2020, 7, 10) assert new_assignment.star_no == "54321" incident = cop4.incidents[0] diff --git a/OpenOversight/tests/test_functional.py b/OpenOversight/tests/test_functional.py index 923e80215..985075131 100644 --- a/OpenOversight/tests/test_functional.py +++ b/OpenOversight/tests/test_functional.py @@ -179,10 +179,10 @@ def test_incident_detail_display_read_more_button_for_descriptions_over_cutoff( # Navigate to profile page for officer with short and long incident descriptions browser.get(f"http://localhost:{server_port}/officer/1") - incident_long_descrip = Incident.query.filter( + incident_long_description = Incident.query.filter( func.length(Incident.description) > DESCRIPTION_CUTOFF ).one_or_none() - incident_id = str(incident_long_descrip.id) + incident_id = str(incident_long_description.id) result = browser.find_element_by_id("description-overflow-row_" + incident_id) assert result.is_displayed() @@ -194,10 +194,10 @@ def test_incident_detail_truncate_description_for_descriptions_over_cutoff( # Navigate to profile page for officer with short and long incident descriptions browser.get(f"http://localhost:{server_port}/officer/1") - incident_long_descrip = Incident.query.filter( + incident_long_description = Incident.query.filter( func.length(Incident.description) > DESCRIPTION_CUTOFF ).one_or_none() - incident_id = str(incident_long_descrip.id) + incident_id = str(incident_long_description.id) # Check that the text is truncated and contains more than just the ellipsis truncated_text = browser.find_element( @@ -261,8 +261,8 @@ def test_officer_form_has_units_alpha_sorted(mockdata, browser, server_port): # get the units from the DB in the sort we expect db_units_sorted = list( map( - lambda x: x.descrip, - db.session.query(Unit).order_by(Unit.descrip.asc()).all(), + lambda x: x.description, + db.session.query(Unit).order_by(Unit.description.asc()).all(), ) ) # the Select tag in the interface has a 'None' value at the start diff --git a/OpenOversight/tests/test_models.py b/OpenOversight/tests/test_models.py index 2df690db7..75add7f6a 100644 --- a/OpenOversight/tests/test_models.py +++ b/OpenOversight/tests/test_models.py @@ -69,7 +69,7 @@ def test_face_repr(mockdata): def test_unit_repr(mockdata): unit = Unit.query.first() - assert unit.__repr__() == "Unit: {}".format(unit.descrip) + assert unit.__repr__() == "Unit: {}".format(unit.description) def test_user_repr(mockdata): diff --git a/OpenOversight/tests/test_utils.py b/OpenOversight/tests/test_utils.py index 89e40a414..9bf427065 100644 --- a/OpenOversight/tests/test_utils.py +++ b/OpenOversight/tests/test_utils.py @@ -312,7 +312,7 @@ def test_filter_by_form_filter_unit( mockdata, units, has_officers_with_unit, has_officers_with_no_unit ): form_data = dict(unit=units) - unit_id = Unit.query.filter_by(descrip="Donut Devourers").one().id + unit_id = Unit.query.filter_by(description="Donut Devourers").one().id department_id = Department.query.first().id officers = filter_by_form(form_data, Officer.query, department_id).all() diff --git a/docs/bulk_upload.rst b/docs/bulk_upload.rst index af3244660..e7ceb40dc 100644 --- a/docs/bulk_upload.rst +++ b/docs/bulk_upload.rst @@ -38,7 +38,7 @@ The csv file can have the following fields: star_no job_title unit_id - star_date + start_date resign_date salary salary_year @@ -74,7 +74,7 @@ Current Employment information: - ``job_title`` rank or title, needs to be added to this department verbatim or ``Not Sure`` - ``unit_id`` id of unit within the department -- ``star_date`` (sic) start date of this assignment +- ``start_date`` start date of this assignment - ``resign_date`` resignation date of this assignment Salary information: @@ -91,7 +91,7 @@ Required fields - ``department_id``, ``first_name``, ``last_name``, ``job_title`` and either ``star_no`` or ``unique_internal_identifier`` are required. -- ``employment_date``, ``star_date`` and ``resign_date`` can be either +- ``employment_date``, ``start_date`` and ``resign_date`` can be either in ``yyyy-mm-dd`` or ``mm/dd/yyyy`` format - if the column is present the field cannot be left blank From 9c2cea08ba2c9d24d671b93e99c9b7915df9a973 Mon Sep 17 00:00:00 2001 From: abandoned-prototype <41744410+abandoned-prototype@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:29:16 -0500 Subject: [PATCH 109/137] Rename migration files (#979) ## Description of Changes Renaming old migration files to follow new naming schema and adding messages where they are missing. ## Tests and linting - [x] This branch is up-to-date with the `develop` branch. - [x] `pytest` passes on my local development environment. - [x] `pre-commit` passes on my local development environment. --- ...9f_.py => 2017-12-10-0512_114919b27a9f_initial_migration.py} | 2 +- ...12-23-1812_42233d18ac7b_change_type_of_star_no_to_string.py} | 2 +- ...-04-12-1504_af933dc1ef93_add_preferred_department_column.py} | 2 +- ...018-04-30-1504_e14a1aa4b58f_add_area_coordinator_columns.py} | 2 +- ...505_0acbb0f0b1ef_change_datetime_to_date_officer_details.py} | 2 +- ...> 2018-05-04-2105_6065d7cdcbf8_add_officers_to_incidents.py} | 2 +- ..._.py => 2018-05-04-2105_d86feb8fa5d1_add_incidents_table.py} | 2 +- ...-10-2005_ca95c047bf42_add_department_column_to_incidents.py} | 2 +- ...-15-1705_f4a41e328a06_add_columns_to_incidents_and_links.py} | 2 +- ...05_bd0398fe4aab_rename_user_id_to_creator_id_in_incident.py} | 2 +- ... 2018-06-04-1906_59e9993c169c_change_faces_to_thumbnails.py} | 0 ...0_.py => 2018-06-06-1906_2040f0c804b0_create_notes_table.py} | 2 +- ...py => 2018-06-07-1506_4a490771dda1_add_original_image_fk.py} | 0 ...-06-07-1806_8ce7926aa132_add_explicit_ondelete_for_notes.py} | 2 +- ...-1806_cfc5f3fd5efe_rename_user_id_to_creator_id_in_notes.py} | 2 +- ...18-07-26-1807_7bb53dee8ac9_add_suffix_column_to_officers.py} | 0 ...2018-08-11-2008_0ed957db0058_add_description_to_officers.py} | 0 ...08_9e2827dae28c_add_cascading_foreign_key_constraint_to_.py} | 0 ...-18-1308_2c27bfebe66e_add_unique_internal_identifier_to_.py} | 0 ...el_fix.py => 2019-01-07-2101_e2c2efde8b55_face_model_fix.py} | 0 ...approved.py => 2019-01-24-1501_5c5b80cab45e_add_approved.py} | 0 ...le.py => 2019-01-25-1501_770ed51b4e16_add_salaries_table.py} | 0 ... 2019-02-03-0502_2a9064a2507c_remove_dots_middle_initial.py} | 0 ..._links.py => 2019-03-15-2203_86eb228e4bc0_refactor_links.py} | 0 ...7-1804_8ce3de7679c2_add_unique_internal_identifier_label.py} | 0 ..._table.py => 2019-04-20-1704_3015d1dd9eb4_add_jobs_table.py} | 0 ...19-04-24-1904_c1fc26073f85_rank_rename_po_police_officer.py} | 0 ...0505_6045f42587ec_split_apart_date_and_time_in_incidents.py} | 0 ...obs.py => 2020-04-24-0104_562bd5f1bc1f_add_order_to_jobs.py} | 0 ...07-13-0207_cd39b33b5360_constrain_officer_gender_options.py} | 0 ...07-31-1407_79a14685454f_add_featured_flag_to_faces_table.py} | 0 ...y => 2022-01-05-0601_93fc3e074dcc_remove_link_length_cap.py} | 0 32 files changed, 13 insertions(+), 13 deletions(-) rename OpenOversight/migrations/versions/{114919b27a9f_.py => 2017-12-10-0512_114919b27a9f_initial_migration.py} (98%) rename OpenOversight/migrations/versions/{42233d18ac7b_.py => 2017-12-23-1812_42233d18ac7b_change_type_of_star_no_to_string.py} (96%) rename OpenOversight/migrations/versions/{af933dc1ef93_.py => 2018-04-12-1504_af933dc1ef93_add_preferred_department_column.py} (95%) rename OpenOversight/migrations/versions/{e14a1aa4b58f_.py => 2018-04-30-1504_e14a1aa4b58f_add_area_coordinator_columns.py} (96%) rename OpenOversight/migrations/versions/{0acbb0f0b1ef_.py => 2018-05-03-1505_0acbb0f0b1ef_change_datetime_to_date_officer_details.py} (97%) rename OpenOversight/migrations/versions/{6065d7cdcbf8_.py => 2018-05-04-2105_6065d7cdcbf8_add_officers_to_incidents.py} (98%) rename OpenOversight/migrations/versions/{d86feb8fa5d1_.py => 2018-05-04-2105_d86feb8fa5d1_add_incidents_table.py} (99%) rename OpenOversight/migrations/versions/{ca95c047bf42_.py => 2018-05-10-2005_ca95c047bf42_add_department_column_to_incidents.py} (95%) rename OpenOversight/migrations/versions/{f4a41e328a06_.py => 2018-05-15-1705_f4a41e328a06_add_columns_to_incidents_and_links.py} (97%) rename OpenOversight/migrations/versions/{bd0398fe4aab_.py => 2018-05-15-1905_bd0398fe4aab_rename_user_id_to_creator_id_in_incident.py} (96%) rename OpenOversight/migrations/versions/{59e9993c169c_change_faces_to_thumbnails.py => 2018-06-04-1906_59e9993c169c_change_faces_to_thumbnails.py} (100%) rename OpenOversight/migrations/versions/{2040f0c804b0_.py => 2018-06-06-1906_2040f0c804b0_create_notes_table.py} (98%) rename OpenOversight/migrations/versions/{4a490771dda1_add_original_image_fk.py => 2018-06-07-1506_4a490771dda1_add_original_image_fk.py} (100%) rename OpenOversight/migrations/versions/{8ce7926aa132_.py => 2018-06-07-1806_8ce7926aa132_add_explicit_ondelete_for_notes.py} (97%) rename OpenOversight/migrations/versions/{cfc5f3fd5efe_.py => 2018-06-07-1806_cfc5f3fd5efe_rename_user_id_to_creator_id_in_notes.py} (96%) rename OpenOversight/migrations/versions/{7bb53dee8ac9_add_suffix_column_to_officers.py => 2018-07-26-1807_7bb53dee8ac9_add_suffix_column_to_officers.py} (100%) rename OpenOversight/migrations/versions/{0ed957db0058_add_description_to_officers.py => 2018-08-11-2008_0ed957db0058_add_description_to_officers.py} (100%) rename OpenOversight/migrations/versions/{9e2827dae28c_.py => 2018-08-13-1308_9e2827dae28c_add_cascading_foreign_key_constraint_to_.py} (100%) rename OpenOversight/migrations/versions/{2c27bfebe66e_add_unique_internal_identifier_to_.py => 2018-08-18-1308_2c27bfebe66e_add_unique_internal_identifier_to_.py} (100%) rename OpenOversight/migrations/versions/{e2c2efde8b55_face_model_fix.py => 2019-01-07-2101_e2c2efde8b55_face_model_fix.py} (100%) rename OpenOversight/migrations/versions/{5c5b80cab45e_add_approved.py => 2019-01-24-1501_5c5b80cab45e_add_approved.py} (100%) rename OpenOversight/migrations/versions/{770ed51b4e16_add_salaries_table.py => 2019-01-25-1501_770ed51b4e16_add_salaries_table.py} (100%) rename OpenOversight/migrations/versions/{2a9064a2507c_remove_dots_middle_initial.py => 2019-02-03-0502_2a9064a2507c_remove_dots_middle_initial.py} (100%) rename OpenOversight/migrations/versions/{86eb228e4bc0_refactor_links.py => 2019-03-15-2203_86eb228e4bc0_refactor_links.py} (100%) rename OpenOversight/migrations/versions/{8ce3de7679c2_add_unique_internal_identifier_label.py => 2019-04-17-1804_8ce3de7679c2_add_unique_internal_identifier_label.py} (100%) rename OpenOversight/migrations/versions/{3015d1dd9eb4_add_jobs_table.py => 2019-04-20-1704_3015d1dd9eb4_add_jobs_table.py} (100%) rename OpenOversight/migrations/versions/{c1fc26073f85_rank_rename_po_police_officer.py => 2019-04-24-1904_c1fc26073f85_rank_rename_po_police_officer.py} (100%) rename OpenOversight/migrations/versions/{6045f42587ec_split_apart_date_and_time_in_incidents.py => 2019-05-04-0505_6045f42587ec_split_apart_date_and_time_in_incidents.py} (100%) rename OpenOversight/migrations/versions/{562bd5f1bc1f_add_order_to_jobs.py => 2020-04-24-0104_562bd5f1bc1f_add_order_to_jobs.py} (100%) rename OpenOversight/migrations/versions/{cd39b33b5360_constrain_officer_gender_options.py => 2020-07-13-0207_cd39b33b5360_constrain_officer_gender_options.py} (100%) rename OpenOversight/migrations/versions/{79a14685454f_add_featured_flag_to_faces_table.py => 2020-07-31-1407_79a14685454f_add_featured_flag_to_faces_table.py} (100%) rename OpenOversight/migrations/versions/{93fc3e074dcc_remove_link_length_cap.py => 2022-01-05-0601_93fc3e074dcc_remove_link_length_cap.py} (100%) diff --git a/OpenOversight/migrations/versions/114919b27a9f_.py b/OpenOversight/migrations/versions/2017-12-10-0512_114919b27a9f_initial_migration.py similarity index 98% rename from OpenOversight/migrations/versions/114919b27a9f_.py rename to OpenOversight/migrations/versions/2017-12-10-0512_114919b27a9f_initial_migration.py index f43e7afbd..52e017df9 100644 --- a/OpenOversight/migrations/versions/114919b27a9f_.py +++ b/OpenOversight/migrations/versions/2017-12-10-0512_114919b27a9f_initial_migration.py @@ -1,4 +1,4 @@ -"""empty message +"""initial migration Revision ID: 114919b27a9f Revises: diff --git a/OpenOversight/migrations/versions/42233d18ac7b_.py b/OpenOversight/migrations/versions/2017-12-23-1812_42233d18ac7b_change_type_of_star_no_to_string.py similarity index 96% rename from OpenOversight/migrations/versions/42233d18ac7b_.py rename to OpenOversight/migrations/versions/2017-12-23-1812_42233d18ac7b_change_type_of_star_no_to_string.py index cced08c05..fc785b34b 100644 --- a/OpenOversight/migrations/versions/42233d18ac7b_.py +++ b/OpenOversight/migrations/versions/2017-12-23-1812_42233d18ac7b_change_type_of_star_no_to_string.py @@ -1,4 +1,4 @@ -"""empty message +"""change type of star_no to string Revision ID: 42233d18ac7b Revises: 114919b27a9f diff --git a/OpenOversight/migrations/versions/af933dc1ef93_.py b/OpenOversight/migrations/versions/2018-04-12-1504_af933dc1ef93_add_preferred_department_column.py similarity index 95% rename from OpenOversight/migrations/versions/af933dc1ef93_.py rename to OpenOversight/migrations/versions/2018-04-12-1504_af933dc1ef93_add_preferred_department_column.py index fff6fdbd1..8fda29751 100644 --- a/OpenOversight/migrations/versions/af933dc1ef93_.py +++ b/OpenOversight/migrations/versions/2018-04-12-1504_af933dc1ef93_add_preferred_department_column.py @@ -1,4 +1,4 @@ -"""empty message +"""add preferred department column Revision ID: af933dc1ef93 Revises: 42233d18ac7b diff --git a/OpenOversight/migrations/versions/e14a1aa4b58f_.py b/OpenOversight/migrations/versions/2018-04-30-1504_e14a1aa4b58f_add_area_coordinator_columns.py similarity index 96% rename from OpenOversight/migrations/versions/e14a1aa4b58f_.py rename to OpenOversight/migrations/versions/2018-04-30-1504_e14a1aa4b58f_add_area_coordinator_columns.py index 6c889a8f4..7c94a5ba1 100644 --- a/OpenOversight/migrations/versions/e14a1aa4b58f_.py +++ b/OpenOversight/migrations/versions/2018-04-30-1504_e14a1aa4b58f_add_area_coordinator_columns.py @@ -1,4 +1,4 @@ -"""empty message +"""add area coordinator columns Revision ID: e14a1aa4b58f Revises: af933dc1ef93 diff --git a/OpenOversight/migrations/versions/0acbb0f0b1ef_.py b/OpenOversight/migrations/versions/2018-05-03-1505_0acbb0f0b1ef_change_datetime_to_date_officer_details.py similarity index 97% rename from OpenOversight/migrations/versions/0acbb0f0b1ef_.py rename to OpenOversight/migrations/versions/2018-05-03-1505_0acbb0f0b1ef_change_datetime_to_date_officer_details.py index 5878d9b95..87046337d 100644 --- a/OpenOversight/migrations/versions/0acbb0f0b1ef_.py +++ b/OpenOversight/migrations/versions/2018-05-03-1505_0acbb0f0b1ef_change_datetime_to_date_officer_details.py @@ -1,4 +1,4 @@ -"""empty message +"""change datetime to date officer details Revision ID: 0acbb0f0b1ef Revises: af933dc1ef93 diff --git a/OpenOversight/migrations/versions/6065d7cdcbf8_.py b/OpenOversight/migrations/versions/2018-05-04-2105_6065d7cdcbf8_add_officers_to_incidents.py similarity index 98% rename from OpenOversight/migrations/versions/6065d7cdcbf8_.py rename to OpenOversight/migrations/versions/2018-05-04-2105_6065d7cdcbf8_add_officers_to_incidents.py index 1b53315ac..caf341023 100644 --- a/OpenOversight/migrations/versions/6065d7cdcbf8_.py +++ b/OpenOversight/migrations/versions/2018-05-04-2105_6065d7cdcbf8_add_officers_to_incidents.py @@ -1,4 +1,4 @@ -"""empty message +"""add officers to incidents Revision ID: 6065d7cdcbf8 Revises: d86feb8fa5d1 diff --git a/OpenOversight/migrations/versions/d86feb8fa5d1_.py b/OpenOversight/migrations/versions/2018-05-04-2105_d86feb8fa5d1_add_incidents_table.py similarity index 99% rename from OpenOversight/migrations/versions/d86feb8fa5d1_.py rename to OpenOversight/migrations/versions/2018-05-04-2105_d86feb8fa5d1_add_incidents_table.py index 8d49f254a..20f7c4478 100644 --- a/OpenOversight/migrations/versions/d86feb8fa5d1_.py +++ b/OpenOversight/migrations/versions/2018-05-04-2105_d86feb8fa5d1_add_incidents_table.py @@ -1,4 +1,4 @@ -"""empty message +"""add incidents table Revision ID: d86feb8fa5d1 Revises: e14a1aa4b58f diff --git a/OpenOversight/migrations/versions/ca95c047bf42_.py b/OpenOversight/migrations/versions/2018-05-10-2005_ca95c047bf42_add_department_column_to_incidents.py similarity index 95% rename from OpenOversight/migrations/versions/ca95c047bf42_.py rename to OpenOversight/migrations/versions/2018-05-10-2005_ca95c047bf42_add_department_column_to_incidents.py index df49bf939..ceb73edc2 100644 --- a/OpenOversight/migrations/versions/ca95c047bf42_.py +++ b/OpenOversight/migrations/versions/2018-05-10-2005_ca95c047bf42_add_department_column_to_incidents.py @@ -1,4 +1,4 @@ -"""empty message +"""add department column to incidents Revision ID: ca95c047bf42 Revises: 6065d7cdcbf8 diff --git a/OpenOversight/migrations/versions/f4a41e328a06_.py b/OpenOversight/migrations/versions/2018-05-15-1705_f4a41e328a06_add_columns_to_incidents_and_links.py similarity index 97% rename from OpenOversight/migrations/versions/f4a41e328a06_.py rename to OpenOversight/migrations/versions/2018-05-15-1705_f4a41e328a06_add_columns_to_incidents_and_links.py index c7783084c..a1fa0b1b6 100644 --- a/OpenOversight/migrations/versions/f4a41e328a06_.py +++ b/OpenOversight/migrations/versions/2018-05-15-1705_f4a41e328a06_add_columns_to_incidents_and_links.py @@ -1,4 +1,4 @@ -"""empty message +"""add columns to incidents and links Revision ID: f4a41e328a06 Revises: ca95c047bf42 diff --git a/OpenOversight/migrations/versions/bd0398fe4aab_.py b/OpenOversight/migrations/versions/2018-05-15-1905_bd0398fe4aab_rename_user_id_to_creator_id_in_incident.py similarity index 96% rename from OpenOversight/migrations/versions/bd0398fe4aab_.py rename to OpenOversight/migrations/versions/2018-05-15-1905_bd0398fe4aab_rename_user_id_to_creator_id_in_incident.py index a82bee411..ddefaeeec 100644 --- a/OpenOversight/migrations/versions/bd0398fe4aab_.py +++ b/OpenOversight/migrations/versions/2018-05-15-1905_bd0398fe4aab_rename_user_id_to_creator_id_in_incident.py @@ -1,4 +1,4 @@ -"""empty message +"""rename user_id to creator_id in incidents Revision ID: bd0398fe4aab Revises: f4a41e328a06 diff --git a/OpenOversight/migrations/versions/59e9993c169c_change_faces_to_thumbnails.py b/OpenOversight/migrations/versions/2018-06-04-1906_59e9993c169c_change_faces_to_thumbnails.py similarity index 100% rename from OpenOversight/migrations/versions/59e9993c169c_change_faces_to_thumbnails.py rename to OpenOversight/migrations/versions/2018-06-04-1906_59e9993c169c_change_faces_to_thumbnails.py diff --git a/OpenOversight/migrations/versions/2040f0c804b0_.py b/OpenOversight/migrations/versions/2018-06-06-1906_2040f0c804b0_create_notes_table.py similarity index 98% rename from OpenOversight/migrations/versions/2040f0c804b0_.py rename to OpenOversight/migrations/versions/2018-06-06-1906_2040f0c804b0_create_notes_table.py index 5e905adad..5198e605e 100644 --- a/OpenOversight/migrations/versions/2040f0c804b0_.py +++ b/OpenOversight/migrations/versions/2018-06-06-1906_2040f0c804b0_create_notes_table.py @@ -1,4 +1,4 @@ -"""empty message +"""create notes table Revision ID: 2040f0c804b0 Revises: bd0398fe4aab diff --git a/OpenOversight/migrations/versions/4a490771dda1_add_original_image_fk.py b/OpenOversight/migrations/versions/2018-06-07-1506_4a490771dda1_add_original_image_fk.py similarity index 100% rename from OpenOversight/migrations/versions/4a490771dda1_add_original_image_fk.py rename to OpenOversight/migrations/versions/2018-06-07-1506_4a490771dda1_add_original_image_fk.py diff --git a/OpenOversight/migrations/versions/8ce7926aa132_.py b/OpenOversight/migrations/versions/2018-06-07-1806_8ce7926aa132_add_explicit_ondelete_for_notes.py similarity index 97% rename from OpenOversight/migrations/versions/8ce7926aa132_.py rename to OpenOversight/migrations/versions/2018-06-07-1806_8ce7926aa132_add_explicit_ondelete_for_notes.py index a8c7e106f..a081a7775 100644 --- a/OpenOversight/migrations/versions/8ce7926aa132_.py +++ b/OpenOversight/migrations/versions/2018-06-07-1806_8ce7926aa132_add_explicit_ondelete_for_notes.py @@ -1,4 +1,4 @@ -"""empty message +"""add explicit ondelete for notes Revision ID: 8ce7926aa132 Revises: cfc5f3fd5efe diff --git a/OpenOversight/migrations/versions/cfc5f3fd5efe_.py b/OpenOversight/migrations/versions/2018-06-07-1806_cfc5f3fd5efe_rename_user_id_to_creator_id_in_notes.py similarity index 96% rename from OpenOversight/migrations/versions/cfc5f3fd5efe_.py rename to OpenOversight/migrations/versions/2018-06-07-1806_cfc5f3fd5efe_rename_user_id_to_creator_id_in_notes.py index bcebbe874..2a8f27dc0 100644 --- a/OpenOversight/migrations/versions/cfc5f3fd5efe_.py +++ b/OpenOversight/migrations/versions/2018-06-07-1806_cfc5f3fd5efe_rename_user_id_to_creator_id_in_notes.py @@ -1,4 +1,4 @@ -"""empty message +"""rename user_id to creator_id in notes Revision ID: cfc5f3fd5efe Revises: 2040f0c804b0 diff --git a/OpenOversight/migrations/versions/7bb53dee8ac9_add_suffix_column_to_officers.py b/OpenOversight/migrations/versions/2018-07-26-1807_7bb53dee8ac9_add_suffix_column_to_officers.py similarity index 100% rename from OpenOversight/migrations/versions/7bb53dee8ac9_add_suffix_column_to_officers.py rename to OpenOversight/migrations/versions/2018-07-26-1807_7bb53dee8ac9_add_suffix_column_to_officers.py diff --git a/OpenOversight/migrations/versions/0ed957db0058_add_description_to_officers.py b/OpenOversight/migrations/versions/2018-08-11-2008_0ed957db0058_add_description_to_officers.py similarity index 100% rename from OpenOversight/migrations/versions/0ed957db0058_add_description_to_officers.py rename to OpenOversight/migrations/versions/2018-08-11-2008_0ed957db0058_add_description_to_officers.py diff --git a/OpenOversight/migrations/versions/9e2827dae28c_.py b/OpenOversight/migrations/versions/2018-08-13-1308_9e2827dae28c_add_cascading_foreign_key_constraint_to_.py similarity index 100% rename from OpenOversight/migrations/versions/9e2827dae28c_.py rename to OpenOversight/migrations/versions/2018-08-13-1308_9e2827dae28c_add_cascading_foreign_key_constraint_to_.py diff --git a/OpenOversight/migrations/versions/2c27bfebe66e_add_unique_internal_identifier_to_.py b/OpenOversight/migrations/versions/2018-08-18-1308_2c27bfebe66e_add_unique_internal_identifier_to_.py similarity index 100% rename from OpenOversight/migrations/versions/2c27bfebe66e_add_unique_internal_identifier_to_.py rename to OpenOversight/migrations/versions/2018-08-18-1308_2c27bfebe66e_add_unique_internal_identifier_to_.py diff --git a/OpenOversight/migrations/versions/e2c2efde8b55_face_model_fix.py b/OpenOversight/migrations/versions/2019-01-07-2101_e2c2efde8b55_face_model_fix.py similarity index 100% rename from OpenOversight/migrations/versions/e2c2efde8b55_face_model_fix.py rename to OpenOversight/migrations/versions/2019-01-07-2101_e2c2efde8b55_face_model_fix.py diff --git a/OpenOversight/migrations/versions/5c5b80cab45e_add_approved.py b/OpenOversight/migrations/versions/2019-01-24-1501_5c5b80cab45e_add_approved.py similarity index 100% rename from OpenOversight/migrations/versions/5c5b80cab45e_add_approved.py rename to OpenOversight/migrations/versions/2019-01-24-1501_5c5b80cab45e_add_approved.py diff --git a/OpenOversight/migrations/versions/770ed51b4e16_add_salaries_table.py b/OpenOversight/migrations/versions/2019-01-25-1501_770ed51b4e16_add_salaries_table.py similarity index 100% rename from OpenOversight/migrations/versions/770ed51b4e16_add_salaries_table.py rename to OpenOversight/migrations/versions/2019-01-25-1501_770ed51b4e16_add_salaries_table.py diff --git a/OpenOversight/migrations/versions/2a9064a2507c_remove_dots_middle_initial.py b/OpenOversight/migrations/versions/2019-02-03-0502_2a9064a2507c_remove_dots_middle_initial.py similarity index 100% rename from OpenOversight/migrations/versions/2a9064a2507c_remove_dots_middle_initial.py rename to OpenOversight/migrations/versions/2019-02-03-0502_2a9064a2507c_remove_dots_middle_initial.py diff --git a/OpenOversight/migrations/versions/86eb228e4bc0_refactor_links.py b/OpenOversight/migrations/versions/2019-03-15-2203_86eb228e4bc0_refactor_links.py similarity index 100% rename from OpenOversight/migrations/versions/86eb228e4bc0_refactor_links.py rename to OpenOversight/migrations/versions/2019-03-15-2203_86eb228e4bc0_refactor_links.py diff --git a/OpenOversight/migrations/versions/8ce3de7679c2_add_unique_internal_identifier_label.py b/OpenOversight/migrations/versions/2019-04-17-1804_8ce3de7679c2_add_unique_internal_identifier_label.py similarity index 100% rename from OpenOversight/migrations/versions/8ce3de7679c2_add_unique_internal_identifier_label.py rename to OpenOversight/migrations/versions/2019-04-17-1804_8ce3de7679c2_add_unique_internal_identifier_label.py diff --git a/OpenOversight/migrations/versions/3015d1dd9eb4_add_jobs_table.py b/OpenOversight/migrations/versions/2019-04-20-1704_3015d1dd9eb4_add_jobs_table.py similarity index 100% rename from OpenOversight/migrations/versions/3015d1dd9eb4_add_jobs_table.py rename to OpenOversight/migrations/versions/2019-04-20-1704_3015d1dd9eb4_add_jobs_table.py diff --git a/OpenOversight/migrations/versions/c1fc26073f85_rank_rename_po_police_officer.py b/OpenOversight/migrations/versions/2019-04-24-1904_c1fc26073f85_rank_rename_po_police_officer.py similarity index 100% rename from OpenOversight/migrations/versions/c1fc26073f85_rank_rename_po_police_officer.py rename to OpenOversight/migrations/versions/2019-04-24-1904_c1fc26073f85_rank_rename_po_police_officer.py diff --git a/OpenOversight/migrations/versions/6045f42587ec_split_apart_date_and_time_in_incidents.py b/OpenOversight/migrations/versions/2019-05-04-0505_6045f42587ec_split_apart_date_and_time_in_incidents.py similarity index 100% rename from OpenOversight/migrations/versions/6045f42587ec_split_apart_date_and_time_in_incidents.py rename to OpenOversight/migrations/versions/2019-05-04-0505_6045f42587ec_split_apart_date_and_time_in_incidents.py diff --git a/OpenOversight/migrations/versions/562bd5f1bc1f_add_order_to_jobs.py b/OpenOversight/migrations/versions/2020-04-24-0104_562bd5f1bc1f_add_order_to_jobs.py similarity index 100% rename from OpenOversight/migrations/versions/562bd5f1bc1f_add_order_to_jobs.py rename to OpenOversight/migrations/versions/2020-04-24-0104_562bd5f1bc1f_add_order_to_jobs.py diff --git a/OpenOversight/migrations/versions/cd39b33b5360_constrain_officer_gender_options.py b/OpenOversight/migrations/versions/2020-07-13-0207_cd39b33b5360_constrain_officer_gender_options.py similarity index 100% rename from OpenOversight/migrations/versions/cd39b33b5360_constrain_officer_gender_options.py rename to OpenOversight/migrations/versions/2020-07-13-0207_cd39b33b5360_constrain_officer_gender_options.py diff --git a/OpenOversight/migrations/versions/79a14685454f_add_featured_flag_to_faces_table.py b/OpenOversight/migrations/versions/2020-07-31-1407_79a14685454f_add_featured_flag_to_faces_table.py similarity index 100% rename from OpenOversight/migrations/versions/79a14685454f_add_featured_flag_to_faces_table.py rename to OpenOversight/migrations/versions/2020-07-31-1407_79a14685454f_add_featured_flag_to_faces_table.py diff --git a/OpenOversight/migrations/versions/93fc3e074dcc_remove_link_length_cap.py b/OpenOversight/migrations/versions/2022-01-05-0601_93fc3e074dcc_remove_link_length_cap.py similarity index 100% rename from OpenOversight/migrations/versions/93fc3e074dcc_remove_link_length_cap.py rename to OpenOversight/migrations/versions/2022-01-05-0601_93fc3e074dcc_remove_link_length_cap.py From 273adb2aa7d044c3b372a86a49bfa287bdd8f882 Mon Sep 17 00:00:00 2001 From: Michael Plunkett <5885605+michplunkett@users.noreply.github.com> Date: Mon, 24 Jul 2023 10:17:15 -0500 Subject: [PATCH 110/137] Change `timestamp` datatypes to `timestamptz` (#978) ## Fixes issue https://github.com/lucyparsons/OpenOversight/issues/323 ## Description of Changes Standardizing column names and changing `timestamp` datatypes to `timestamptz`. To make sure the times were presented accurately, I created a function that was called before any other ones to set the `TIMEZONE` in the session object. From there, I used a filter that applies the user's timezone to any dates that are presented. This session object goes away after `60` minutes of no requests, though that value is configurable should someone to change it. The solution is essentially based off these two solutions: - https://stackoverflow.com/a/75271114 - https://stackoverflow.com/a/49891626 Column name changes: - `descriptions`: `date_created` -> `created_at`, `date_updated` -> `updated_at` - `notes`: `date_created` -> `created_at`, `date_updated` -> `updated_at` - `raw_images`: `date_image_inserted`: `created_at`, `date_image_taken`: `taken_at` As a `timestamp`: Screenshot 2023-07-19 at 1 45 05 PM As a `timestamptz`: Screenshot 2023-07-19 at 1 52 46 PM As a `timestamp` (testing the `downgrade` command): Screenshot 2023-07-19 at 1 53 06 PM ## Screenshots Description and Note `created_at` confirmation: Screenshot 2023-07-20 at 5 46 46 PM `descriptions.updated_at`: `2023-07-19 19:08:54.587185 +00:00` -> `July 19, 2023 2:08 PM` `notes.updated_at`: `2023-07-19 19:06:51.464656 +00:00` -> `July 19, 2023 2:06 PM` What it will actually look like: Screenshot 2023-07-20 at 5 55 19 PM ## Tests and linting - [x] This branch is up-to-date with the `develop` branch. - [x] `pytest` passes on my local development environment. - [x] `pre-commit` passes on my local development environment. - [x] Data-migration output:
    Column Name Ouput ```shell $ docker exec -it openoversight-web-1 bash $ flask db stamp head [2023-07-18 20:22:15,836] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. $ flask db migrate -m "standarize datetime field names" [2023-07-18 20:27:19,780] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. ... INFO [alembic.autogenerate.compare] Detected added column 'descriptions.created_at' INFO [alembic.autogenerate.compare] Detected added column 'descriptions.updated_at' INFO [alembic.autogenerate.compare] Detected removed column 'descriptions.date_updated' INFO [alembic.autogenerate.compare] Detected removed column 'descriptions.date_created' INFO [alembic.autogenerate.compare] Detected added column 'notes.created_at' INFO [alembic.autogenerate.compare] Detected added column 'notes.updated_at' INFO [alembic.autogenerate.compare] Detected removed column 'notes.date_updated' INFO [alembic.autogenerate.compare] Detected removed column 'notes.date_created' INFO [alembic.autogenerate.compare] Detected added column 'raw_images.created_at' INFO [alembic.autogenerate.compare] Detected added column 'raw_images.taken_at' INFO [alembic.autogenerate.compare] Detected removed index 'ix_raw_images_date_image_inserted' on 'raw_images' INFO [alembic.autogenerate.compare] Detected removed index 'ix_raw_images_date_image_taken' on 'raw_images' INFO [alembic.autogenerate.compare] Detected added index 'ix_raw_images_created_at' on '['created_at']' INFO [alembic.autogenerate.compare] Detected added index 'ix_raw_images_taken_at' on '['taken_at']' INFO [alembic.autogenerate.compare] Detected removed column 'raw_images.date_image_inserted' INFO [alembic.autogenerate.compare] Detected removed column 'raw_images.date_image_taken' Generating /usr/src/app/OpenOversight/migrations/versions/2023-07-18-2027_07ace5f956ca_standarize_datetime_field_names.py ... done $ flask db upgrade [2023-07-18 20:34:18,812] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running upgrade eb0266dc8588 -> 07ace5f956ca, standardize datetime field names $ ```
    Datatype Output ```shell % docker exec -it openoversight-web-1 bash $ flask db stamp head /usr/local/lib/python3.11/site-packages/flask_limiter/extension.py:293: UserWarning: Using the in-memory storage for tracking rate limits as no storage was explicitly specified. This is not recommended for production use. See: https://flask-limiter.readthedocs.io#configuring-a-storage-backend for documentation about configuring the storage backend. warnings.warn( [2023-07-19 18:49:21,592] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running stamp_revision -> eb0266dc8588 $ flask db upgrade [2023-07-19 18:49:46,272] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running upgrade eb0266dc8588 -> 07ace5f956ca, standardize datetime field names INFO [alembic.runtime.migration] Running upgrade 07ace5f956ca -> 1931b987ce0d, convert timestamp to timestamptz $ flask db downgrade [2023-07-19 18:51:59,673] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running downgrade 1931b987ce0d -> 07ace5f956ca, convert timestamp to timestamptz $ flask db upgrade [2023-07-19 18:52:28,084] INFO in __init__: OpenOversight startup INFO [alembic.runtime.migration] Context impl PostgresqlImpl. INFO [alembic.runtime.migration] Will assume transactional DDL. INFO [alembic.runtime.migration] Running upgrade 07ace5f956ca -> 1931b987ce0d, convert timestamp to timestamptz $ ```
    --------- Co-authored-by: abandoned-prototype <41744410+abandoned-prototype@users.noreply.github.com> --- CONTRIB.md | 2 + OpenOversight/app/__init__.py | 37 +---- OpenOversight/app/filters.py | 65 +++++++++ OpenOversight/app/main/__init__.py | 2 +- OpenOversight/app/main/downloads.py | 4 +- OpenOversight/app/main/model_view.py | 4 +- OpenOversight/app/main/views.py | 21 ++- OpenOversight/app/models/config.py | 3 +- OpenOversight/app/models/database.py | 36 +++-- OpenOversight/app/templates/base.html | 7 + OpenOversight/app/templates/image.html | 8 +- .../partials/officer_descriptions.html | 2 +- .../app/templates/partials/officer_notes.html | 2 +- OpenOversight/app/utils/cloud.py | 4 +- OpenOversight/app/utils/constants.py | 3 + OpenOversight/app/utils/forms.py | 16 +-- OpenOversight/app/utils/general.py | 2 +- ...f956ca_standardize_datetime_field_names.py | 89 ++++++++++++ ...87ce0d_convert_timestamp_to_timestamptz.py | 129 ++++++++++++++++++ OpenOversight/tests/conftest.py | 8 +- .../tests/routes/test_descriptions.py | 66 ++++----- OpenOversight/tests/routes/test_notes.py | 62 ++++----- OpenOversight/tests/routes/test_other.py | 18 ++- OpenOversight/tests/test_models.py | 4 +- docker-compose.yml | 3 + requirements.txt | 1 + 26 files changed, 457 insertions(+), 141 deletions(-) create mode 100644 OpenOversight/app/filters.py create mode 100644 OpenOversight/migrations/versions/2023-07-18-2027_07ace5f956ca_standardize_datetime_field_names.py create mode 100644 OpenOversight/migrations/versions/2023-07-19-1638_1931b987ce0d_convert_timestamp_to_timestamptz.py diff --git a/CONTRIB.md b/CONTRIB.md index 4b20ecf83..18d294f44 100644 --- a/CONTRIB.md +++ b/CONTRIB.md @@ -107,6 +107,8 @@ In the event that you need to create or delete the test data, you can do that wi or `$ python test_data.py --cleanup` to delete the data +Within the database we use [`timestamptz`](https://stackoverflow.com/a/48069726) fields for timestamps. To make sure that you are setting timestamps in the correct timezone, set the environment variable `TIMEZONE` to your respective [Olson-style timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#list) so that you can make sure any DST conversions are handled by PostgreSQL. + ### Migrating the Database You'll first have to start the Docker instance for the OpenOversight app using the command `make start`. To do this, you'll need to be in the base folder of the repository (the one that houses the `Makefile`). diff --git a/OpenOversight/app/__init__.py b/OpenOversight/app/__init__.py index 4538914a5..d447fc8cf 100644 --- a/OpenOversight/app/__init__.py +++ b/OpenOversight/app/__init__.py @@ -1,12 +1,8 @@ -import datetime import logging import os from http import HTTPStatus from logging.handlers import RotatingFileHandler -import bleach -import markdown as _markdown -from bleach_allowlist import markdown_attrs, markdown_tags from flask import Flask, jsonify, render_template, request from flask_bootstrap import Bootstrap from flask_limiter import Limiter @@ -15,9 +11,9 @@ from flask_migrate import Migrate from flask_sitemap import Sitemap from flask_wtf.csrf import CSRFProtect -from markupsafe import Markup from OpenOversight.app.email_client import EmailClient +from OpenOversight.app.filters import instantiate_filters from OpenOversight.app.models.config import config from OpenOversight.app.models.database import db from OpenOversight.app.utils.constants import MEGABYTE, SERVICE_ACCOUNT_FILE @@ -57,11 +53,11 @@ def create_app(config_name="default"): login_manager.init_app(app) sitemap.init_app(app) - from .main import main as main_blueprint + from OpenOversight.app.main import main as main_blueprint app.register_blueprint(main_blueprint) - from .auth import auth as auth_blueprint + from OpenOversight.app.auth import auth as auth_blueprint app.register_blueprint(auth_blueprint, url_prefix="/auth") @@ -121,35 +117,14 @@ def _handler_method(e): # Pass generated errorhandler function to @app.errorhandler decorator app.errorhandler(code)(create_errorhandler(code, error, template)) - # create jinja2 filter for titles with multiple capitals - @app.template_filter("capfirst") - def capfirst_filter(s): - return s[0].capitalize() + s[1:] # only change 1st letter - - @app.template_filter("get_age") - def get_age_from_birth_year(birth_year): - if birth_year: - return int(datetime.datetime.now().year - birth_year) - - @app.template_filter("field_in_query") - def field_in_query(form_data, field): - """ - Determine if a field is specified in the form data, and if so return a Bootstrap - class which will render the field accordion open. - """ - return " in " if form_data.get(field) else "" - - @app.template_filter("markdown") - def markdown(text): - text = text.replace("\n", " \n") # make markdown not ignore new lines. - html = bleach.clean(_markdown.markdown(text), markdown_tags, markdown_attrs) - return Markup(html) + # Instantiate filters + instantiate_filters(app) # Add commands Migrate( app, db, os.path.join(os.path.dirname(__file__), "..", "migrations") ) # Adds 'db' command - from .commands import ( + from OpenOversight.app.commands import ( add_department, add_job_title, advanced_csv_import, diff --git a/OpenOversight/app/filters.py b/OpenOversight/app/filters.py new file mode 100644 index 000000000..e8f1098a8 --- /dev/null +++ b/OpenOversight/app/filters.py @@ -0,0 +1,65 @@ +"""Contains all templates filters.""" +import datetime + +import bleach +import markdown as _markdown +import pytz as pytz +from bleach_allowlist import markdown_attrs, markdown_tags +from flask import Flask, session +from markupsafe import Markup + +from OpenOversight.app.utils.constants import KEY_TIMEZONE + + +def instantiate_filters(app: Flask): + """Instantiate all template filters""" + + def get_timezone(): + """Return the applicable timezone for the filter.""" + return ( + session[KEY_TIMEZONE] + if KEY_TIMEZONE in session + else app.config.get(KEY_TIMEZONE) + ) + + @app.template_filter("capfirst") + def capfirst_filter(s): + return s[0].capitalize() + s[1:] # only change 1st letter + + @app.template_filter("get_age") + def get_age_from_birth_year(birth_year): + if birth_year: + return int( + datetime.datetime.now(pytz.timezone(get_timezone())).year - birth_year + ) + + @app.template_filter("field_in_query") + def field_in_query(form_data, field): + """ + Determine if a field is specified in the form data, and if so return a Bootstrap + class which will render the field accordion open. + """ + return " in " if form_data.get(field) else "" + + @app.template_filter("markdown") + def markdown(text): + text = text.replace("\n", " \n") # make markdown not ignore new lines. + html = bleach.clean(_markdown.markdown(text), markdown_tags, markdown_attrs) + return Markup(html) + + @app.template_filter("local_date") + def local_date(value): + """Convert UTC datetime.datetime into a localized date string.""" + return value.astimezone(pytz.timezone(get_timezone())).strftime("%b %d, %Y") + + @app.template_filter("local_date_time") + def local_date_time(value): + """Convert UTC datetime.datetime into a localized date time string.""" + return value.astimezone(pytz.timezone(get_timezone())).strftime( + "%I:%M %p on %b %d, %Y" + ) + + @app.template_filter("local_time") + def local_time(value): + """Convert UTC datetime.datetime into a localized time string.""" + return value.astimezone(pytz.timezone(get_timezone())).strftime("%I:%M %p") diff --git a/OpenOversight/app/main/__init__.py b/OpenOversight/app/main/__init__.py index 360c10c91..34232f053 100644 --- a/OpenOversight/app/main/__init__.py +++ b/OpenOversight/app/main/__init__.py @@ -3,4 +3,4 @@ main = Blueprint("main", __name__) -from . import views # noqa: E402,F401 +from OpenOversight.app.main import views # noqa: E402,F401 diff --git a/OpenOversight/app/main/downloads.py b/OpenOversight/app/main/downloads.py index dc689f3cc..599ebfe28 100644 --- a/OpenOversight/app/main/downloads.py +++ b/OpenOversight/app/main/downloads.py @@ -161,6 +161,6 @@ def descriptions_record_maker(description: Description) -> _Record: "text_contents": description.text_contents, "creator_id": description.creator_id, "officer_id": description.officer_id, - "date_created": description.date_created, - "date_updated": description.date_updated, + "created_at": description.created_at, + "updated_at": description.updated_at, } diff --git a/OpenOversight/app/main/model_view.py b/OpenOversight/app/main/model_view.py index 5271d5838..48cc40890 100644 --- a/OpenOversight/app/main/model_view.py +++ b/OpenOversight/app/main/model_view.py @@ -166,8 +166,8 @@ def get_department_id(self, obj): def populate_obj(self, form, obj): form.populate_obj(obj) - if hasattr(obj, "date_updated"): - obj.date_updated = datetime.datetime.now() + if hasattr(obj, "updated_at"): + obj.updated_at = datetime.datetime.now() db.session.add(obj) db.session.commit() diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 388a0f2f8..50366c3ab 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -6,6 +6,7 @@ from traceback import format_exc from flask import ( + Response, abort, current_app, flash, @@ -73,6 +74,7 @@ ) from OpenOversight.app.utils.auth import ac_or_admin_required, admin_required from OpenOversight.app.utils.cloud import crop_image, upload_image_to_s3_and_store_in_db +from OpenOversight.app.utils.constants import ENCODING_UTF_8, KEY_TIMEZONE from OpenOversight.app.utils.db import ( add_department_query, add_unit_query, @@ -134,6 +136,17 @@ def index(): return render_template("index.html") +@main.route("/timezone", methods=[HTTPMethod.POST]) +def set_session_timezone(): + if KEY_TIMEZONE not in session: + session.permanent = True + timezone = request.data.decode(ENCODING_UTF_8) + session[KEY_TIMEZONE] = ( + timezone if timezone != "" else current_app.config.get(KEY_TIMEZONE) + ) + return Response("User timezone saved", status=HTTPStatus.OK) + + @sitemap_include @main.route("/browse", methods=[HTTPMethod.GET]) def browse(): @@ -147,7 +160,7 @@ def get_officer(): js_loads = ["js/find_officer.js"] form = FindOfficerForm() - departments_dict = [dept_choice.toCustomDict() for dept_choice in dept_choices()] + departments_dict = [dept_choice.to_custom_dict() for dept_choice in dept_choices()] if getattr(current_user, "dept_pref_rel", None): set_dynamic_default(form.dept, current_user.dept_pref_rel) @@ -1338,8 +1351,8 @@ def download_dept_descriptions_csv(department_id): "text_contents", "creator_id", "officer_id", - "date_created", - "date_updated", + "created_at", + "updated_at", ] return make_downloadable_csv( notes, department_id, "Notes", field_names, descriptions_record_maker @@ -1637,7 +1650,7 @@ def sitemap_incidents(): class TextApi(ModelView): - order_by = "date_created" + order_by = "created_at" descending = True department_check = True form = TextForm diff --git a/OpenOversight/app/models/config.py b/OpenOversight/app/models/config.py index f1de76e05..20ccb2f44 100644 --- a/OpenOversight/app/models/config.py +++ b/OpenOversight/app/models/config.py @@ -1,6 +1,6 @@ import os -from OpenOversight.app.utils.constants import MEGABYTE +from OpenOversight.app.utils.constants import KEY_TIMEZONE, MEGABYTE basedir = os.path.abspath(os.path.dirname(__file__)) @@ -12,6 +12,7 @@ def __init__(self): self.DEBUG = False self.ENV = os.environ.get("ENV", "development") self.SEED = 666 + self.TIMEZONE = os.environ.get(KEY_TIMEZONE, "America/Chicago") self.TESTING = False # Use session cookie to store URL to redirect to after login # https://flask-login.readthedocs.io/en/latest/#customizing-the-login-process diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index 3b80c5189..fdaebb306 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -9,6 +9,7 @@ from flask_sqlalchemy.model import DefaultMeta from sqlalchemy import CheckConstraint, UniqueConstraint, func from sqlalchemy.orm import validates +from sqlalchemy.sql import func as sql_func from werkzeug.security import check_password_hash, generate_password_hash from OpenOversight.app.utils.constants import ENCODING_UTF_8 @@ -49,7 +50,7 @@ class Department(BaseModel): def __repr__(self): return "".format(self.id, self.name) - def toCustomDict(self): + def to_custom_dict(self): return { "id": self.id, "name": self.name, @@ -90,8 +91,13 @@ class Note(BaseModel): creator = db.relationship("User", backref="notes") officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) officer = db.relationship("Officer", back_populates="notes") - date_created = db.Column(db.DateTime) - date_updated = db.Column(db.DateTime) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + updated_at = db.Column(db.DateTime(timezone=True), unique=False) class Description(BaseModel): @@ -103,8 +109,13 @@ class Description(BaseModel): text_contents = db.Column(db.Text()) creator_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) - date_created = db.Column(db.DateTime) - date_updated = db.Column(db.DateTime) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + updated_at = db.Column(db.DateTime(timezone=True), unique=False) class Officer(BaseModel): @@ -132,10 +143,10 @@ class Officer(BaseModel): "Link", secondary=officer_links, backref=db.backref("officers", lazy=True) ) notes = db.relationship( - "Note", back_populates="officer", order_by="Note.date_created" + "Note", back_populates="officer", order_by="Note.created_at" ) descriptions = db.relationship( - "Description", back_populates="officer", order_by="Description.date_created" + "Description", back_populates="officer", order_by="Description.created_at" ) salaries = db.relationship( "Salary", back_populates="officer", order_by="Salary.year.desc()" @@ -324,12 +335,17 @@ class Image(BaseModel): hash_img = db.Column(db.String(120), unique=False, nullable=True) # Track when the image was put into our database - date_image_inserted = db.Column( - db.DateTime, index=True, unique=False, nullable=True + created_at = db.Column( + db.DateTime(timezone=True), + index=True, + unique=False, + server_default=sql_func.now(), ) # We might know when the image was taken e.g. through EXIF data - date_image_taken = db.Column(db.DateTime, index=True, unique=False, nullable=True) + taken_at = db.Column( + db.DateTime(timezone=True), index=True, unique=False, nullable=True + ) contains_cops = db.Column(db.Boolean, nullable=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) diff --git a/OpenOversight/app/templates/base.html b/OpenOversight/app/templates/base.html index 00eef61e0..91c64430c 100644 --- a/OpenOversight/app/templates/base.html +++ b/OpenOversight/app/templates/base.html @@ -16,6 +16,13 @@ OpenOversight - a Lucy Parsons Labs project {% endblock title %} + {% if 'TIMEZONE' not in session %} + + {% endif %} diff --git a/OpenOversight/app/templates/image.html b/OpenOversight/app/templates/image.html index d19e5979f..6f94df7df 100644 --- a/OpenOversight/app/templates/image.html +++ b/OpenOversight/app/templates/image.html @@ -31,8 +31,8 @@

    Metadata

    Date image inserted - {% if image.date_image_inserted %} - {{ image.date_image_inserted }} + {% if image.created_at %} + {{ image.created_at | local_date_time }} {% else %} Not provided {% endif %} @@ -43,8 +43,8 @@

    Metadata

    Date image taken - {% if image.date_image_taken %} - {{ image.date_image_taken }} + {% if image.taken_at %} + {{ image.taken_at | local_date_time }} {% else %} Not provided {% endif %} diff --git a/OpenOversight/app/templates/partials/officer_descriptions.html b/OpenOversight/app/templates/partials/officer_descriptions.html index ee6d80beb..08208d6d4 100644 --- a/OpenOversight/app/templates/partials/officer_descriptions.html +++ b/OpenOversight/app/templates/partials/officer_descriptions.html @@ -3,7 +3,7 @@

    Descriptions