diff --git a/OpenOversight/app/main/downloads.py b/OpenOversight/app/main/downloads.py index 8aead13ce..e055618ab 100644 --- a/OpenOversight/app/main/downloads.py +++ b/OpenOversight/app/main/downloads.py @@ -162,5 +162,5 @@ def descriptions_record_maker(description: Description) -> _Record: "created_by": description.created_by, "officer_id": description.officer_id, "created_at": description.created_at, - "updated_at": description.updated_at, + "last_updated_at": description.last_updated_at, } diff --git a/OpenOversight/app/main/model_view.py b/OpenOversight/app/main/model_view.py index 8166fa032..61c207836 100644 --- a/OpenOversight/app/main/model_view.py +++ b/OpenOversight/app/main/model_view.py @@ -80,8 +80,13 @@ def new(self, form=None): if form.validate_on_submit(): new_obj = self.create_function(form, current_user) + if hasattr(new_obj, "created_by"): + new_obj.created_by = current_user.get_id() + if hasattr(new_obj, "last_updated_by"): + new_obj.last_updated_by = current_user.get_id() db.session.add(new_obj) db.session.commit() + match self.model.__name__: case Incident.__name__: Department(id=new_obj.department_id).remove_database_cache_entries( @@ -117,20 +122,6 @@ def edit(self, obj_id, form=None): if not form: form = self.get_edit_form(obj) - # if the object doesn't have a creator id set it to current user - if ( - hasattr(obj, "created_by") - and hasattr(form, "created_by") - and getattr(obj, "created_by") - ): - form.created_by.data = obj.created_by - elif hasattr(form, "created_by"): - form.created_by.data = current_user.get_id() - - # if the object keeps track of who updated it last, set to current user - if hasattr(obj, "last_updated_by") and hasattr(form, "last_updated_by"): - form.last_updated_by.data = current_user.get_id() - form.last_updated_at.data = datetime.datetime.now() if hasattr(form, "department"): add_department_query(form, current_user) @@ -225,8 +216,15 @@ def get_department_id(self, obj): def populate_obj(self, form, obj): form.populate_obj(obj) - if hasattr(obj, "updated_at"): - obj.updated_at = datetime.datetime.now() + + # if the object doesn't have a creator id set it to current user + if hasattr(obj, "created_by") and not getattr(obj, "created_by"): + obj.created_by = current_user.get_id() + # if the object keeps track of who updated it last, set to current user + if hasattr(obj, "last_updated_at"): + obj.last_updated_at = datetime.datetime.now() + obj.last_updated_by = current_user.get_id() + db.session.add(obj) db.session.commit() diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 5e1fdcd67..cbedccc6a 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -1796,7 +1796,7 @@ def download_dept_descriptions_csv(department_id: int): "created_by", "officer_id", "created_at", - "updated_at", + "last_updated_at", ] return make_downloadable_csv( notes, department_id, "Notes", field_names, descriptions_record_maker diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index 35fd79afc..daf0e6958 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -9,8 +9,7 @@ from flask_login import UserMixin from flask_sqlalchemy import SQLAlchemy from sqlalchemy import CheckConstraint, UniqueConstraint, func -from sqlalchemy.ext.declarative import DeclarativeMeta -from sqlalchemy.orm import validates +from sqlalchemy.orm import DeclarativeMeta, declarative_mixin, declared_attr, validates from sqlalchemy.sql import func as sql_func from werkzeug.security import check_password_hash, generate_password_hash @@ -22,6 +21,7 @@ from OpenOversight.app.utils.choices import GENDER_CHOICES, RACE_CHOICES from OpenOversight.app.utils.constants import ( ENCODING_UTF_8, + KEY_DB_CREATOR, KEY_DEPT_TOTAL_ASSIGNMENTS, KEY_DEPT_TOTAL_INCIDENTS, KEY_DEPT_TOTAL_OFFICERS, @@ -63,22 +63,49 @@ ) -class Department(BaseModel): - __tablename__ = "departments" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(255), index=False, unique=False, nullable=False) - short_name = db.Column(db.String(100), unique=False, nullable=False) - state = db.Column(db.String(2), server_default="", nullable=False) +@declarative_mixin +class TrackUpdates: + """Add columns to track the date of and user who created and last modified + the object. + """ + created_at = db.Column( db.DateTime(timezone=True), nullable=False, server_default=sql_func.now(), unique=False, ) - created_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + last_updated_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, ) + @declared_attr + def created_by(cls): + return db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + + @declared_attr + def last_updated_by(cls): + return db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + + @declared_attr + def creator(cls): + return db.relationship("User", foreign_keys=[cls.created_by]) + + +class Department(BaseModel, TrackUpdates): + __tablename__ = "departments" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), index=False, unique=False, nullable=False) + short_name = db.Column(db.String(100), unique=False, nullable=False) + state = db.Column(db.String(2), server_default="", nullable=False) + # See https://github.com/lucyparsons/OpenOversight/issues/462 unique_internal_identifier_label = db.Column( db.String(100), unique=False, nullable=True @@ -128,7 +155,7 @@ def remove_database_cache_entries(self, update_types: List[str]) -> None: remove_database_cache_entries(self, update_types) -class Job(BaseModel): +class Job(BaseModel, TrackUpdates): __tablename__ = "jobs" id = db.Column(db.Integer, primary_key=True) @@ -137,15 +164,6 @@ class Job(BaseModel): 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") - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) - created_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False - ) __table_args__ = ( UniqueConstraint( @@ -160,47 +178,25 @@ def __str__(self): return self.job_title -class Note(BaseModel): +class Note(BaseModel, TrackUpdates): __tablename__ = "notes" id = db.Column(db.Integer, primary_key=True) text_contents = db.Column(db.Text()) - created_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False - ) - 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") - 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): +class Description(BaseModel, TrackUpdates): __tablename__ = "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()) - created_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False - ) officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) - 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): +class Officer(BaseModel, TrackUpdates): __tablename__ = "officers" id = db.Column(db.Integer, primary_key=True) @@ -219,15 +215,6 @@ class Officer(BaseModel): unique_internal_identifier = db.Column( db.String(50), index=True, unique=True, nullable=True ) - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) - created_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False - ) links = db.relationship( "Link", secondary=officer_links, backref=db.backref("officers", lazy=True) @@ -312,7 +299,7 @@ def __repr__(self): ) -class Salary(BaseModel): +class Salary(BaseModel, TrackUpdates): __tablename__ = "salaries" id = db.Column(db.Integer, primary_key=True) @@ -322,21 +309,12 @@ class Salary(BaseModel): 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) - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) - created_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False - ) def __repr__(self): return f"" -class Unit(BaseModel): +class Unit(BaseModel, TrackUpdates): __tablename__ = "unit_types" id = db.Column(db.Integer, primary_key=True) @@ -372,21 +341,12 @@ class Unit(BaseModel): department = db.relationship( "Department", backref="unit_types", order_by="Unit.description.asc()" ) - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) - created_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False - ) def __repr__(self): return f"Unit: {self.description}" -class Face(BaseModel): +class Face(BaseModel, TrackUpdates): __tablename__ = "faces" id = db.Column(db.Integer, primary_key=True) @@ -419,16 +379,9 @@ class Face(BaseModel): original_image = db.relationship( "Image", backref="tags", foreign_keys=[original_image_id], lazy=True ) - created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) featured = db.Column( db.Boolean, nullable=False, default=False, server_default="false" ) - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) __table_args__ = (UniqueConstraint("officer_id", "img_id", name="unique_faces"),) @@ -436,33 +389,19 @@ def __repr__(self): return f"" -class Image(BaseModel): +class Image(BaseModel, TrackUpdates): __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) - 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 taken_at = db.Column( db.DateTime(timezone=True), index=True, unique=False, nullable=True ) contains_cops = db.Column(db.Boolean, nullable=True) - created_by = db.Column( - db.Integer, - db.ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - unique=False, - ) - user = db.relationship("User", back_populates="classifications") is_tagged = db.Column(db.Boolean, default=False, unique=False, nullable=True) department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) @@ -525,7 +464,7 @@ def __repr__(self): ) -class Location(BaseModel): +class Location(BaseModel, TrackUpdates): __tablename__ = "locations" id = db.Column(db.Integer, primary_key=True) @@ -535,18 +474,6 @@ class Location(BaseModel): city = db.Column(db.String(100), unique=False, index=True) state = db.Column(db.String(2), unique=False, index=True) zip_code = db.Column(db.String(5), unique=False, index=True) - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) - created_by = db.Column( - db.Integer, - db.ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - unique=False, - ) @validates("zip_code") def validate_zip_code(self, key, zip_code): @@ -580,24 +507,12 @@ def __repr__(self): return f"{self.city} {self.state}" -class LicensePlate(BaseModel): +class LicensePlate(BaseModel, TrackUpdates): __tablename__ = "license_plates" id = db.Column(db.Integer, primary_key=True) number = db.Column(db.String(8), nullable=False, index=True) state = db.Column(db.String(2), index=True) - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) - created_by = db.Column( - db.Integer, - db.ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - unique=False, - ) # for use if car is federal, diplomat, or other non-state # non_state_identifier = db.Column(db.String(20), index=True) @@ -607,7 +522,7 @@ def validate_state(self, key, state): return state_validator(state) -class Link(BaseModel): +class Link(BaseModel, TrackUpdates): __tablename__ = "links" id = db.Column(db.Integer, primary_key=True) @@ -616,26 +531,13 @@ 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) - created_by = db.Column( - db.Integer, - db.ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - unique=False, - ) - creator = db.relationship("User", backref="links", lazy=True) - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) @validates("url") def validate_url(self, key, url): return url_validator(url) -class Incident(BaseModel): +class Incident(BaseModel, TrackUpdates): __tablename__ = "incidents" id = db.Column(db.Integer, primary_key=True) @@ -665,32 +567,6 @@ class Incident(BaseModel): ) department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) department = db.relationship("Department", backref="incidents", lazy=True) - created_at = db.Column( - db.DateTime(timezone=True), - nullable=False, - server_default=sql_func.now(), - unique=False, - ) - created_by = db.Column( - db.Integer, - db.ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - unique=False, - ) - creator = db.relationship( - "User", backref="incidents_created", lazy=True, foreign_keys=[created_by] - ) - last_updated_by = db.Column( - db.Integer, - db.ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - unique=False, - ) - last_updated_at = db.Column( - db.DateTime(timezone=True), - nullable=True, - unique=False, - ) class User(UserMixin, BaseModel): @@ -710,8 +586,29 @@ class User(UserMixin, BaseModel): 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", back_populates="user") - tags = db.relationship("Face", backref="user") + + # creator backlinks + classifications = db.relationship( + "Image", back_populates=KEY_DB_CREATOR, foreign_keys="Image.created_by" + ) + descriptions = db.relationship( + "Description", + back_populates=KEY_DB_CREATOR, + foreign_keys="Description.created_by", + ) + incidents_created = db.relationship( + "Incident", back_populates=KEY_DB_CREATOR, foreign_keys="Incident.created_by" + ) + links = db.relationship( + "Link", back_populates=KEY_DB_CREATOR, foreign_keys="Link.created_by" + ) + notes = db.relationship( + "Note", back_populates=KEY_DB_CREATOR, foreign_keys="Note.created_by" + ) + tags = db.relationship( + "Face", back_populates=KEY_DB_CREATOR, foreign_keys="Face.created_by" + ) + created_at = db.Column( db.DateTime(timezone=True), nullable=False, diff --git a/OpenOversight/app/templates/image.html b/OpenOversight/app/templates/image.html index 6f94df7df..496a96d86 100644 --- a/OpenOversight/app/templates/image.html +++ b/OpenOversight/app/templates/image.html @@ -78,7 +78,7 @@

Classification

Classified by user - {{ image.user.username }} + {{ image.creator.username }} diff --git a/OpenOversight/app/templates/partials/officer_descriptions.html b/OpenOversight/app/templates/partials/officer_descriptions.html index 08208d6d4..79d864967 100644 --- a/OpenOversight/app/templates/partials/officer_descriptions.html +++ b/OpenOversight/app/templates/partials/officer_descriptions.html @@ -3,11 +3,11 @@

Descriptions