From c2be7e27071f53eb75d9f85fdbc3d5aed9add844 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:14:25 -0500 Subject: [PATCH 01/48] Update database.py --- OpenOversight/app/models/database.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index c50e0d59d..d8e5f6f34 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -723,7 +723,15 @@ class User(UserMixin, BaseModel): username = db.Column(db.String(64), unique=True, index=True) password_hash = db.Column(db.String(128)) confirmed = db.Column(db.Boolean, default=False) + confirmed_at = db.Column(db.DateTime(timezone=True)) + confirmed_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) approved = db.Column(db.Boolean, default=False) + approved_at = db.Column(db.DateTime(timezone=True)) + approved_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) is_area_coordinator = db.Column(db.Boolean, default=False) ac_department_id = db.Column( db.Integer, db.ForeignKey("departments.id", name="users_ac_department_id_fkey") @@ -735,6 +743,11 @@ class User(UserMixin, BaseModel): ) is_administrator = db.Column(db.Boolean, default=False) is_disabled = db.Column(db.Boolean, default=False) + disabled_at = db.Column(db.DateTime(timezone=True)) + disabled_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + dept_pref = db.Column( db.Integer, db.ForeignKey("departments.id", name="users_dept_pref_fkey") ) From 9ab33f5e6e6f2eb753a607643671415398375b26 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:14:29 -0500 Subject: [PATCH 02/48] Create 2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py --- ...b9_add_remaining_audit_fields_to_users_.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py diff --git a/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py b/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py new file mode 100644 index 000000000..c5656d5b2 --- /dev/null +++ b/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py @@ -0,0 +1,107 @@ +"""add remaining audit fields to users table + +Revision ID: 0a7c591e13b9 +Revises: 5865f488470c +Create Date: 2024-10-09 18:59:15.546419 + +""" +from alembic import op +import sqlalchemy as sa + + + +revision = '0a7c591e13b9' +down_revision = '5865f488470c' + + +def upgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('confirmed_by', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('approved_by', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('disabled_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('disabled_by', sa.Integer(), nullable=True)) + batch_op.create_foreign_key(None, 'users', ['disabled_by'], ['id'], ondelete='SET NULL') + batch_op.create_foreign_key(None, 'users', ['confirmed_by'], ['id'], ondelete='SET NULL') + batch_op.create_foreign_key(None, 'users', ['approved_by'], ['id'], ondelete='SET NULL') + + op.execute( + """ + UPDATE users + SET approved_at = NOW() + WHERE approved IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET approved_by = ( + SELECT id + FROM users + WHERE is_administrator is TRUE + ORDER BY created_at ASC + LIMIT 1 + ) + WHERE approved IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET confirmed_at = NOW() + WHERE confirmed IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET confirmed_by = ( + SELECT id + FROM users + WHERE is_administrator is TRUE + ORDER BY created_at ASC + LIMIT 1 + ) + WHERE confirmed IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET disabled_at = NOW() + WHERE is_disabled IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET disabled_by = ( + SELECT id + FROM users + WHERE is_administrator is TRUE + ORDER BY created_at ASC + LIMIT 1 + ) + WHERE is_disabled IS TRUE + """ + ) + + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('disabled_by') + batch_op.drop_column('disabled_at') + batch_op.drop_column('approved_by') + batch_op.drop_column('approved_at') + batch_op.drop_column('confirmed_by') + batch_op.drop_column('confirmed_at') From ef4fc79b41ce34443a08fedfd05ef48300066f9b Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:14:58 -0500 Subject: [PATCH 03/48] Update 2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py --- ...b9_add_remaining_audit_fields_to_users_.py | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py b/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py index c5656d5b2..2d1436ef4 100644 --- a/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py +++ b/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py @@ -5,26 +5,38 @@ Create Date: 2024-10-09 18:59:15.546419 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op -revision = '0a7c591e13b9' -down_revision = '5865f488470c' +revision = "0a7c591e13b9" +down_revision = "5865f488470c" def upgrade(): - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True)) - batch_op.add_column(sa.Column('confirmed_by', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True)) - batch_op.add_column(sa.Column('approved_by', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('disabled_at', sa.DateTime(timezone=True), nullable=True)) - batch_op.add_column(sa.Column('disabled_by', sa.Integer(), nullable=True)) - batch_op.create_foreign_key(None, 'users', ['disabled_by'], ['id'], ondelete='SET NULL') - batch_op.create_foreign_key(None, 'users', ['confirmed_by'], ['id'], ondelete='SET NULL') - batch_op.create_foreign_key(None, 'users', ['approved_by'], ['id'], ondelete='SET NULL') + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("confirmed_by", sa.Integer(), nullable=True)) + batch_op.add_column( + sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("approved_by", sa.Integer(), nullable=True)) + batch_op.add_column( + sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("disabled_by", sa.Integer(), nullable=True)) + batch_op.create_foreign_key( + None, "users", ["disabled_by"], ["id"], ondelete="SET NULL" + ) + batch_op.create_foreign_key( + None, "users", ["confirmed_by"], ["id"], ondelete="SET NULL" + ) + batch_op.create_foreign_key( + None, "users", ["approved_by"], ["id"], ondelete="SET NULL" + ) op.execute( """ @@ -93,15 +105,14 @@ def upgrade(): ) - def downgrade(): - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_column('disabled_by') - batch_op.drop_column('disabled_at') - batch_op.drop_column('approved_by') - batch_op.drop_column('approved_at') - batch_op.drop_column('confirmed_by') - batch_op.drop_column('confirmed_at') + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_constraint(None, type_="foreignkey") + batch_op.drop_constraint(None, type_="foreignkey") + batch_op.drop_constraint(None, type_="foreignkey") + batch_op.drop_column("disabled_by") + batch_op.drop_column("disabled_at") + batch_op.drop_column("approved_by") + batch_op.drop_column("approved_at") + batch_op.drop_column("confirmed_by") + batch_op.drop_column("confirmed_at") From d0b34fc59c131e330c8e891f09c5da0010338b93 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:24:14 -0500 Subject: [PATCH 04/48] Delete 2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py --- ...b9_add_remaining_audit_fields_to_users_.py | 118 ------------------ 1 file changed, 118 deletions(-) delete mode 100644 OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py diff --git a/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py b/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py deleted file mode 100644 index 2d1436ef4..000000000 --- a/OpenOversight/migrations/versions/2024-10-09-1859_0a7c591e13b9_add_remaining_audit_fields_to_users_.py +++ /dev/null @@ -1,118 +0,0 @@ -"""add remaining audit fields to users table - -Revision ID: 0a7c591e13b9 -Revises: 5865f488470c -Create Date: 2024-10-09 18:59:15.546419 - -""" - -import sqlalchemy as sa -from alembic import op - - -revision = "0a7c591e13b9" -down_revision = "5865f488470c" - - -def upgrade(): - with op.batch_alter_table("users", schema=None) as batch_op: - batch_op.add_column( - sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True) - ) - batch_op.add_column(sa.Column("confirmed_by", sa.Integer(), nullable=True)) - batch_op.add_column( - sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True) - ) - batch_op.add_column(sa.Column("approved_by", sa.Integer(), nullable=True)) - batch_op.add_column( - sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True) - ) - batch_op.add_column(sa.Column("disabled_by", sa.Integer(), nullable=True)) - batch_op.create_foreign_key( - None, "users", ["disabled_by"], ["id"], ondelete="SET NULL" - ) - batch_op.create_foreign_key( - None, "users", ["confirmed_by"], ["id"], ondelete="SET NULL" - ) - batch_op.create_foreign_key( - None, "users", ["approved_by"], ["id"], ondelete="SET NULL" - ) - - op.execute( - """ - UPDATE users - SET approved_at = NOW() - WHERE approved IS TRUE - """ - ) - - op.execute( - """ - UPDATE users - SET approved_by = ( - SELECT id - FROM users - WHERE is_administrator is TRUE - ORDER BY created_at ASC - LIMIT 1 - ) - WHERE approved IS TRUE - """ - ) - - op.execute( - """ - UPDATE users - SET confirmed_at = NOW() - WHERE confirmed IS TRUE - """ - ) - - op.execute( - """ - UPDATE users - SET confirmed_by = ( - SELECT id - FROM users - WHERE is_administrator is TRUE - ORDER BY created_at ASC - LIMIT 1 - ) - WHERE confirmed IS TRUE - """ - ) - - op.execute( - """ - UPDATE users - SET disabled_at = NOW() - WHERE is_disabled IS TRUE - """ - ) - - op.execute( - """ - UPDATE users - SET disabled_by = ( - SELECT id - FROM users - WHERE is_administrator is TRUE - ORDER BY created_at ASC - LIMIT 1 - ) - WHERE is_disabled IS TRUE - """ - ) - - -def downgrade(): - with op.batch_alter_table("users", schema=None) as batch_op: - batch_op.drop_constraint(None, type_="foreignkey") - batch_op.drop_constraint(None, type_="foreignkey") - batch_op.drop_constraint(None, type_="foreignkey") - batch_op.drop_column("disabled_by") - batch_op.drop_column("disabled_at") - batch_op.drop_column("approved_by") - batch_op.drop_column("approved_at") - batch_op.drop_column("confirmed_by") - batch_op.drop_column("confirmed_at") From 9dcb3847c6eb2e48ee6520882978d8791216222a Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:30:29 -0500 Subject: [PATCH 05/48] Update database.py --- OpenOversight/app/models/database.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index d8e5f6f34..669d71811 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -725,12 +725,12 @@ class User(UserMixin, BaseModel): confirmed = db.Column(db.Boolean, default=False) confirmed_at = db.Column(db.DateTime(timezone=True)) confirmed_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL", name="users_confirmed_by_fkey"), unique=False ) approved = db.Column(db.Boolean, default=False) approved_at = db.Column(db.DateTime(timezone=True)) approved_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL", name="users_approved_by_fkey"), unique=False ) is_area_coordinator = db.Column(db.Boolean, default=False) ac_department_id = db.Column( @@ -745,7 +745,7 @@ class User(UserMixin, BaseModel): is_disabled = db.Column(db.Boolean, default=False) disabled_at = db.Column(db.DateTime(timezone=True)) disabled_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL", name="users_disabled_by_fkey"), unique=False ) dept_pref = db.Column( From a098944b4a0620dfa9d3dc97445b673d6ea3e2ff Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:30:34 -0500 Subject: [PATCH 06/48] Create 2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py --- ...a_add_remaining_audit_fields_for_users_.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 OpenOversight/migrations/versions/2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py diff --git a/OpenOversight/migrations/versions/2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py b/OpenOversight/migrations/versions/2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py new file mode 100644 index 000000000..0c7ecf5bd --- /dev/null +++ b/OpenOversight/migrations/versions/2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py @@ -0,0 +1,107 @@ +"""add remaining audit fields for users table + +Revision ID: bf254c0961ca +Revises: 5865f488470c +Create Date: 2024-10-09 19:27:11.429699 + +""" +from alembic import op +import sqlalchemy as sa + + + +revision = 'bf254c0961ca' +down_revision = '5865f488470c' + + +def upgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('confirmed_by', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('approved_by', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('disabled_at', sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column('disabled_by', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('users_confirmed_by_fkey', 'users', ['confirmed_by'], ['id'], ondelete='SET NULL') + batch_op.create_foreign_key('users_disabled_by_fkey', 'users', ['disabled_by'], ['id'], ondelete='SET NULL') + batch_op.create_foreign_key('users_approved_by_fkey', 'users', ['approved_by'], ['id'], ondelete='SET NULL') + + op.execute( + """ + UPDATE users + SET approved_at = NOW() + WHERE approved IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET approved_by = ( + SELECT id + FROM users + WHERE is_administrator is TRUE + ORDER BY created_at ASC + LIMIT 1 + ) + WHERE approved IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET confirmed_at = NOW() + WHERE confirmed IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET confirmed_by = ( + SELECT id + FROM users + WHERE is_administrator is TRUE + ORDER BY created_at ASC + LIMIT 1 + ) + WHERE confirmed IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET disabled_at = NOW() + WHERE is_disabled IS TRUE + """ + ) + + op.execute( + """ + UPDATE users + SET disabled_by = ( + SELECT id + FROM users + WHERE is_administrator is TRUE + ORDER BY created_at ASC + LIMIT 1 + ) + WHERE is_disabled IS TRUE + """ + ) + + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint('users_approved_by_fkey', type_='foreignkey') + batch_op.drop_constraint('users_disabled_by_fkey', type_='foreignkey') + batch_op.drop_constraint('users_confirmed_by_fkey', type_='foreignkey') + batch_op.drop_column('disabled_by') + batch_op.drop_column('disabled_at') + batch_op.drop_column('approved_by') + batch_op.drop_column('approved_at') + batch_op.drop_column('confirmed_by') + batch_op.drop_column('confirmed_at') From 70f883fc529602329b0fec0ecf3976c62d666b96 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:31:12 -0500 Subject: [PATCH 07/48] Update database.py --- OpenOversight/app/models/database.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index 669d71811..31dbc5f7a 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -725,12 +725,16 @@ class User(UserMixin, BaseModel): confirmed = db.Column(db.Boolean, default=False) confirmed_at = db.Column(db.DateTime(timezone=True)) confirmed_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL", name="users_confirmed_by_fkey"), unique=False + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL", name="users_confirmed_by_fkey"), + unique=False, ) approved = db.Column(db.Boolean, default=False) approved_at = db.Column(db.DateTime(timezone=True)) approved_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL", name="users_approved_by_fkey"), unique=False + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL", name="users_approved_by_fkey"), + unique=False, ) is_area_coordinator = db.Column(db.Boolean, default=False) ac_department_id = db.Column( @@ -745,7 +749,9 @@ class User(UserMixin, BaseModel): is_disabled = db.Column(db.Boolean, default=False) disabled_at = db.Column(db.DateTime(timezone=True)) disabled_by = db.Column( - db.Integer, db.ForeignKey("users.id", ondelete="SET NULL", name="users_disabled_by_fkey"), unique=False + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL", name="users_disabled_by_fkey"), + unique=False, ) dept_pref = db.Column( From 8e065c456d7f31d51cccb0bfe325a796e0d2293c Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:31:17 -0500 Subject: [PATCH 08/48] Update 2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py --- ...a_add_remaining_audit_fields_for_users_.py | 73 ++++++++++++------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/OpenOversight/migrations/versions/2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py b/OpenOversight/migrations/versions/2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py index 0c7ecf5bd..0cb78ee97 100644 --- a/OpenOversight/migrations/versions/2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py +++ b/OpenOversight/migrations/versions/2024-10-09-1927_bf254c0961ca_add_remaining_audit_fields_for_users_.py @@ -5,26 +5,50 @@ Create Date: 2024-10-09 19:27:11.429699 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op -revision = 'bf254c0961ca' -down_revision = '5865f488470c' +revision = "bf254c0961ca" +down_revision = "5865f488470c" def upgrade(): - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True)) - batch_op.add_column(sa.Column('confirmed_by', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True)) - batch_op.add_column(sa.Column('approved_by', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('disabled_at', sa.DateTime(timezone=True), nullable=True)) - batch_op.add_column(sa.Column('disabled_by', sa.Integer(), nullable=True)) - batch_op.create_foreign_key('users_confirmed_by_fkey', 'users', ['confirmed_by'], ['id'], ondelete='SET NULL') - batch_op.create_foreign_key('users_disabled_by_fkey', 'users', ['disabled_by'], ['id'], ondelete='SET NULL') - batch_op.create_foreign_key('users_approved_by_fkey', 'users', ['approved_by'], ['id'], ondelete='SET NULL') + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("confirmed_by", sa.Integer(), nullable=True)) + batch_op.add_column( + sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("approved_by", sa.Integer(), nullable=True)) + batch_op.add_column( + sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("disabled_by", sa.Integer(), nullable=True)) + batch_op.create_foreign_key( + "users_confirmed_by_fkey", + "users", + ["confirmed_by"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_foreign_key( + "users_disabled_by_fkey", + "users", + ["disabled_by"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_foreign_key( + "users_approved_by_fkey", + "users", + ["approved_by"], + ["id"], + ondelete="SET NULL", + ) op.execute( """ @@ -93,15 +117,14 @@ def upgrade(): ) - def downgrade(): - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_constraint('users_approved_by_fkey', type_='foreignkey') - batch_op.drop_constraint('users_disabled_by_fkey', type_='foreignkey') - batch_op.drop_constraint('users_confirmed_by_fkey', type_='foreignkey') - batch_op.drop_column('disabled_by') - batch_op.drop_column('disabled_at') - batch_op.drop_column('approved_by') - batch_op.drop_column('approved_at') - batch_op.drop_column('confirmed_by') - batch_op.drop_column('confirmed_at') + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_constraint("users_approved_by_fkey", type_="foreignkey") + batch_op.drop_constraint("users_disabled_by_fkey", type_="foreignkey") + batch_op.drop_constraint("users_confirmed_by_fkey", type_="foreignkey") + batch_op.drop_column("disabled_by") + batch_op.drop_column("disabled_at") + batch_op.drop_column("approved_by") + batch_op.drop_column("approved_at") + batch_op.drop_column("confirmed_by") + batch_op.drop_column("confirmed_at") From 37472ce6273bd6f2a9e5c94db3a96b8b9c645f53 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:03:07 -0500 Subject: [PATCH 09/48] Update database.py --- OpenOversight/app/models/database.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index 31dbc5f7a..b887f6522 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -722,14 +722,12 @@ class User(UserMixin, BaseModel): email = db.Column(db.String(64), unique=True, index=True) username = db.Column(db.String(64), unique=True, index=True) password_hash = db.Column(db.String(128)) - confirmed = db.Column(db.Boolean, default=False) confirmed_at = db.Column(db.DateTime(timezone=True)) confirmed_by = db.Column( db.Integer, db.ForeignKey("users.id", ondelete="SET NULL", name="users_confirmed_by_fkey"), unique=False, ) - approved = db.Column(db.Boolean, default=False) approved_at = db.Column(db.DateTime(timezone=True)) approved_by = db.Column( db.Integer, @@ -746,7 +744,6 @@ class User(UserMixin, BaseModel): foreign_keys=[ac_department_id], ) is_administrator = db.Column(db.Boolean, default=False) - is_disabled = db.Column(db.Boolean, default=False) disabled_at = db.Column(db.DateTime(timezone=True)) disabled_by = db.Column( db.Integer, @@ -788,6 +785,21 @@ class User(UserMixin, BaseModel): unique=False, ) + __table_args__ = ( + CheckConstraint( + "(disabled_at IS NULL and disabled_by IS NULL) or (disabled_at IS NOT NULL and disabled_by IS NOT NULL)", + name="users_disabled_constraint", + ), + CheckConstraint( + "(confirmed_at IS NULL and confirmed_by IS NULL) or (confirmed_at IS NOT NULL and confirmed_by IS NOT NULL)", + name="users_confirmed_constraint", + ), + CheckConstraint( + "(approved_at IS NULL and approved_by IS NULL) or (approved_at IS NOT NULL and approved_by IS NOT NULL)", + name="users_approved_constraint", + ), + ) + def is_admin_or_coordinator(self, department: Optional[Department]) -> bool: return self.is_administrator or ( department is not None From 0e0e2f793b20d462b2eb6fb23831b0ee73c65c71 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:04:36 -0500 Subject: [PATCH 10/48] Create 2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py --- ..._complete_audit_field_addition_to_users.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 OpenOversight/migrations/versions/2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py diff --git a/OpenOversight/migrations/versions/2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py b/OpenOversight/migrations/versions/2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py new file mode 100644 index 000000000..0de6d4da1 --- /dev/null +++ b/OpenOversight/migrations/versions/2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py @@ -0,0 +1,52 @@ +"""complete audit field addition to users + +Revision ID: 99c50fc8d294 +Revises: bf254c0961ca +Create Date: 2024-10-09 20:00:55.155377 + +""" +from alembic import op +import sqlalchemy as sa + + + +revision = '99c50fc8d294' +down_revision = 'bf254c0961ca' + + +def upgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('approved') + batch_op.drop_column('confirmed') + batch_op.drop_column('is_disabled') + + +def downgrade(): + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_disabled', sa.BOOLEAN(), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('confirmed', sa.BOOLEAN(), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('approved', sa.BOOLEAN(), autoincrement=False, nullable=True)) + + op.execute( + """ + UPDATE users + SET is_disabled = TRUE + WHERE disabled_at IS NOT NULL + """ + ) + + op.execute( + """ + UPDATE users + SET approved = TRUE + WHERE approved_at IS NOT NULL + """ + ) + + op.execute( + """ + UPDATE users + SET confirmed = TRUE + WHERE confirmed_at IS NOT NULL + """ + ) From 5de0e1998b5018ea5d1d44f82b463f404f48d7b8 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:05:02 -0500 Subject: [PATCH 11/48] Update 2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py --- ..._complete_audit_field_addition_to_users.py | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/OpenOversight/migrations/versions/2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py b/OpenOversight/migrations/versions/2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py index 0de6d4da1..9a22e1743 100644 --- a/OpenOversight/migrations/versions/2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py +++ b/OpenOversight/migrations/versions/2024-10-09-2000_99c50fc8d294_complete_audit_field_addition_to_users.py @@ -5,27 +5,33 @@ Create Date: 2024-10-09 20:00:55.155377 """ -from alembic import op -import sqlalchemy as sa +import sqlalchemy as sa +from alembic import op -revision = '99c50fc8d294' -down_revision = 'bf254c0961ca' +revision = "99c50fc8d294" +down_revision = "bf254c0961ca" def upgrade(): - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('approved') - batch_op.drop_column('confirmed') - batch_op.drop_column('is_disabled') + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_column("approved") + batch_op.drop_column("confirmed") + batch_op.drop_column("is_disabled") def downgrade(): - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_disabled', sa.BOOLEAN(), autoincrement=False, nullable=True)) - batch_op.add_column(sa.Column('confirmed', sa.BOOLEAN(), autoincrement=False, nullable=True)) - batch_op.add_column(sa.Column('approved', sa.BOOLEAN(), autoincrement=False, nullable=True)) + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column("is_disabled", sa.BOOLEAN(), autoincrement=False, nullable=True) + ) + batch_op.add_column( + sa.Column("confirmed", sa.BOOLEAN(), autoincrement=False, nullable=True) + ) + batch_op.add_column( + sa.Column("approved", sa.BOOLEAN(), autoincrement=False, nullable=True) + ) op.execute( """ From 171b657f44fb96ba76ba79a824386e396482c49d Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:26:04 -0500 Subject: [PATCH 12/48] Update database.py --- OpenOversight/app/models/database.py | 37 ++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index b887f6522..ea40e198d 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -2,7 +2,7 @@ import re import time import uuid -from datetime import date, datetime +from datetime import date, datetime, timezone from typing import List, Optional from authlib.jose import JoseError, JsonWebToken @@ -922,7 +922,40 @@ def get_id(self): @property def is_active(self): """Override UserMixin.is_active to prevent disabled users from logging in.""" - return not self.is_disabled + return not self.disabled_at + + def disable_user(self, disabling_user: int): + """Handle disabling logic.""" + if self.disabled_at or self.disabled_by: + return False + + self.disabled_at = datetime.now(timezone.utc) + self.disabled_by = disabling_user + db.session.add(self) + db.session.commit() + return True + + def approve_user(self, approving_user: int): + """Handle approving logic.""" + if self.approved_at or self.approved_by: + return False + + self.approved_at = datetime.now(timezone.utc) + self.approved_by = approving_user + db.session.add(self) + db.session.commit() + return True + + def confirm_user(self, confirming_user: int): + """Handle confirming logic.""" + if self.confirmed_at or self.confirmed_by: + return False + + self.confirmed_at = datetime.now(timezone.utc) + self.confirmed_by = confirming_user + db.session.add(self) + db.session.commit() + return True def __repr__(self): return f"" From 930c5f321bbe52f96bc047ecca124e696216ee44 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:41:35 -0500 Subject: [PATCH 13/48] Update views.py --- OpenOversight/app/auth/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 75c02f734..e73fc3f90 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -69,7 +69,7 @@ def before_request(): def unconfirmed(): if current_user.is_anonymous or current_user.confirmed: return redirect(url_for("main.index")) - if current_app.config["APPROVE_REGISTRATIONS"] and not current_user.approved: + if current_app.config["APPROVE_REGISTRATIONS"] and not current_user.approved_at: return render_template("auth/unapproved.html") else: return render_template("auth/unconfirmed.html") @@ -313,7 +313,7 @@ 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 + already_approved = user.approved_at is not None form.populate_obj(user) db.session.add(user) db.session.commit() @@ -323,8 +323,8 @@ def edit_user(user_id): if ( current_app.config["APPROVE_REGISTRATIONS"] and not already_approved - and user.approved - and not user.confirmed + and user.approved_at + and not user.confirmed_at ): admin_resend_confirmation(user) From 450e71516ecf75477d0cc055093ec10eedeb0ab0 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:41:39 -0500 Subject: [PATCH 14/48] Update users.html --- OpenOversight/app/templates/auth/users.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenOversight/app/templates/auth/users.html b/OpenOversight/app/templates/auth/users.html index 8428dedc0..71830a4c9 100644 --- a/OpenOversight/app/templates/auth/users.html +++ b/OpenOversight/app/templates/auth/users.html @@ -43,7 +43,7 @@

Users

Disabled {% elif user.confirmed %} Active - {% elif user.approved %} + {% elif user.approved_at %} Pending Confirmation {% else %} Pending Approval From a4af57201afb1f932c9091d5dca952f43454eddd Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:53:22 -0500 Subject: [PATCH 15/48] Update test_user_api.py --- OpenOversight/tests/routes/test_user_api.py | 47 ++++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index 9a4f156f9..6aa9afb17 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -287,14 +287,16 @@ def test_register_user_approval_required(client, session): def test_admin_can_approve_user(client, session): with current_app.test_request_context(): - login_admin(client) + _, current_user = login_admin(client) user = User.query.filter_by(email=GENERAL_USER_EMAIL).first() - user.approved = False + user.approved_by = None + user.approved_at = None session.commit() user = session.get(User, user.id) - assert not user.approved + assert not user.approved_by + assert not user.approved_at form = EditUserForm( approved=True, @@ -310,7 +312,8 @@ def test_admin_can_approve_user(client, session): assert "updated!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert user.approved + assert user.approved_at + assert user.approved_by @pytest.mark.parametrize( @@ -338,16 +341,37 @@ def test_admin_approval_sends_confirmation_email( ): current_app.config["APPROVE_REGISTRATIONS"] = approve_registration_config with current_app.test_request_context(): - login_admin(client) + _, current_user = login_admin(client) user = User.query.filter_by(is_administrator=False).first() - user.approved = currently_approved - user.confirmed = currently_confirmed - session.commit() + if currently_approved: + user.approve_user(current_user.id) + else: + user.approved_by = None + user.approved_at = None + session.commit() + + if currently_confirmed: + user.confirm_user(current_user.id) + else: + user.confirmed_by = None + user.confirmed = None + session.commit() user = session.get(User, user.id) - assert user.approved == currently_approved - assert user.confirmed == currently_confirmed + if currently_approved: + assert user.approved_at is not None + assert user.approved_by == current_user.id + else: + assert user.approved_at is None + assert user.approved_by is None + + if currently_confirmed: + assert user.confirmed_at is not None + assert user.confirmed_by == current_user.id + else: + assert user.confirmed_at is None + assert user.confirmed_by is None form = EditUserForm(approved=True, submit=True, confirmed=currently_confirmed) @@ -363,4 +387,5 @@ def test_admin_approval_sends_confirmation_email( assert "updated!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert user.approved + assert user.approved_at is not None + assert user.approved_by == current_user.id From 46fd1e4a7284a9799e15ad0f567f301c97520d20 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:55:18 -0500 Subject: [PATCH 16/48] Update test_user_api.py --- OpenOversight/tests/routes/test_user_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index 6aa9afb17..e3246cb44 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -347,15 +347,15 @@ def test_admin_approval_sends_confirmation_email( if currently_approved: user.approve_user(current_user.id) else: - user.approved_by = None user.approved_at = None + user.approved_by = None session.commit() if currently_confirmed: user.confirm_user(current_user.id) else: + user.confirmed_at = None user.confirmed_by = None - user.confirmed = None session.commit() user = session.get(User, user.id) From 4842a1819cef122e15883e19bef8837bb7d4968e Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:05:21 -0500 Subject: [PATCH 17/48] Update users.html --- OpenOversight/app/templates/auth/users.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenOversight/app/templates/auth/users.html b/OpenOversight/app/templates/auth/users.html index 71830a4c9..23d215873 100644 --- a/OpenOversight/app/templates/auth/users.html +++ b/OpenOversight/app/templates/auth/users.html @@ -39,9 +39,9 @@

Users

{{ user.email }} - {% if user.is_disabled %} + {% if user.disabled_at %} Disabled - {% elif user.confirmed %} + {% elif user.confirmed_at %} Active {% elif user.approved_at %} Pending Confirmation From be8caa5eac1133cd82eedfa39ab53f497e7276e3 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:09:46 -0500 Subject: [PATCH 18/48] Update test_models.py --- OpenOversight/tests/test_models.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/OpenOversight/tests/test_models.py b/OpenOversight/tests/test_models.py index 6683295a1..a4c805de0 100644 --- a/OpenOversight/tests/test_models.py +++ b/OpenOversight/tests/test_models.py @@ -240,8 +240,11 @@ def test_valid_confirmation_token(mockdata, session): user = User(password="bacon") session.add(user) session.commit() + + admin_user = User.query.filter_by(is_administrator=False).first() token = user.generate_confirmation_token() - assert user.confirm(token) is True + + assert user.confirm(token, admin_user.id) is True def test_invalid_confirmation_token(mockdata, session): @@ -250,17 +253,23 @@ def test_invalid_confirmation_token(mockdata, session): session.add(user1) session.add(user2) session.commit() + + admin_user = User.query.filter_by(is_administrator=False).first() token = user1.generate_confirmation_token() - assert user2.confirm(token) is False + + assert user2.confirm(token, admin_user.id) is False def test_expired_confirmation_token(mockdata, session): user = User(password="bacon") session.add(user) session.commit() + + admin_user = User.query.filter_by(is_administrator=False).first() token = user.generate_confirmation_token(1) time.sleep(2) - assert user.confirm(token) is False + + assert user.confirm(token, admin_user.id) is False def test_valid_reset_token(mockdata, session): From 102f222143d0b31b70e231ece1694ca42e926265 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:10:01 -0500 Subject: [PATCH 19/48] Blep --- OpenOversight/app/auth/views.py | 14 +++++++----- OpenOversight/app/models/database.py | 24 ++++++--------------- OpenOversight/tests/routes/test_user_api.py | 8 ++++--- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index e73fc3f90..fc6429c64 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -57,7 +57,8 @@ def static_routes(): def before_request(): if ( current_user.is_authenticated - and not current_user.confirmed + and not current_user.confirmed_at + and not current_user.confirmed_by and request.endpoint and request.endpoint[:5] != "auth." and request.endpoint not in ["static", "bootstrap.static"] @@ -67,7 +68,7 @@ def before_request(): @auth.route("/unconfirmed") def unconfirmed(): - if current_user.is_anonymous or current_user.confirmed: + if current_user.is_anonymous or current_user.confirmed_at: return redirect(url_for("main.index")) if current_app.config["APPROVE_REGISTRATIONS"] and not current_user.approved_at: return render_template("auth/unapproved.html") @@ -141,9 +142,11 @@ def register(): @auth.route("/confirm/", methods=[HTTPMethod.GET]) @login_required def confirm(token): - if current_user.confirmed: + if current_user.confirmed_at and current_user.confirmed_by: return redirect(url_for("main.index")) - if current_user.confirm(token): + if current_user.confirm( + token, User.query.filter_by(is_administrator=True).first().id + ): admins = User.query.filter_by(is_administrator=True).all() for admin in admins: EmailClient.send_email( @@ -325,6 +328,7 @@ def edit_user(user_id): and not already_approved and user.approved_at and not user.confirmed_at + and not current_user.confirmed_by ): admin_resend_confirmation(user) @@ -353,7 +357,7 @@ def delete_user(user_id): def admin_resend_confirmation(user): - if user.confirmed: + if user.confirmed_at and current_user.confirmed_by: flash(f"User {user.username} is already confirmed.") else: token = user.generate_confirmation_token() diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index ea40e198d..b98983cbc 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -856,7 +856,7 @@ def generate_confirmation_token(self, expiration=3600): payload = {"confirm": self.uuid} return self._jwt_encode(payload, expiration).decode(ENCODING_UTF_8) - def confirm(self, token): + def confirm(self, token, confirming_user_id: int): try: data = self._jwt_decode(token) except JoseError as e: @@ -869,7 +869,8 @@ def confirm(self, token): self.uuid, ) return False - self.confirmed = True + self.confirmed_at = datetime.now(timezone.utc) + self.confirmed_by = confirming_user_id db.session.add(self) db.session.commit() return True @@ -924,35 +925,24 @@ def is_active(self): """Override UserMixin.is_active to prevent disabled users from logging in.""" return not self.disabled_at - def disable_user(self, disabling_user: int): + def disable_user(self, disabling_user_id: int): """Handle disabling logic.""" if self.disabled_at or self.disabled_by: return False self.disabled_at = datetime.now(timezone.utc) - self.disabled_by = disabling_user + self.disabled_by = disabling_user_id db.session.add(self) db.session.commit() return True - def approve_user(self, approving_user: int): + def approve_user(self, approving_user_id: int): """Handle approving logic.""" if self.approved_at or self.approved_by: return False self.approved_at = datetime.now(timezone.utc) - self.approved_by = approving_user - db.session.add(self) - db.session.commit() - return True - - def confirm_user(self, confirming_user: int): - """Handle confirming logic.""" - if self.confirmed_at or self.confirmed_by: - return False - - self.confirmed_at = datetime.now(timezone.utc) - self.confirmed_by = confirming_user + self.approved_by = approving_user_id db.session.add(self) db.session.commit() return True diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index e3246cb44..8ab615fe6 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from http import HTTPMethod, HTTPStatus import pytest @@ -349,14 +350,15 @@ def test_admin_approval_sends_confirmation_email( else: user.approved_at = None user.approved_by = None - session.commit() if currently_confirmed: - user.confirm_user(current_user.id) + user.confirmed_at = datetime.now(timezone.utc) + user.confirmed_by = current_user.id else: user.confirmed_at = None user.confirmed_by = None - session.commit() + + session.commit() user = session.get(User, user.id) if currently_approved: From 04c49066b60c89d2726bbbba6676f3ff9d5fe3c7 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:14:46 -0500 Subject: [PATCH 20/48] Update conftest.py --- OpenOversight/tests/conftest.py | 38 +++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/OpenOversight/tests/conftest.py b/OpenOversight/tests/conftest.py index bf5e2724d..c494b1edc 100644 --- a/OpenOversight/tests/conftest.py +++ b/OpenOversight/tests/conftest.py @@ -5,7 +5,7 @@ import sys import threading import uuid -from datetime import date, datetime, time, timedelta +from datetime import date, datetime, time, timedelta, timezone from io import BytesIO from pathlib import Path from time import sleep @@ -368,28 +368,29 @@ def test_csv_dir(): def add_mockdata(session): assert current_app.config[KEY_NUM_OFFICERS] >= 5 - test_user = User( - email=GENERAL_USER_EMAIL, - username=GENERAL_USER_USERNAME, - password=GENERAL_USER_PASSWORD, - confirmed=True, - ) - session.add(test_user) - test_admin = User( email=ADMIN_USER_EMAIL, username=ADMIN_USER_USER_NAME, password=ADMIN_USER_PASSWORD, - confirmed=True, + confirmed_at=datetime.now(timezone.utc), + confirmed_by=1, is_administrator=True, ) session.add(test_admin) + test_user = User( + email=GENERAL_USER_EMAIL, + username=GENERAL_USER_USERNAME, + password=GENERAL_USER_PASSWORD, + confirmed_at=datetime.now(timezone.utc), + confirmed_by=1, + ) + session.add(test_user) + test_unconfirmed_user = User( email=UNCONFIRMED_USER_EMAIL, username=UNCONFIRMED_USER_USERNAME, password=UNCONFIRMED_USER_PASSWORD, - confirmed=False, ) session.add(test_unconfirmed_user) session.commit() @@ -398,8 +399,10 @@ def add_mockdata(session): email=DISABLED_USER_EMAIL, username=DISABLED_USER_USERNAME, password=DISABLED_USER_PASSWORD, - confirmed=True, - is_disabled=True, + confirmed_at=datetime.now(timezone.utc), + confirmed_by=1, + disabled_at=datetime.now(timezone.utc), + disabled_by=1, ) session.add(test_disabled_user) session.commit() @@ -408,8 +411,10 @@ def add_mockdata(session): email=MOD_DISABLED_USER_EMAIL, username=MOD_DISABLED_USER_USERNAME, password=MOD_DISABLED_USER_PASSWORD, - confirmed=True, - is_disabled=True, + confirmed_at=datetime.now(timezone.utc), + confirmed_by=1, + disabled_at=datetime.now(timezone.utc), + disabled_by=1, ) session.add(test_modified_disabled_user) session.commit() @@ -445,7 +450,8 @@ def add_mockdata(session): email=AC_USER_EMAIL, username=AC_USER_USERNAME, password=AC_USER_PASSWORD, - confirmed=True, + confirmed_at=datetime.now(timezone.utc), + confirmed_by=1, is_area_coordinator=True, ac_department_id=AC_DEPT, ) From 464d65dc9e3030695d8ac37ba99d805a64fc4a6f Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:19:53 -0500 Subject: [PATCH 21/48] Disabling --- OpenOversight/app/templates/cop_face.html | 4 ++-- OpenOversight/app/templates/profile.html | 4 ++-- OpenOversight/app/templates/sort.html | 4 ++-- OpenOversight/tests/routes/test_auth.py | 4 +++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/OpenOversight/app/templates/cop_face.html b/OpenOversight/app/templates/cop_face.html index fbf7fec82..4536454a3 100644 --- a/OpenOversight/app/templates/cop_face.html +++ b/OpenOversight/app/templates/cop_face.html @@ -10,7 +10,7 @@ {% block content %}
{% if current_user and current_user.is_authenticated %} - {% if image and not current_user.is_disabled %} + {% if image and not current_user.disabled_at and not current_user_disabled_by %}

@@ -160,7 +160,7 @@

- {% elif current_user.is_disabled %} + {% elif current_user.disabled_at and current_user.disabled_by %}

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

User Statistics role="button">Show leaderboard

Account Status

- {% if user.is_disabled %} + {% if user.disabled_at and user.disabled_by %}

Disabled

- {% elif user.is_disabled == False %} + {% elif not user.disabled_at and not user.disabled_by %}

Enabled

{% endif %} {% if current_user.is_administrator and not user.is_administrators %} diff --git a/OpenOversight/app/templates/sort.html b/OpenOversight/app/templates/sort.html index 2c5d6c2f3..c317fddab 100644 --- a/OpenOversight/app/templates/sort.html +++ b/OpenOversight/app/templates/sort.html @@ -21,7 +21,7 @@ {% block content %}
{% if current_user and current_user.is_authenticated %} - {% if image and current_user.is_disabled == False %} + {% if image and not user.disabled_at and not user.disabled_by %}

@@ -70,7 +70,7 @@

Picture to be sorted

- {% elif current_user.is_disabled == True %} + {% elif user.disabled_at and user.disabled_by %}

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

Date: Wed, 9 Oct 2024 16:25:16 -0500 Subject: [PATCH 22/48] Update test_auth.py --- OpenOversight/tests/routes/test_auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenOversight/tests/routes/test_auth.py b/OpenOversight/tests/routes/test_auth.py index 8596ae175..3d8c4d8f8 100644 --- a/OpenOversight/tests/routes/test_auth.py +++ b/OpenOversight/tests/routes/test_auth.py @@ -467,15 +467,15 @@ def test_disabled_user_cannot_visit_pages_requiring_auth(client, session): with current_app.test_request_context(): # Temporarily enable account for login user = User.query.filter_by(email=MOD_DISABLED_USER_EMAIL).one() - user.is_disabled = False + user.disabled_at = None + user.disabled_by = None session.add(user) rv, _ = login_modified_disabled_user(client) assert b"/user/sam" in rv.data # Disable account again and check that login_required redirects user correctly - user.is_disabled = True - session.add(user) + user.disable_user(User.query.filter_by(is_administrator=True).first().id) # Logged in disabled user cannot access pages requiring auth rv = client.get("/auth/logout") From faf125f8e0c6f5e6c35ff71aad9feec41fc8d034 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:25:19 -0500 Subject: [PATCH 23/48] Update test_user_api.py --- OpenOversight/tests/routes/test_user_api.py | 23 +++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index 8ab615fe6..8c5406782 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -161,7 +161,8 @@ def test_admin_can_disable_user(client, session): # just need to make sure to not select the admin user user = User.query.filter_by(is_administrator=False).first() - assert not user.is_disabled + assert not user.disabled_at + assert not user.disabled_by form = EditUserForm( is_disabled=True, @@ -177,14 +178,16 @@ def test_admin_can_disable_user(client, session): assert "updated!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert user.is_disabled + assert user.disabled_at + assert user.disabled_by def test_admin_cannot_disable_self(client, session): with current_app.test_request_context(): _, user = login_admin(client) - assert not user.is_disabled + assert not user.disabled_at + assert not user.disabled_by form = EditUserForm( is_disabled=True, @@ -200,19 +203,20 @@ def test_admin_cannot_disable_self(client, session): assert "You cannot edit your own account!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert not user.is_disabled + assert user.disabled_at + assert user.disabled_by def test_admin_can_enable_user(client, session): with current_app.test_request_context(): - login_admin(client) + _, current_user = login_admin(client) user = User.query.filter_by(email=GENERAL_USER_EMAIL).one() - user.is_disabled = True - session.commit() + user.disable_user(current_user.id) user = session.get(User, user.id) - assert user.is_disabled + assert user.disabled_at + assert user.disabled_by form = EditUserForm( is_disabled=False, @@ -228,7 +232,8 @@ def test_admin_can_enable_user(client, session): assert "updated!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert not user.is_disabled + assert not user.disabled_at + assert not user.disabled_by def test_admin_can_resend_user_confirmation_email(client, session): From 3cd4bc97011b3610e1059b404b74bc8652b04627 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:30:59 -0500 Subject: [PATCH 24/48] Update test_auth.py --- OpenOversight/tests/routes/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenOversight/tests/routes/test_auth.py b/OpenOversight/tests/routes/test_auth.py index 3d8c4d8f8..ed054353e 100644 --- a/OpenOversight/tests/routes/test_auth.py +++ b/OpenOversight/tests/routes/test_auth.py @@ -406,7 +406,7 @@ def test_user_cannot_change_email_with_invalid_reset_token(client, session): def test_user_can_confirm_account_with_valid_token(client, session): with current_app.test_request_context(): login_unconfirmed_user(client) - user = User.query.filter_by(confirmed=False).first() + user = User.query.filter_by(confirmed_at=None, confirmed_by=None).first() token = user.generate_confirmation_token() rv = client.get(url_for("auth.confirm", token=token), follow_redirects=True) From c03bb4a2e08861c9f19f70b7b7d40cfa8b022584 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:31:02 -0500 Subject: [PATCH 25/48] Update test_user_api.py --- OpenOversight/tests/routes/test_user_api.py | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index 8c5406782..e8a138829 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -156,13 +156,13 @@ def test_admin_cannot_delete_other_admin(client, session): def test_admin_can_disable_user(client, session): with current_app.test_request_context(): - login_admin(client) + _, current_user = login_admin(client) # just need to make sure to not select the admin user user = User.query.filter_by(is_administrator=False).first() - assert not user.disabled_at - assert not user.disabled_by + assert user.disabled_at is None + assert user.disabled_by is None form = EditUserForm( is_disabled=True, @@ -178,16 +178,16 @@ def test_admin_can_disable_user(client, session): assert "updated!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert user.disabled_at - assert user.disabled_by + assert user.disabled_at is not None + assert user.disabled_by == current_user.id def test_admin_cannot_disable_self(client, session): with current_app.test_request_context(): _, user = login_admin(client) - assert not user.disabled_at - assert not user.disabled_by + assert user.disabled_at is None + assert user.disabled_by is None form = EditUserForm( is_disabled=True, @@ -203,8 +203,8 @@ def test_admin_cannot_disable_self(client, session): assert "You cannot edit your own account!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert user.disabled_at - assert user.disabled_by + assert user.disabled_at is not None + assert user.disabled_by == user.id def test_admin_can_enable_user(client, session): @@ -215,8 +215,8 @@ def test_admin_can_enable_user(client, session): user.disable_user(current_user.id) user = session.get(User, user.id) - assert user.disabled_at - assert user.disabled_by + assert user.disabled_at is not None + assert user.disabled_by == current_user.id form = EditUserForm( is_disabled=False, @@ -232,8 +232,8 @@ def test_admin_can_enable_user(client, session): assert "updated!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert not user.disabled_at - assert not user.disabled_by + assert user.disabled_at is None + assert user.disabled_by is None def test_admin_can_resend_user_confirmation_email(client, session): From 0d7669d0ebba9189104dac837033d46231022bc2 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:32:07 -0500 Subject: [PATCH 26/48] Update sort.html --- OpenOversight/app/templates/sort.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenOversight/app/templates/sort.html b/OpenOversight/app/templates/sort.html index c317fddab..40bae8240 100644 --- a/OpenOversight/app/templates/sort.html +++ b/OpenOversight/app/templates/sort.html @@ -21,7 +21,7 @@ {% block content %}

{% if current_user and current_user.is_authenticated %} - {% if image and not user.disabled_at and not user.disabled_by %} + {% if image and not current_user.disabled_at and not current_user.disabled_by %}

@@ -70,7 +70,7 @@

Picture to be sorted

- {% elif user.disabled_at and user.disabled_by %} + {% elif current_user.disabled_at and current_user.disabled_by %}

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

Date: Wed, 9 Oct 2024 16:40:35 -0500 Subject: [PATCH 27/48] Update views.py --- OpenOversight/app/auth/views.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index fc6429c64..b2dd874b6 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from http import HTTPMethod, HTTPStatus from flask import ( @@ -113,7 +114,12 @@ def register(): email=form.email.data, username=form.username.data, password=form.password.data, - approved=False if current_app.config["APPROVE_REGISTRATIONS"] else True, + approved_at=None + if current_app.config["APPROVE_REGISTRATIONS"] + else datetime.now(timezone.utc), + approved_by=None + if current_app.config["APPROVE_REGISTRATIONS"] + else current_user.id, ) db.session.add(user) db.session.commit() @@ -316,7 +322,9 @@ 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_at is not None + already_approved = ( + user.approved_at is not None and user.approved_by is not None + ) form.populate_obj(user) db.session.add(user) db.session.commit() From 486c4184accdd7c8365648c8210dcdaa0f21822f Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:43:36 -0500 Subject: [PATCH 28/48] Update views.py --- OpenOversight/app/auth/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index b2dd874b6..40a968d70 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -119,7 +119,7 @@ def register(): else datetime.now(timezone.utc), approved_by=None if current_app.config["APPROVE_REGISTRATIONS"] - else current_user.id, + else User.query.filter_by(is_administrator=True).first().id, ) db.session.add(user) db.session.commit() From 837849d71e6ef55f09f8bb6891a24a60f54fedec Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:50:54 -0500 Subject: [PATCH 29/48] Update test_user_api.py --- OpenOversight/tests/routes/test_user_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index e8a138829..c188366be 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -159,7 +159,9 @@ def test_admin_can_disable_user(client, session): _, current_user = login_admin(client) # just need to make sure to not select the admin user - user = User.query.filter_by(is_administrator=False).first() + user = User.query.filter_by( + is_administrator=False, disabled_at=None, disabled_by=None + ).first() assert user.disabled_at is None assert user.disabled_by is None From 48fbab5af22ae7ab448d602bb52110222a698c02 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:50:57 -0500 Subject: [PATCH 30/48] Update views.py --- OpenOversight/app/auth/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 40a968d70..3deef851d 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -325,6 +325,9 @@ def edit_user(user_id): already_approved = ( user.approved_at is not None and user.approved_by is not None ) + if form.is_disabled: + user.disable_user(current_user.id) + form.populate_obj(user) db.session.add(user) db.session.commit() From 851d9d7f84501156c7c4ea04b6a116da233d1c8a Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:54:34 -0500 Subject: [PATCH 31/48] Update test_user_api.py --- OpenOversight/tests/routes/test_user_api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index c188366be..52ed37533 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -186,10 +186,10 @@ def test_admin_can_disable_user(client, session): def test_admin_cannot_disable_self(client, session): with current_app.test_request_context(): - _, user = login_admin(client) + _, current_user = login_admin(client) - assert user.disabled_at is None - assert user.disabled_by is None + assert current_user.disabled_at is None + assert current_user.disabled_by is None form = EditUserForm( is_disabled=True, @@ -197,16 +197,16 @@ def test_admin_cannot_disable_self(client, session): ) rv = client.post( - url_for("auth.edit_user", user_id=user.id), + url_for("auth.edit_user", user_id=current_user.id), data=form.data, follow_redirects=True, ) assert "You cannot edit your own account!" in rv.data.decode(ENCODING_UTF_8) - user = session.get(User, user.id) - assert user.disabled_at is not None - assert user.disabled_by == user.id + user = session.get(User, current_user.id) + assert user.disabled_at is None + assert user.disabled_by is None def test_admin_can_enable_user(client, session): From 8ea8a46c310c630249110cbf18c6675c1e2628e5 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:54:36 -0500 Subject: [PATCH 32/48] Update views.py --- OpenOversight/app/auth/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 3deef851d..a04c37a6d 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -325,7 +325,7 @@ def edit_user(user_id): already_approved = ( user.approved_at is not None and user.approved_by is not None ) - if form.is_disabled: + if form.is_disabled.data: user.disable_user(current_user.id) form.populate_obj(user) From 08d65a9c7ab63cba01a088851b8533ed1d5c439d Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 23:18:05 -0500 Subject: [PATCH 33/48] Update test_user_api.py --- OpenOversight/tests/routes/test_user_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index 52ed37533..eee024586 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -234,8 +234,8 @@ def test_admin_can_enable_user(client, session): assert "updated!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert user.disabled_at is None - assert user.disabled_by is None + assert user.disabled_at is not None + assert user.disabled_by == current_user.id def test_admin_can_resend_user_confirmation_email(client, session): From b4463856b4d277e866ddb1efd40f5f37d5d619d3 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 23:34:33 -0500 Subject: [PATCH 34/48] Update database.py --- OpenOversight/app/models/database.py | 31 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index b98983cbc..6ffd345a5 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -925,24 +925,35 @@ def is_active(self): """Override UserMixin.is_active to prevent disabled users from logging in.""" return not self.disabled_at - def disable_user(self, disabling_user_id: int): - """Handle disabling logic.""" - if self.disabled_at or self.disabled_by: + def approve_user(self, approving_user_id: int): + """Handle approving logic.""" + if self.approved_at or self.approved_by: return False - self.disabled_at = datetime.now(timezone.utc) - self.disabled_by = disabling_user_id + self.approved_at = datetime.now(timezone.utc) + self.approved_by = approving_user_id db.session.add(self) db.session.commit() return True - def approve_user(self, approving_user_id: int): - """Handle approving logic.""" - if self.approved_at or self.approved_by: + def confirm_user(self, confirming_user_id: int): + """Handle confirming logic.""" + if self.confirmed_at or self.confirmed_by: return False - self.approved_at = datetime.now(timezone.utc) - self.approved_by = approving_user_id + self.confirmed_at = datetime.now(timezone.utc) + self.confirmed_by = confirming_user_id + db.session.add(self) + db.session.commit() + return True + + def disable_user(self, disabling_user_id: int): + """Handle disabling logic.""" + if self.disabled_at or self.disabled_by: + return False + + self.disabled_at = datetime.now(timezone.utc) + self.disabled_by = disabling_user_id db.session.add(self) db.session.commit() return True From 7bff35f425bca500c63a2b4111fe9a498fc8551c Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 23:34:42 -0500 Subject: [PATCH 35/48] Update views.py --- OpenOversight/app/auth/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index a04c37a6d..b49cd75d9 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -325,6 +325,12 @@ def edit_user(user_id): already_approved = ( user.approved_at is not None and user.approved_by is not None ) + if form.approved.data: + user.approve_user(current_user.id) + + if form.confirmed.data: + user.confirm_user(current_user.id) + if form.is_disabled.data: user.disable_user(current_user.id) From e77393d47523c9ee1121efc4b4103bbe75929487 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 23:34:56 -0500 Subject: [PATCH 36/48] Update test_user_api.py --- OpenOversight/tests/routes/test_user_api.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index eee024586..012ccdf29 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -1,4 +1,3 @@ -from datetime import datetime, timezone from http import HTTPMethod, HTTPStatus import pytest @@ -303,8 +302,8 @@ def test_admin_can_approve_user(client, session): session.commit() user = session.get(User, user.id) - assert not user.approved_by - assert not user.approved_at + assert user.approved_by is None + assert user.approved_at is None form = EditUserForm( approved=True, @@ -320,8 +319,8 @@ def test_admin_can_approve_user(client, session): assert "updated!" in rv.data.decode(ENCODING_UTF_8) user = session.get(User, user.id) - assert user.approved_at - assert user.approved_by + assert user.approved_at is not None + assert user.approved_by == current_user.id @pytest.mark.parametrize( @@ -357,15 +356,14 @@ def test_admin_approval_sends_confirmation_email( else: user.approved_at = None user.approved_by = None + session.commit() if currently_confirmed: - user.confirmed_at = datetime.now(timezone.utc) - user.confirmed_by = current_user.id + user.confirm_user(current_user.id) else: user.confirmed_at = None user.confirmed_by = None - - session.commit() + session.commit() user = session.get(User, user.id) if currently_approved: From 0d7dbc1c24ba5b350b6c5fb5a0446c9ea4a5b78a Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Wed, 9 Oct 2024 23:37:48 -0500 Subject: [PATCH 37/48] Update users.html --- OpenOversight/app/templates/auth/users.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenOversight/app/templates/auth/users.html b/OpenOversight/app/templates/auth/users.html index 23d215873..4e7b1cfaa 100644 --- a/OpenOversight/app/templates/auth/users.html +++ b/OpenOversight/app/templates/auth/users.html @@ -39,11 +39,11 @@

Users

{{ user.email }} - {% if user.disabled_at %} + {% if user.disabled_at and user.disabled_by %} Disabled - {% elif user.confirmed_at %} + {% elif user.confirmed_at and user.confirmed_by %} Active - {% elif user.approved_at %} + {% elif user.approved_at and user.approved_by %} Pending Confirmation {% else %} Pending Approval From 3393a51620017c26446cf0b9c105dd97189a0edd Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:11:37 -0500 Subject: [PATCH 38/48] Add KEY_APPROVE_REGISTRATIONS --- OpenOversight/app/auth/views.py | 11 ++++++----- OpenOversight/app/models/config.py | 3 ++- OpenOversight/app/utils/constants.py | 1 + OpenOversight/tests/routes/test_user_api.py | 6 +++--- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index b49cd75d9..b4bbb6bd6 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -35,6 +35,7 @@ ResetPasswordEmail, ) from OpenOversight.app.utils.auth import admin_required +from OpenOversight.app.utils.constants import KEY_APPROVE_REGISTRATIONS from OpenOversight.app.utils.forms import set_dynamic_default from OpenOversight.app.utils.general import validate_redirect_url @@ -71,7 +72,7 @@ def before_request(): def unconfirmed(): if current_user.is_anonymous or current_user.confirmed_at: return redirect(url_for("main.index")) - if current_app.config["APPROVE_REGISTRATIONS"] and not current_user.approved_at: + if current_app.config[KEY_APPROVE_REGISTRATIONS] and not current_user.approved_at: return render_template("auth/unapproved.html") else: return render_template("auth/unconfirmed.html") @@ -115,15 +116,15 @@ def register(): username=form.username.data, password=form.password.data, approved_at=None - if current_app.config["APPROVE_REGISTRATIONS"] + if current_app.config[KEY_APPROVE_REGISTRATIONS] else datetime.now(timezone.utc), approved_by=None - if current_app.config["APPROVE_REGISTRATIONS"] + if current_app.config[KEY_APPROVE_REGISTRATIONS] else User.query.filter_by(is_administrator=True).first().id, ) db.session.add(user) db.session.commit() - if current_app.config["APPROVE_REGISTRATIONS"]: + if current_app.config[KEY_APPROVE_REGISTRATIONS]: admins = User.query.filter_by(is_administrator=True).all() for admin in admins: EmailClient.send_email( @@ -341,7 +342,7 @@ def edit_user(user_id): # automatically send a confirmation email when approving an # unconfirmed user if ( - current_app.config["APPROVE_REGISTRATIONS"] + current_app.config[KEY_APPROVE_REGISTRATIONS] and not already_approved and user.approved_at and not user.confirmed_at diff --git a/OpenOversight/app/models/config.py b/OpenOversight/app/models/config.py index 26d8833bb..3e7f48fb4 100644 --- a/OpenOversight/app/models/config.py +++ b/OpenOversight/app/models/config.py @@ -1,6 +1,7 @@ import os from OpenOversight.app.utils.constants import ( + KEY_APPROVE_REGISTRATIONS, KEY_DATABASE_URI, KEY_ENV, KEY_ENV_DEV, @@ -80,7 +81,7 @@ def __init__(self): self.MAX_CONTENT_LENGTH = 50 * MEGABYTE # User settings - self.APPROVE_REGISTRATIONS = os.environ.get("APPROVE_REGISTRATIONS", False) + self.APPROVE_REGISTRATIONS = os.environ.get(KEY_APPROVE_REGISTRATIONS, False) class DevelopmentConfig(BaseConfig): diff --git a/OpenOversight/app/utils/constants.py b/OpenOversight/app/utils/constants.py index 37a598c07..91e2e6fb9 100644 --- a/OpenOversight/app/utils/constants.py +++ b/OpenOversight/app/utils/constants.py @@ -14,6 +14,7 @@ # Config Key Constants KEY_ALLOWED_EXTENSIONS = "ALLOWED_EXTENSIONS" +KEY_APPROVE_REGISTRATIONS = "APPROVE_REGISTRATIONS" KEY_DATABASE_URI = "SQLALCHEMY_DATABASE_URI" KEY_ENV = "ENV" KEY_ENV_DEV = "development" diff --git a/OpenOversight/tests/routes/test_user_api.py b/OpenOversight/tests/routes/test_user_api.py index 012ccdf29..4c3debebf 100644 --- a/OpenOversight/tests/routes/test_user_api.py +++ b/OpenOversight/tests/routes/test_user_api.py @@ -5,7 +5,7 @@ from OpenOversight.app.auth.forms import EditUserForm, LoginForm, RegistrationForm from OpenOversight.app.models.database import User -from OpenOversight.app.utils.constants import ENCODING_UTF_8 +from OpenOversight.app.utils.constants import ENCODING_UTF_8, KEY_APPROVE_REGISTRATIONS from OpenOversight.tests.conftest import AC_DEPT from OpenOversight.tests.constants import ( ADMIN_USER_EMAIL, @@ -260,7 +260,7 @@ def test_admin_can_resend_user_confirmation_email(client, session): def test_register_user_approval_required(client, session): - current_app.config["APPROVE_REGISTRATIONS"] = True + current_app.config[KEY_APPROVE_REGISTRATIONS] = True with current_app.test_request_context(): diceware_password = "operative hamster persevere verbalize curling" new_user_email = "jen@example.com" @@ -346,7 +346,7 @@ def test_admin_approval_sends_confirmation_email( client, session, ): - current_app.config["APPROVE_REGISTRATIONS"] = approve_registration_config + current_app.config[KEY_APPROVE_REGISTRATIONS] = approve_registration_config with current_app.test_request_context(): _, current_user = login_admin(client) From f022e77b97af027ecc64a96098964806b86a6a1b Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:37:57 -0500 Subject: [PATCH 39/48] Update views.py --- OpenOversight/app/auth/views.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index b4bbb6bd6..82d9b3d02 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -59,8 +59,8 @@ def static_routes(): def before_request(): if ( current_user.is_authenticated - and not current_user.confirmed_at - and not current_user.confirmed_by + and current_user.confirmed_at is not None + and current_user.confirmed_by is not None and request.endpoint and request.endpoint[:5] != "auth." and request.endpoint not in ["static", "bootstrap.static"] @@ -344,9 +344,8 @@ def edit_user(user_id): if ( current_app.config[KEY_APPROVE_REGISTRATIONS] and not already_approved - and user.approved_at - and not user.confirmed_at - and not current_user.confirmed_by + and user.confirmed_at is None + and current_user.confirmed_by is None ): admin_resend_confirmation(user) @@ -375,7 +374,7 @@ def delete_user(user_id): def admin_resend_confirmation(user): - if user.confirmed_at and current_user.confirmed_by: + if user.confirmed_at is not None and current_user.confirmed_by is not None: flash(f"User {user.username} is already confirmed.") else: token = user.generate_confirmation_token() From 4f0c6c14d0690725c4f38c7277d811727b11bd73 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:09:32 -0500 Subject: [PATCH 40/48] Update views.py --- OpenOversight/app/auth/views.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 82d9b3d02..e5977779d 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -59,8 +59,8 @@ def static_routes(): def before_request(): if ( current_user.is_authenticated - and current_user.confirmed_at is not None - and current_user.confirmed_by is not None + and current_user.confirmed_at is None + and current_user.confirmed_by is None and request.endpoint and request.endpoint[:5] != "auth." and request.endpoint not in ["static", "bootstrap.static"] @@ -72,7 +72,10 @@ def before_request(): def unconfirmed(): if current_user.is_anonymous or current_user.confirmed_at: return redirect(url_for("main.index")) - if current_app.config[KEY_APPROVE_REGISTRATIONS] and not current_user.approved_at: + if ( + current_app.config[KEY_APPROVE_REGISTRATIONS] + and current_user.approved_at is None + ): return render_template("auth/unapproved.html") else: return render_template("auth/unconfirmed.html") @@ -149,7 +152,7 @@ def register(): @auth.route("/confirm/", methods=[HTTPMethod.GET]) @login_required def confirm(token): - if current_user.confirmed_at and current_user.confirmed_by: + if current_user.confirmed_at is not None and current_user.confirmed_by is not None: return redirect(url_for("main.index")) if current_user.confirm( token, User.query.filter_by(is_administrator=True).first().id From 9800ebd6346dca9f0cc375224512f25bb4a7b05c Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:54:42 -0500 Subject: [PATCH 41/48] Update views.py --- OpenOversight/app/auth/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index e5977779d..024c2b724 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -348,7 +348,7 @@ def edit_user(user_id): current_app.config[KEY_APPROVE_REGISTRATIONS] and not already_approved and user.confirmed_at is None - and current_user.confirmed_by is None + and user.confirmed_by is None ): admin_resend_confirmation(user) @@ -377,7 +377,7 @@ def delete_user(user_id): def admin_resend_confirmation(user): - if user.confirmed_at is not None and current_user.confirmed_by is not None: + if user.confirmed_at and current_user.confirmed_by: flash(f"User {user.username} is already confirmed.") else: token = user.generate_confirmation_token() From d05da427e470ebde16579ac89efe3735d66e770c Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:58:11 -0500 Subject: [PATCH 42/48] Update views.py --- OpenOversight/app/auth/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 024c2b724..4c22f0291 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -59,8 +59,8 @@ def static_routes(): def before_request(): if ( current_user.is_authenticated - and current_user.confirmed_at is None - and current_user.confirmed_by is None + and not current_user.confirmed_at + and not current_user.confirmed_by and request.endpoint and request.endpoint[:5] != "auth." and request.endpoint not in ["static", "bootstrap.static"] From 4588b1022f895e1722dee8e853268cc4fb193049 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:59:21 -0500 Subject: [PATCH 43/48] Update views.py --- OpenOversight/app/auth/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 4c22f0291..7f40c73a9 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -70,7 +70,9 @@ def before_request(): @auth.route("/unconfirmed") def unconfirmed(): - if current_user.is_anonymous or current_user.confirmed_at: + if current_user.is_anonymous or ( + current_user.confirmed_at and current_user.confirmed_by + ): return redirect(url_for("main.index")) if ( current_app.config[KEY_APPROVE_REGISTRATIONS] From 42f7d278473a8a3c0649b0622aeaef24dd69f8ad Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:00:30 -0500 Subject: [PATCH 44/48] Update views.py --- OpenOversight/app/auth/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 7f40c73a9..439ffabc4 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -76,7 +76,8 @@ def unconfirmed(): return redirect(url_for("main.index")) if ( current_app.config[KEY_APPROVE_REGISTRATIONS] - and current_user.approved_at is None + and not current_user.approved_at + and not current_user.approved_by ): return render_template("auth/unapproved.html") else: From c9ad779853dfddd9d3a779ca9efc63268b7ca65d Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:07:01 -0500 Subject: [PATCH 45/48] Update views.py --- OpenOversight/app/auth/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 439ffabc4..458fc2b4d 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -155,7 +155,7 @@ def register(): @auth.route("/confirm/", methods=[HTTPMethod.GET]) @login_required def confirm(token): - if current_user.confirmed_at is not None and current_user.confirmed_by is not None: + if current_user.confirmed_at and current_user.confirmed_by: return redirect(url_for("main.index")) if current_user.confirm( token, User.query.filter_by(is_administrator=True).first().id @@ -350,8 +350,8 @@ def edit_user(user_id): if ( current_app.config[KEY_APPROVE_REGISTRATIONS] and not already_approved - and user.confirmed_at is None - and user.confirmed_by is None + and not user.confirmed_at + and not user.confirmed_by ): admin_resend_confirmation(user) From 83263fec8e79f06dc510013a2cd61cea9375f7c5 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:07:55 -0500 Subject: [PATCH 46/48] Update views.py --- OpenOversight/app/auth/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 458fc2b4d..c1af3ceb6 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -350,6 +350,8 @@ def edit_user(user_id): if ( current_app.config[KEY_APPROVE_REGISTRATIONS] and not already_approved + and not user.approved_at + and not user.approved_by and not user.confirmed_at and not user.confirmed_by ): From fc20b27e94686e9b6290cd228ff6956ef2c10560 Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:13:53 -0500 Subject: [PATCH 47/48] Update views.py --- OpenOversight/app/auth/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index c1af3ceb6..8a3fc0a26 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -350,8 +350,8 @@ def edit_user(user_id): if ( current_app.config[KEY_APPROVE_REGISTRATIONS] and not already_approved - and not user.approved_at - and not user.approved_by + and user.approved_at + and user.approved_by and not user.confirmed_at and not user.confirmed_by ): From 709b8b066f98e5f45e31b1714cac11f4100501da Mon Sep 17 00:00:00 2001 From: michplunkett <5885605+michplunkett@users.noreply.github.com> Date: Tue, 15 Oct 2024 22:21:42 -0700 Subject: [PATCH 48/48] Update test_models.py --- OpenOversight/tests/test_models.py | 94 ++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/OpenOversight/tests/test_models.py b/OpenOversight/tests/test_models.py index a4c805de0..8919ba4c1 100644 --- a/OpenOversight/tests/test_models.py +++ b/OpenOversight/tests/test_models.py @@ -2,6 +2,7 @@ import random import time +import pytest from pytest import raises from sqlalchemy import and_ from sqlalchemy.exc import IntegrityError @@ -626,3 +627,96 @@ def test_officer_incident_sort_order(mockdata, session): ) assert officer.incidents == sorted_incidents + + +def test_user_confirmed_constraint(mockdata, session, faker): + email = faker.company_email() + + session.add( + User( + email=email, + username=faker.word(), + password=faker.word(), + ) + ) + session.commit() + + user_one = User.query.filter_by(email=email).one() + + assert user_one.confirm_user(1) is True + assert user_one.confirmed_at is not None + assert user_one.confirmed_by is not None + session.add(user_one) + session.commit() + + user_two = User.query.filter_by(email=email).one() + assert user_two.confirm_user(1) is False + + user_two.confirmed_by = None + session.add(user_two) + + # Confirm that both _at and _by fields must be not None + with pytest.raises(IntegrityError): + session.commit() + + +def test_user_approved_constraint(mockdata, session, faker): + email = faker.company_email() + + session.add( + User( + email=email, + username=faker.word(), + password=faker.word(), + ) + ) + session.commit() + + user_one = User.query.filter_by(email=email).one() + + assert user_one.approve_user(1) is True + assert user_one.approved_at is not None + assert user_one.approved_by is not None + session.add(user_one) + session.commit() + + user_two = User.query.filter_by(email=email).one() + assert user_two.approve_user(1) is False + + user_two.approved_by = None + session.add(user_two) + + # Confirm that both _at and _by fields must be not None + with pytest.raises(IntegrityError): + session.commit() + + +def test_user_disabled_constraint(mockdata, session, faker): + email = faker.company_email() + + session.add( + User( + email=email, + username=faker.word(), + password=faker.word(), + ) + ) + session.commit() + + user_one = User.query.filter_by(email=email).one() + + assert user_one.disable_user(1) is True + assert user_one.disabled_at is not None + assert user_one.disabled_by is not None + session.add(user_one) + session.commit() + + user_two = User.query.filter_by(email=email).one() + assert user_two.disable_user(1) is False + + user_two.disabled_by = None + session.add(user_two) + + # Confirm that both _at and _by fields must be not None + with pytest.raises(IntegrityError): + session.commit()