• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lucyparsons / OpenOversight / 13504844618

24 Feb 2025 06:12PM UTC coverage: 89.074% (+0.003%) from 89.071%
13504844618

push

github

web-flow
UX Re-design (#1155)

8862 of 9949 relevant lines covered (89.07%)

0.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.01
OpenOversight/app/models/database.py
1
import itertools
1✔
2
import operator
1✔
3
import re
1✔
4
import time
1✔
5
import uuid
1✔
6
from datetime import date, datetime
1✔
7
from datetime import time as dt_time
1✔
8
from datetime import timezone
1✔
9
from typing import List, Optional
1✔
10

11
from authlib.jose import JoseError, JsonWebToken
1✔
12
from cachetools import cached
1✔
13
from flask import current_app
1✔
14
from flask_login import UserMixin
1✔
15
from flask_sqlalchemy import SQLAlchemy
1✔
16
from sqlalchemy import CheckConstraint, UniqueConstraint, func
1✔
17
from sqlalchemy.inspection import inspect
1✔
18
from sqlalchemy.orm import (
1✔
19
    DeclarativeMeta,
20
    contains_eager,
21
    declarative_mixin,
22
    declared_attr,
23
    joinedload,
24
    validates,
25
)
26
from sqlalchemy.sql import func as sql_func
1✔
27
from werkzeug.security import check_password_hash, generate_password_hash
1✔
28

29
from OpenOversight.app.models.database_cache import (
1✔
30
    DB_CACHE,
31
    get_database_cache_entry,
32
    model_cache_key,
33
    put_database_cache_entry,
34
    remove_database_cache_entries,
35
)
36
from OpenOversight.app.utils.choices import GENDER_CHOICES, RACE_CHOICES
1✔
37
from OpenOversight.app.utils.constants import (
1✔
38
    ENCODING_UTF_8,
39
    KEY_DB_CREATOR,
40
    KEY_DEPT_ALL_ASSIGNMENTS,
41
    KEY_DEPT_ALL_INCIDENTS,
42
    KEY_DEPT_ALL_LINKS,
43
    KEY_DEPT_ALL_NOTES,
44
    KEY_DEPT_ALL_OFFICERS,
45
    KEY_DEPT_ALL_SALARIES,
46
    KEY_DEPT_TOTAL_ASSIGNMENTS,
47
    KEY_DEPT_TOTAL_INCIDENTS,
48
    KEY_DEPT_TOTAL_OFFICERS,
49
    SIGNATURE_ALGORITHM,
50
)
51
from OpenOversight.app.validators import state_validator, url_validator
1✔
52

53

54
db = SQLAlchemy()
1✔
55
jwt = JsonWebToken(SIGNATURE_ALGORITHM)
1✔
56
Base: DeclarativeMeta = db.Model
1✔
57

58

59
class BaseModel(Base):
1✔
60
    __abstract__ = True
1✔
61

62
    EXCLUDED = [
1✔
63
        "approved_at",
64
        "approved_by",
65
        "confirmed_at",
66
        "confirmed_by",
67
        "created_at",
68
        "created_by",
69
        "disabled_at",
70
        "disabled_by",
71
        "password_hash",
72
        "last_updated_at",
73
        "last_updated_by",
74
    ]
75

76
    def __repr__(self) -> str:
1✔
77
        """Convert model to a string that contains all values needed for recreation."""
78
        ret_str = f"<{self.__class__.__name__} ("
1✔
79
        for column in inspect(self).mapper.column_attrs:
1✔
80
            if column.key in self.EXCLUDED or column.key.startswith("_"):
1✔
81
                continue
1✔
82

83
            if ret_str[-1] != "(":
1✔
84
                ret_str += " : "
1✔
85

86
            value = getattr(self, column.key)
1✔
87
            if isinstance(value, (date, datetime)):
1✔
88
                ret_str += f"{column.key}: {value.isoformat()}"
1✔
89
            elif isinstance(value, date):
1✔
90
                ret_str += f'{column.key}: {value.strftime("%Y-%m-%d")}'
×
91
            elif isinstance(value, dt_time):
1✔
92
                ret_str += f'{column.key}: {value.strftime("%I:%M %p")}'
×
93
            else:
94
                ret_str += f"{column.key}: {value}"
1✔
95

96
        return ret_str + ")>"
1✔
97

98
    def to_dict(self) -> dict:
1✔
99
        """Convert a generic model instance into a dictionary."""
100
        data = {}
1✔
101

102
        for column in inspect(self).mapper.column_attrs:
1✔
103
            if column.key in self.EXCLUDED or column.key.startswith("_"):
1✔
104
                continue
1✔
105

106
            value = getattr(self, column.key)
1✔
107
            if isinstance(value, (date, datetime)):
1✔
108
                data[column.key] = value.isoformat()
×
109
            elif isinstance(value, date):
1✔
110
                data[column.key] = value.strftime("%Y-%m-%d")
×
111
            elif isinstance(value, dt_time):
1✔
112
                data[column.key] = value.strftime("%I:%M %p")
×
113
            else:
114
                data[column.key] = value
1✔
115

116
        return data
1✔
117

118

119
officer_links = db.Table(
1✔
120
    "officer_links",
121
    db.Column(
122
        "officer_id",
123
        db.Integer,
124
        db.ForeignKey("officers.id", name="officer_links_officer_id_fkey"),
125
        primary_key=True,
126
    ),
127
    db.Column(
128
        "link_id",
129
        db.Integer,
130
        db.ForeignKey("links.id", name="officer_links_link_id_fkey"),
131
        primary_key=True,
132
    ),
133
    db.Column(
134
        "created_at",
135
        db.DateTime(timezone=True),
136
        nullable=False,
137
        server_default=sql_func.now(),
138
        unique=False,
139
    ),
140
)
141

142
officer_incidents = db.Table(
1✔
143
    "officer_incidents",
144
    db.Column(
145
        "officer_id",
146
        db.Integer,
147
        db.ForeignKey("officers.id", name="officer_incidents_officer_id_fkey"),
148
        primary_key=True,
149
    ),
150
    db.Column(
151
        "incident_id",
152
        db.Integer,
153
        db.ForeignKey("incidents.id", name="officer_incidents_incident_id_fkey"),
154
        primary_key=True,
155
    ),
156
    db.Column(
157
        "created_at",
158
        db.DateTime(timezone=True),
159
        nullable=False,
160
        server_default=sql_func.now(),
161
        unique=False,
162
    ),
163
)
164

165

166
@declarative_mixin
1✔
167
class TrackUpdates:
1✔
168
    """Add columns to track the date of and user who created and last modified
169
    the object.
170
    """
171

172
    created_at = db.Column(
1✔
173
        db.DateTime(timezone=True),
174
        nullable=False,
175
        server_default=sql_func.now(),
176
        unique=False,
177
    )
178
    last_updated_at = db.Column(
1✔
179
        db.DateTime(timezone=True),
180
        nullable=False,
181
        server_default=sql_func.now(),
182
        unique=False,
183
        onupdate=datetime.utcnow,
184
    )
185

186
    @declared_attr
1✔
187
    def created_by(cls):
1✔
188
        return db.Column(
1✔
189
            db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False
190
        )
191

192
    @declared_attr
1✔
193
    def last_updated_by(cls):
1✔
194
        return db.Column(
1✔
195
            db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False
196
        )
197

198
    @declared_attr
1✔
199
    def creator(cls):
1✔
200
        return db.relationship("User", foreign_keys=[cls.created_by])
1✔
201

202

203
class Department(BaseModel, TrackUpdates):
1✔
204
    __tablename__ = "departments"
1✔
205

206
    id = db.Column(db.Integer, primary_key=True)
1✔
207
    name = db.Column(db.String(255), index=False, unique=False, nullable=False)
1✔
208
    short_name = db.Column(db.String(100), unique=False, nullable=False)
1✔
209
    state = db.Column(db.String(2), server_default="", nullable=False)
1✔
210

211
    # See https://github.com/lucyparsons/OpenOversight/issues/462
212
    unique_internal_identifier_label = db.Column(
1✔
213
        db.String(100), unique=False, nullable=True
214
    )
215

216
    __table_args__ = (UniqueConstraint("name", "state", name="departments_name_state"),)
1✔
217

218
    @staticmethod
1✔
219
    def get_assignments(department_id: int) -> list["Assignment"]:
1✔
220
        cache_params = Department(id=department_id), KEY_DEPT_ALL_ASSIGNMENTS
1✔
221
        assignments = get_database_cache_entry(*cache_params)
1✔
222

223
        if assignments is None:
1✔
224
            assignments = (
1✔
225
                db.session.query(Assignment)
226
                .join(Assignment.base_officer)
227
                .filter(Officer.department_id == department_id)
228
                .options(contains_eager(Assignment.base_officer))
229
                .options(joinedload(Assignment.unit))
230
                .options(joinedload(Assignment.job))
231
                .all()
232
            )
233
            put_database_cache_entry(*cache_params, assignments)
1✔
234

235
        return assignments
1✔
236

237
    @staticmethod
1✔
238
    def get_descriptions(department_id: int) -> list["Description"]:
1✔
239
        cache_params = (Department(id=department_id), KEY_DEPT_ALL_NOTES)
1✔
240
        descriptions = get_database_cache_entry(*cache_params)
1✔
241

242
        if descriptions is None:
1✔
243
            descriptions = (
1✔
244
                db.session.query(Description)
245
                .join(Description.officer)
246
                .filter(Officer.department_id == department_id)
247
                .options(contains_eager(Description.officer))
248
                .all()
249
            )
250
            put_database_cache_entry(*cache_params, descriptions)
1✔
251

252
        return descriptions
1✔
253

254
    @staticmethod
1✔
255
    def get_incidents(department_id: int) -> list["Incident"]:
1✔
256
        cache_params = (Department(id=department_id), KEY_DEPT_ALL_INCIDENTS)
1✔
257
        incidents = get_database_cache_entry(*cache_params)
1✔
258

259
        if incidents is None:
1✔
260
            incidents = Incident.query.filter_by(department_id=department_id).all()
1✔
261
            put_database_cache_entry(*cache_params, incidents)
1✔
262

263
        return incidents
1✔
264

265
    @staticmethod
1✔
266
    def get_links(department_id: int) -> list["Link"]:
1✔
267
        cache_params = (Department(id=department_id), KEY_DEPT_ALL_LINKS)
1✔
268
        links = get_database_cache_entry(*cache_params)
1✔
269

270
        if links is None:
1✔
271
            links = (
1✔
272
                db.session.query(Link)
273
                .join(Link.officers)
274
                .filter(Officer.department_id == department_id)
275
                .options(contains_eager(Link.officers))
276
                .all()
277
            )
278
            put_database_cache_entry(*cache_params, links)
1✔
279

280
        return links
1✔
281

282
    @staticmethod
1✔
283
    def get_officers(department_id: int) -> list["Officer"]:
1✔
284
        cache_params = (Department(id=department_id), KEY_DEPT_ALL_OFFICERS)
1✔
285
        officers = get_database_cache_entry(*cache_params)
1✔
286

287
        if officers is None:
1✔
288
            officers = (
1✔
289
                db.session.query(Officer)
290
                .options(joinedload(Officer.assignments).joinedload(Assignment.job))
291
                .options(joinedload(Officer.salaries))
292
                .filter_by(department_id=department_id)
293
                .all()
294
            )
295
            put_database_cache_entry(*cache_params, officers)
1✔
296

297
        return officers
1✔
298

299
    @staticmethod
1✔
300
    def get_salaries(department_id: int) -> list["Salary"]:
1✔
301
        cache_params = (Department(id=department_id), KEY_DEPT_ALL_SALARIES)
1✔
302
        salaries = get_database_cache_entry(*cache_params)
1✔
303

304
        if salaries is None:
1✔
305
            salaries = (
1✔
306
                db.session.query(Salary)
307
                .join(Salary.officer)
308
                .filter(Officer.department_id == department_id)
309
                .options(contains_eager(Salary.officer))
310
                .all()
311
            )
312
            put_database_cache_entry(*cache_params, salaries)
1✔
313

314
        return salaries
1✔
315

316
    @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_ASSIGNMENTS))
1✔
317
    def total_documented_assignments(self) -> int:
1✔
318
        return (
1✔
319
            db.session.query(Assignment.id)
320
            .join(Officer, Assignment.officer_id == Officer.id)
321
            .filter(Officer.department_id == self.id)
322
            .count()
323
        )
324

325
    @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_INCIDENTS))
1✔
326
    def total_documented_incidents(self) -> int:
1✔
327
        return (
1✔
328
            db.session.query(Incident).filter(Incident.department_id == self.id).count()
329
        )
330

331
    @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_OFFICERS))
1✔
332
    def total_documented_officers(self) -> int:
1✔
333
        return (
1✔
334
            db.session.query(Officer).filter(Officer.department_id == self.id).count()
335
        )
336

337
    def remove_database_cache_entries(self, update_types: List[str]) -> None:
1✔
338
        """Remove the Department model key from the cache if it exists."""
339
        remove_database_cache_entries(self, update_types)
1✔
340

341
    @staticmethod
1✔
342
    def by_state() -> dict[str, list["Department"]]:
1✔
343
        departments = Department.query.filter(Department.officers.any()).order_by(
1✔
344
            Department.state.asc(), Department.name.asc()
345
        )
346
        departments_by_state = {
1✔
347
            state: list(group)
348
            for state, group in itertools.groupby(departments, lambda d: d.state)
349
        }
350
        return departments_by_state
1✔
351

352

353
class Job(BaseModel, TrackUpdates):
1✔
354
    __tablename__ = "jobs"
1✔
355

356
    id = db.Column(db.Integer, primary_key=True)
1✔
357
    job_title = db.Column(db.String(255), index=True, unique=False, nullable=False)
1✔
358
    is_sworn_officer = db.Column(db.Boolean, index=True, default=True)
1✔
359
    order = db.Column(db.Integer, index=True, unique=False, nullable=False)
1✔
360
    department_id = db.Column(
1✔
361
        db.Integer, db.ForeignKey("departments.id", name="jobs_department_id_fkey")
362
    )
363
    department = db.relationship(
1✔
364
        "Department", backref=db.backref("jobs", cascade_backrefs=False)
365
    )
366

367
    __table_args__ = (
1✔
368
        UniqueConstraint(
369
            "job_title", "department_id", name="unique_department_job_titles"
370
        ),
371
    )
372

373
    def __str__(self):
1✔
374
        return self.job_title
1✔
375

376

377
class Note(BaseModel, TrackUpdates):
1✔
378
    __tablename__ = "notes"
1✔
379

380
    id = db.Column(db.Integer, primary_key=True)
1✔
381
    text_contents = db.Column(db.Text())
1✔
382
    officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE"))
1✔
383
    officer = db.relationship("Officer", back_populates="notes")
1✔
384

385

386
class Description(BaseModel, TrackUpdates):
1✔
387
    __tablename__ = "descriptions"
1✔
388

389
    id = db.Column(db.Integer, primary_key=True)
1✔
390
    text_contents = db.Column(db.Text())
1✔
391
    officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE"))
1✔
392
    officer = db.relationship("Officer", back_populates="descriptions")
1✔
393

394

395
class Officer(BaseModel, TrackUpdates):
1✔
396
    __tablename__ = "officers"
1✔
397

398
    id = db.Column(db.Integer, primary_key=True)
1✔
399
    last_name = db.Column(db.String(120), index=True, unique=False)
1✔
400
    first_name = db.Column(db.String(120), index=True, unique=False)
1✔
401
    middle_initial = db.Column(db.String(120), unique=False, nullable=True)
1✔
402
    suffix = db.Column(db.String(120), index=True, unique=False)
1✔
403
    race = db.Column(db.String(120), index=True, unique=False)
1✔
404
    gender = db.Column(db.String(5), index=True, unique=False, nullable=True)
1✔
405
    employment_date = db.Column(db.Date, index=True, unique=False, nullable=True)
1✔
406
    birth_year = db.Column(db.Integer, index=True, unique=False, nullable=True)
1✔
407
    assignments = db.relationship(
1✔
408
        "Assignment", back_populates="base_officer", cascade_backrefs=False
409
    )
410
    face = db.relationship(
1✔
411
        "Face", backref=db.backref("officer", cascade_backrefs=False)
412
    )
413
    department_id = db.Column(
1✔
414
        db.Integer, db.ForeignKey("departments.id", name="officers_department_id_fkey")
415
    )
416
    department = db.relationship(
1✔
417
        "Department", backref=db.backref("officers", cascade_backrefs=False)
418
    )
419
    unique_internal_identifier = db.Column(
1✔
420
        db.String(50), index=True, unique=True, nullable=True
421
    )
422

423
    links = db.relationship(
1✔
424
        "Link",
425
        secondary=officer_links,
426
        backref=db.backref("officers", lazy=True, cascade_backrefs=False),
427
        lazy=True,
428
    )
429
    notes = db.relationship(
1✔
430
        "Note",
431
        back_populates="officer",
432
        cascade_backrefs=False,
433
        order_by="Note.created_at",
434
    )
435
    descriptions = db.relationship(
1✔
436
        "Description",
437
        back_populates="officer",
438
        cascade_backrefs=False,
439
        order_by="Description.created_at",
440
    )
441
    salaries = db.relationship(
1✔
442
        "Salary",
443
        back_populates="officer",
444
        cascade_backrefs=False,
445
        order_by="Salary.year.desc()",
446
    )
447

448
    __table_args__ = (
1✔
449
        CheckConstraint("gender in ('M', 'F', 'Other')", name="gender_options"),
450
    )
451

452
    def __repr__(self):
1✔
453
        if self.unique_internal_identifier:
1✔
454
            return (
1✔
455
                f"<Officer ID: {self.id} : {self.full_name()} "
456
                f"({self.unique_internal_identifier})>"
457
            )
458
        return f"<Officer ID: {self.id} : {self.full_name()}>"
1✔
459

460
    def full_name(self):
1✔
461
        if self.middle_initial:
1✔
462
            middle_initial = (
1✔
463
                self.middle_initial + "."
464
                if len(self.middle_initial) == 1
465
                else self.middle_initial
466
            )
467
            if self.suffix:
1✔
468
                return (
1✔
469
                    f"{self.first_name} {middle_initial} {self.last_name} {self.suffix}"
470
                )
471
            else:
472
                return f"{self.first_name} {middle_initial} {self.last_name}"
1✔
473
        if self.suffix:
1✔
474
            return f"{self.first_name} {self.last_name} {self.suffix}"
1✔
475
        return f"{self.first_name} {self.last_name}"
1✔
476

477
    def race_label(self):
1✔
478
        if self.race is None:
1✔
479
            return "Data Missing"
1✔
480

481
        for race, label in RACE_CHOICES:
1✔
482
            if self.race == race:
1✔
483
                return label
1✔
484

485
    def gender_label(self):
1✔
486
        if self.gender is None:
1✔
487
            return "Data Missing"
1✔
488

489
        for gender, label in GENDER_CHOICES:
1✔
490
            if self.gender == gender:
1✔
491
                return label
1✔
492

493
    def job_title(self):
1✔
494
        if self.assignments:
1✔
495
            return max(
1✔
496
                self.assignments, key=operator.attrgetter("start_date_or_min")
497
            ).job.job_title
498

499
    def unit_description(self):
1✔
500
        if self.assignments:
1✔
501
            unit = max(
1✔
502
                self.assignments, key=operator.attrgetter("start_date_or_min")
503
            ).unit
504
            return unit.description if unit else None
1✔
505

506
    def badge_number(self):
1✔
507
        if self.assignments:
1✔
508
            return max(
1✔
509
                self.assignments, key=operator.attrgetter("start_date_or_min")
510
            ).star_no
511

512
    def currently_on_force(self):
1✔
513
        if self.assignments:
1✔
514
            most_recent = max(
1✔
515
                self.assignments, key=operator.attrgetter("start_date_or_min")
516
            )
517
            return "Yes" if most_recent.resign_date is None else "No"
1✔
518
        return "Uncertain"
1✔
519

520

521
class Salary(BaseModel, TrackUpdates):
1✔
522
    __tablename__ = "salaries"
1✔
523

524
    id = db.Column(db.Integer, primary_key=True)
1✔
525
    officer_id = db.Column(
1✔
526
        db.Integer,
527
        db.ForeignKey(
528
            "officers.id", name="salaries_officer_id_fkey", ondelete="CASCADE"
529
        ),
530
    )
531
    officer = db.relationship("Officer", back_populates="salaries")
1✔
532
    salary = db.Column(db.Float, index=True, unique=False, nullable=False)
1✔
533
    overtime_pay = db.Column(db.Float, index=True, unique=False, nullable=True)
1✔
534
    year = db.Column(db.Integer, index=True, unique=False, nullable=False)
1✔
535
    is_fiscal_year = db.Column(db.Boolean, index=False, unique=False, nullable=False)
1✔
536

537
    @property
1✔
538
    def total_pay(self) -> float:
1✔
539
        return self.salary + self.overtime_pay
1✔
540

541
    @property
1✔
542
    def year_repr(self) -> str:
1✔
543
        if self.is_fiscal_year:
1✔
544
            return f"FY{self.year}"
1✔
545
        return str(self.year)
1✔
546

547

548
class Assignment(BaseModel, TrackUpdates):
1✔
549
    __tablename__ = "assignments"
1✔
550

551
    id = db.Column(db.Integer, primary_key=True)
1✔
552
    officer_id = db.Column(
1✔
553
        db.Integer,
554
        db.ForeignKey(
555
            "officers.id", name="assignments_officer_id_fkey", ondelete="CASCADE"
556
        ),
557
    )
558
    base_officer = db.relationship("Officer", back_populates="assignments")
1✔
559
    star_no = db.Column(db.String(120), index=True, unique=False, nullable=True)
1✔
560
    job_id = db.Column(
1✔
561
        db.Integer,
562
        db.ForeignKey("jobs.id", name="assignments_job_id_fkey"),
563
        nullable=False,
564
    )
565
    job = db.relationship("Job")
1✔
566
    unit_id = db.Column(
1✔
567
        db.Integer,
568
        db.ForeignKey("unit_types.id", name="assignments_unit_id_fkey"),
569
        nullable=True,
570
    )
571
    unit = db.relationship("Unit")
1✔
572
    start_date = db.Column(db.Date, index=True, unique=False, nullable=True)
1✔
573
    resign_date = db.Column(db.Date, index=True, unique=False, nullable=True)
1✔
574

575
    @property
1✔
576
    def start_date_or_min(self):
1✔
577
        return self.start_date or date.min
1✔
578

579
    @property
1✔
580
    def start_date_or_max(self):
1✔
581
        return self.start_date or date.max
1✔
582

583

584
class Unit(BaseModel, TrackUpdates):
1✔
585
    __tablename__ = "unit_types"
1✔
586

587
    id = db.Column(db.Integer, primary_key=True)
1✔
588
    description = db.Column(db.String(120), index=True, unique=False)
1✔
589
    department_id = db.Column(
1✔
590
        db.Integer,
591
        db.ForeignKey("departments.id", name="unit_types_department_id_fkey"),
592
    )
593
    department = db.relationship(
1✔
594
        "Department",
595
        backref=db.backref("unit_types", cascade_backrefs=False),
596
        order_by="Unit.description.asc()",
597
    )
598

599

600
class Face(BaseModel, TrackUpdates):
1✔
601
    __tablename__ = "faces"
1✔
602

603
    id = db.Column(db.Integer, primary_key=True)
1✔
604
    officer_id = db.Column(
1✔
605
        db.Integer, db.ForeignKey("officers.id", name="faces_officer_id_fkey")
606
    )
607
    img_id = db.Column(
1✔
608
        db.Integer,
609
        db.ForeignKey(
610
            "raw_images.id",
611
            ondelete="CASCADE",
612
            onupdate="CASCADE",
613
            name="fk_face_image_id",
614
            use_alter=True,
615
        ),
616
    )
617
    original_image_id = db.Column(
1✔
618
        db.Integer,
619
        db.ForeignKey(
620
            "raw_images.id",
621
            ondelete="SET NULL",
622
            onupdate="CASCADE",
623
            use_alter=True,
624
            name="fk_face_original_image_id",
625
        ),
626
    )
627
    face_position_x = db.Column(db.Integer, unique=False)
1✔
628
    face_position_y = db.Column(db.Integer, unique=False)
1✔
629
    face_width = db.Column(db.Integer, unique=False)
1✔
630
    face_height = db.Column(db.Integer, unique=False)
1✔
631
    image = db.relationship(
1✔
632
        "Image",
633
        backref=db.backref("faces", cascade_backrefs=False),
634
        foreign_keys=[img_id],
635
    )
636
    original_image = db.relationship(
1✔
637
        "Image",
638
        backref=db.backref("tags", cascade_backrefs=False),
639
        foreign_keys=[original_image_id],
640
        lazy=True,
641
    )
642
    featured = db.Column(
1✔
643
        db.Boolean, nullable=False, default=False, server_default="false"
644
    )
645

646
    __table_args__ = (UniqueConstraint("officer_id", "img_id", name="unique_faces"),)
1✔
647

648

649
class Image(BaseModel, TrackUpdates):
1✔
650
    __tablename__ = "raw_images"
1✔
651

652
    id = db.Column(db.Integer, primary_key=True)
1✔
653
    filepath = db.Column(db.String(255), unique=False)
1✔
654
    hash_img = db.Column(db.String(120), unique=False, nullable=True)
1✔
655

656
    # We might know when the image was taken e.g. through EXIF data
657
    taken_at = db.Column(
1✔
658
        db.DateTime(timezone=True), index=True, unique=False, nullable=True
659
    )
660
    contains_cops = db.Column(db.Boolean, nullable=True)
1✔
661

662
    is_tagged = db.Column(db.Boolean, default=False, unique=False, nullable=True)
1✔
663

664
    department_id = db.Column(
1✔
665
        db.Integer,
666
        db.ForeignKey("departments.id", name="raw_images_department_id_fkey"),
667
    )
668
    department = db.relationship(
1✔
669
        "Department", backref=db.backref("raw_images", cascade_backrefs=False)
670
    )
671

672

673
incident_links = db.Table(
1✔
674
    "incident_links",
675
    db.Column(
676
        "incident_id",
677
        db.Integer,
678
        db.ForeignKey("incidents.id", name="incident_links_incident_id_fkey"),
679
        primary_key=True,
680
    ),
681
    db.Column(
682
        "link_id",
683
        db.Integer,
684
        db.ForeignKey("links.id", name="incident_links_link_id_fkey"),
685
        primary_key=True,
686
    ),
687
    db.Column(
688
        "created_at",
689
        db.DateTime(timezone=True),
690
        nullable=False,
691
        server_default=sql_func.now(),
692
        unique=False,
693
    ),
694
)
695

696
incident_license_plates = db.Table(
1✔
697
    "incident_license_plates",
698
    db.Column(
699
        "incident_id",
700
        db.Integer,
701
        db.ForeignKey("incidents.id", name="incident_license_plates_incident_id_fkey"),
702
        primary_key=True,
703
    ),
704
    db.Column(
705
        "license_plate_id",
706
        db.Integer,
707
        db.ForeignKey(
708
            "license_plates.id", name="incident_license_plates_license_plate_id_fkey"
709
        ),
710
        primary_key=True,
711
    ),
712
    db.Column(
713
        "created_at",
714
        db.DateTime(timezone=True),
715
        nullable=False,
716
        server_default=sql_func.now(),
717
        unique=False,
718
    ),
719
)
720

721
incident_officers = db.Table(
1✔
722
    "incident_officers",
723
    db.Column(
724
        "incident_id",
725
        db.Integer,
726
        db.ForeignKey("incidents.id", name="incident_officers_incident_id_fkey"),
727
        primary_key=True,
728
    ),
729
    db.Column(
730
        "officers_id",
731
        db.Integer,
732
        db.ForeignKey("officers.id", name="incident_officers_officers_id_fkey"),
733
        primary_key=True,
734
    ),
735
    db.Column(
736
        "created_at",
737
        db.DateTime(timezone=True),
738
        nullable=False,
739
        server_default=sql_func.now(),
740
        unique=False,
741
    ),
742
)
743

744

745
class Location(BaseModel, TrackUpdates):
1✔
746
    __tablename__ = "locations"
1✔
747

748
    id = db.Column(db.Integer, primary_key=True)
1✔
749
    street_name = db.Column(db.String(100), index=True)
1✔
750
    cross_street1 = db.Column(db.String(100), unique=False)
1✔
751
    cross_street2 = db.Column(db.String(100), unique=False)
1✔
752
    city = db.Column(db.String(100), unique=False, index=True)
1✔
753
    state = db.Column(db.String(2), unique=False, index=True)
1✔
754
    zip_code = db.Column(db.String(5), unique=False, index=True)
1✔
755

756
    @validates("zip_code")
1✔
757
    def validate_zip_code(self, key, zip_code):
1✔
758
        if zip_code:
1✔
759
            zip_re = r"^\d{5}$"
1✔
760
            if not re.match(zip_re, zip_code):
1✔
761
                raise ValueError("Not a valid zip code")
1✔
762
            return zip_code
1✔
763

764
    @validates("state")
1✔
765
    def validate_state(self, key, state):
1✔
766
        return state_validator(state)
1✔
767

768
    def __repr__(self):
1✔
769
        if self.street_name and self.cross_street1 and self.cross_street2:
1✔
770
            return (
1✔
771
                f"Intersection of {self.street_name} between {self.cross_street1} "
772
                f"and {self.cross_street2}, {self.city} {self.state}"
773
            )
774
        elif self.street_name and self.cross_street2:
1✔
775
            return (
1✔
776
                f"Intersection of {self.street_name} and {self.cross_street2}, "
777
                + f"{self.city} {self.state}"
778
            )
779
        elif self.street_name and self.cross_street1:
1✔
780
            return (
1✔
781
                f"Intersection of {self.street_name} and {self.cross_street1}, "
782
                + f"{self.city} {self.state}"
783
            )
784
        else:
785
            return f"{self.city} {self.state}"
1✔
786

787

788
class LicensePlate(BaseModel, TrackUpdates):
1✔
789
    __tablename__ = "license_plates"
1✔
790

791
    id = db.Column(db.Integer, primary_key=True)
1✔
792
    number = db.Column(db.String(8), nullable=False, index=True)
1✔
793
    state = db.Column(db.String(2), index=True)
1✔
794

795
    # for use if car is federal, diplomat, or other non-state
796
    # non_state_identifier = db.Column(db.String(20), index=True)
797

798
    @validates("state")
1✔
799
    def validate_state(self, key, state):
1✔
800
        return state_validator(state)
1✔
801

802

803
class Link(BaseModel, TrackUpdates):
1✔
804
    __tablename__ = "links"
1✔
805

806
    id = db.Column(db.Integer, primary_key=True)
1✔
807
    title = db.Column(db.String(100), index=True)
1✔
808
    url = db.Column(db.Text(), nullable=False)
1✔
809
    link_type = db.Column(db.String(100), index=True)
1✔
810
    description = db.Column(db.Text(), nullable=True)
1✔
811
    author = db.Column(db.String(255), nullable=True)
1✔
812
    has_content_warning = db.Column(db.Boolean, nullable=False, default=False)
1✔
813

814
    @validates("url")
1✔
815
    def validate_url(self, key, url):
1✔
816
        return url_validator(url)
1✔
817

818

819
class Incident(BaseModel, TrackUpdates):
1✔
820
    __tablename__ = "incidents"
1✔
821

822
    id = db.Column(db.Integer, primary_key=True)
1✔
823
    date = db.Column(db.Date, unique=False, index=True)
1✔
824
    time = db.Column(db.Time, unique=False, index=True)
1✔
825
    report_number = db.Column(db.String(50), index=True)
1✔
826
    description = db.Column(db.Text(), nullable=True)
1✔
827
    address_id = db.Column(
1✔
828
        db.Integer, db.ForeignKey("locations.id", name="incidents_address_id_fkey")
829
    )
830
    address = db.relationship(
1✔
831
        "Location",
832
        backref=db.backref("incidents", cascade_backrefs=False),
833
        lazy="joined",
834
    )
835
    license_plates = db.relationship(
1✔
836
        "LicensePlate",
837
        secondary=incident_license_plates,
838
        lazy="subquery",
839
        backref=db.backref("incidents", cascade_backrefs=False, lazy=True),
840
    )
841
    links = db.relationship(
1✔
842
        "Link",
843
        secondary=incident_links,
844
        lazy="subquery",
845
        backref=db.backref("incidents", cascade_backrefs=False, lazy=True),
846
    )
847
    officers = db.relationship(
1✔
848
        "Officer",
849
        secondary=officer_incidents,
850
        lazy="subquery",
851
        backref=db.backref(
852
            "incidents",
853
            cascade_backrefs=False,
854
            order_by="Incident.date.desc(), Incident.time.desc()",
855
        ),
856
    )
857
    department_id = db.Column(
1✔
858
        db.Integer, db.ForeignKey("departments.id", name="incidents_department_id_fkey")
859
    )
860
    department = db.relationship(
1✔
861
        "Department", backref=db.backref("incidents", cascade_backrefs=False), lazy=True
862
    )
863

864

865
class User(UserMixin, BaseModel):
1✔
866
    __tablename__ = "users"
1✔
867

868
    id = db.Column(db.Integer, primary_key=True)
1✔
869

870
    # A universally unique identifier (UUID) that can be
871
    # used in place of the user's primary key for things like user
872
    # lookup queries.
873
    _uuid = db.Column(
1✔
874
        db.String(36),
875
        unique=True,
876
        nullable=False,
877
        index=True,
878
        default=lambda: str(uuid.uuid4()),
879
    )
880
    email = db.Column(db.String(64), unique=True, index=True)
1✔
881
    username = db.Column(db.String(64), unique=True, index=True)
1✔
882
    password_hash = db.Column(db.String(128))
1✔
883
    confirmed_at = db.Column(db.DateTime(timezone=True))
1✔
884
    confirmed_by = db.Column(
1✔
885
        db.Integer,
886
        db.ForeignKey("users.id", ondelete="SET NULL", name="users_confirmed_by_fkey"),
887
        unique=False,
888
    )
889
    approved_at = db.Column(db.DateTime(timezone=True))
1✔
890
    approved_by = db.Column(
1✔
891
        db.Integer,
892
        db.ForeignKey("users.id", ondelete="SET NULL", name="users_approved_by_fkey"),
893
        unique=False,
894
    )
895
    is_area_coordinator = db.Column(db.Boolean, default=False)
1✔
896
    ac_department_id = db.Column(
1✔
897
        db.Integer, db.ForeignKey("departments.id", name="users_ac_department_id_fkey")
898
    )
899
    ac_department = db.relationship(
1✔
900
        "Department",
901
        backref=db.backref("coordinators", cascade_backrefs=False),
902
        foreign_keys=[ac_department_id],
903
    )
904
    is_administrator = db.Column(db.Boolean, default=False)
1✔
905
    disabled_at = db.Column(db.DateTime(timezone=True))
1✔
906
    disabled_by = db.Column(
1✔
907
        db.Integer,
908
        db.ForeignKey("users.id", ondelete="SET NULL", name="users_disabled_by_fkey"),
909
        unique=False,
910
    )
911

912
    dept_pref = db.Column(
1✔
913
        db.Integer, db.ForeignKey("departments.id", name="users_dept_pref_fkey")
914
    )
915
    dept_pref_rel = db.relationship("Department", foreign_keys=[dept_pref])
1✔
916

917
    # creator backlinks
918
    classifications = db.relationship(
1✔
919
        "Image", back_populates=KEY_DB_CREATOR, foreign_keys="Image.created_by"
920
    )
921
    descriptions = db.relationship(
1✔
922
        "Description",
923
        back_populates=KEY_DB_CREATOR,
924
        foreign_keys="Description.created_by",
925
    )
926
    incidents_created = db.relationship(
1✔
927
        "Incident", back_populates=KEY_DB_CREATOR, foreign_keys="Incident.created_by"
928
    )
929
    links = db.relationship(
1✔
930
        "Link", back_populates=KEY_DB_CREATOR, foreign_keys="Link.created_by"
931
    )
932
    notes = db.relationship(
1✔
933
        "Note", back_populates=KEY_DB_CREATOR, foreign_keys="Note.created_by"
934
    )
935
    tags = db.relationship(
1✔
936
        "Face", back_populates=KEY_DB_CREATOR, foreign_keys="Face.created_by"
937
    )
938

939
    created_at = db.Column(
1✔
940
        db.DateTime(timezone=True),
941
        nullable=False,
942
        server_default=sql_func.now(),
943
        unique=False,
944
    )
945

946
    __table_args__ = (
1✔
947
        CheckConstraint(
948
            "(disabled_at IS NULL and disabled_by IS NULL) or (disabled_at IS NOT NULL and disabled_by IS NOT NULL)",
949
            name="users_disabled_constraint",
950
        ),
951
        CheckConstraint(
952
            "(confirmed_at IS NULL and confirmed_by IS NULL) or (confirmed_at IS NOT NULL and confirmed_by IS NOT NULL)",
953
            name="users_confirmed_constraint",
954
        ),
955
        CheckConstraint(
956
            "(approved_at IS NULL and approved_by IS NULL) or (approved_at IS NOT NULL and approved_by IS NOT NULL)",
957
            name="users_approved_constraint",
958
        ),
959
    )
960

961
    def is_admin_or_coordinator(self, department: Optional[Department]) -> bool:
1✔
962
        return self.is_administrator or (
1✔
963
            department is not None
964
            and (self.is_area_coordinator and self.ac_department_id == department.id)
965
        )
966

967
    def _jwt_encode(self, payload, expiration):
1✔
968
        secret = current_app.config["SECRET_KEY"]
1✔
969
        header = {"alg": SIGNATURE_ALGORITHM}
1✔
970

971
        now = int(time.time())
1✔
972
        payload["iat"] = now
1✔
973
        payload["exp"] = now + expiration
1✔
974

975
        return jwt.encode(header, payload, secret)
1✔
976

977
    def _jwt_decode(self, token):
1✔
978
        secret = current_app.config["SECRET_KEY"]
1✔
979
        token = jwt.decode(token, secret)
1✔
980
        token.validate()
1✔
981
        return token
1✔
982

983
    @property
1✔
984
    def password(self):
1✔
985
        raise AttributeError("password is not a readable attribute")
1✔
986

987
    # mypy has difficulty with mixins, specifically the ones where we define a function
988
    # twice.
989
    @password.setter  # type: ignore
1✔
990
    def password(self, password):  # type: ignore
1✔
991
        self.password_hash = generate_password_hash(password, method="pbkdf2:sha256")
1✔
992
        self.regenerate_uuid()
1✔
993

994
    @property
1✔
995
    def uuid(self):
1✔
996
        return self._uuid
1✔
997

998
    @staticmethod
1✔
999
    def _case_insensitive_equality(field, value):
1✔
1000
        return User.query.filter(func.lower(field) == func.lower(value))
1✔
1001

1002
    @staticmethod
1✔
1003
    def by_email(email):
1✔
1004
        return User._case_insensitive_equality(User.email, email)
1✔
1005

1006
    @staticmethod
1✔
1007
    def by_username(username):
1✔
1008
        return User._case_insensitive_equality(User.username, username)
1✔
1009

1010
    def verify_password(self, password):
1✔
1011
        return check_password_hash(self.password_hash, password)
1✔
1012

1013
    def generate_confirmation_token(self, expiration=3600):
1✔
1014
        payload = {"confirm": self.uuid}
1✔
1015
        return self._jwt_encode(payload, expiration).decode(ENCODING_UTF_8)
1✔
1016

1017
    def confirm(self, token, confirming_user_id: int):
1✔
1018
        try:
1✔
1019
            data = self._jwt_decode(token)
1✔
1020
        except JoseError as e:
1✔
1021
            current_app.logger.warning("failed to decrypt token: %s", e)
1✔
1022
            return False
1✔
1023
        if data.get("confirm") != self.uuid:
1✔
1024
            current_app.logger.warning(
1✔
1025
                "incorrect uuid here, expected %s, got %s",
1026
                data.get("confirm"),
1027
                self.uuid,
1028
            )
1029
            return False
1✔
1030
        self.confirmed_at = datetime.now(timezone.utc)
1✔
1031
        self.confirmed_by = confirming_user_id
1✔
1032
        db.session.add(self)
1✔
1033
        db.session.commit()
1✔
1034
        return True
1✔
1035

1036
    def generate_reset_token(self, expiration=3600):
1✔
1037
        payload = {"reset": self.uuid}
1✔
1038
        return self._jwt_encode(payload, expiration).decode(ENCODING_UTF_8)
1✔
1039

1040
    def reset_password(self, token, new_password):
1✔
1041
        try:
1✔
1042
            data = self._jwt_decode(token)
1✔
1043
        except JoseError:
1✔
1044
            return False
1✔
1045
        if data.get("reset") != self.uuid:
1✔
1046
            return False
1✔
1047
        self.password = new_password
1✔
1048
        db.session.add(self)
1✔
1049
        db.session.commit()
1✔
1050
        return True
1✔
1051

1052
    def generate_email_change_token(self, new_email, expiration=3600):
1✔
1053
        payload = {"change_email": self.uuid, "new_email": new_email}
1✔
1054
        return self._jwt_encode(payload, expiration).decode(ENCODING_UTF_8)
1✔
1055

1056
    def change_email(self, token):
1✔
1057
        try:
1✔
1058
            data = self._jwt_decode(token)
1✔
1059
        except JoseError:
1✔
1060
            return False
1✔
1061
        if data.get("change_email") != self.uuid:
1✔
1062
            return False
1✔
1063
        new_email = data.get("new_email")
1✔
1064
        if new_email is None:
1✔
1065
            return False
1✔
1066
        if self.query.filter_by(email=new_email).first() is not None:
1✔
1067
            return False
1✔
1068
        self.email = new_email
1✔
1069
        self.regenerate_uuid()
1✔
1070
        db.session.add(self)
1✔
1071
        db.session.commit()
1✔
1072
        return True
1✔
1073

1074
    def regenerate_uuid(self):
1✔
1075
        self._uuid = str(uuid.uuid4())
1✔
1076

1077
    def get_id(self):
1✔
1078
        """Get the Flask-Login user identifier, NOT THE DATABASE ID."""
1079
        return str(self.uuid)
1✔
1080

1081
    @property
1✔
1082
    def is_active(self):
1✔
1083
        """Override UserMixin.is_active to prevent disabled users from logging in."""
1084
        return not self.disabled_at
1✔
1085

1086
    def approve_user(self, approving_user_id: int):
1✔
1087
        """Handle approving logic."""
1088
        if self.approved_at or self.approved_by:
1✔
1089
            return False
1✔
1090

1091
        self.approved_at = datetime.now(timezone.utc)
1✔
1092
        self.approved_by = approving_user_id
1✔
1093
        db.session.add(self)
1✔
1094
        db.session.commit()
1✔
1095
        return True
1✔
1096

1097
    def confirm_user(self, confirming_user_id: int):
1✔
1098
        """Handle confirming logic."""
1099
        if self.confirmed_at or self.confirmed_by:
1✔
1100
            return False
1✔
1101

1102
        self.confirmed_at = datetime.now(timezone.utc)
1✔
1103
        self.confirmed_by = confirming_user_id
1✔
1104
        db.session.add(self)
1✔
1105
        db.session.commit()
1✔
1106
        return True
1✔
1107

1108
    def disable_user(self, disabling_user_id: int):
1✔
1109
        """Handle disabling logic."""
1110
        if self.disabled_at or self.disabled_by:
1✔
1111
            return False
1✔
1112

1113
        self.disabled_at = datetime.now(timezone.utc)
1✔
1114
        self.disabled_by = disabling_user_id
1✔
1115
        db.session.add(self)
1✔
1116
        db.session.commit()
1✔
1117
        return True
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc