diff --git a/app/__init__.py b/app/__init__.py index 200a280..ba0b71c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -13,7 +13,7 @@ from flask_apscheduler import APScheduler from app.luclient import query_cdclient, register_luclient_jinja_helpers from app.commands import init_db, init_accounts, load_property, gen_image_cache, gen_model_cache -from app.models import Account, AccountInvitation +from app.models import Account, AccountInvitation, AuditLog import logging from logging.handlers import RotatingFileHandler @@ -245,3 +245,9 @@ def gm_level(gm_level): return func(*args, **kwargs) return wrapper return decorator + +def log_audit(message): + AuditLog( + account_id=current_user.id, + action=message + ).save() diff --git a/app/accounts.py b/app/accounts.py index c25ff21..ca9c285 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -6,7 +6,7 @@ import datetime import time from app.models import Account, AccountInvitation, db from app.schemas import AccountSchema -from app import gm_level +from app import gm_level, log_audit from app.forms import EditGMLevelForm accounts_blueprint = Blueprint('accounts', __name__) @@ -46,8 +46,10 @@ def edit_gm_level(id): form = EditGMLevelForm() if form.validate_on_submit(): + log_audit(f"Changed ({account_data.id}){account_data.username}'s GM Level from {account_data.gm_level} to {form.gm_level.data}") account_data.gm_level = form.gm_level.data account_data.save() + return redirect(url_for('accounts.view', id=account_data.id)) form.gm_level.data = account_data.gm_level @@ -63,8 +65,10 @@ def lock(id): account.locked = not account.locked account.save() if account.locked: + log_audit(f"Locked ({account.id}){account.username}") flash("Locked Account", "danger") else: + log_audit(f"Unlocked ({account.id}){account.username}") flash("Unlocked account", "success") return redirect(request.referrer if request.referrer else url_for("main.index")) @@ -77,8 +81,10 @@ def ban(id): account.banned = not account.banned account.save() if account.banned: + log_audit(f"Banned ({account.id}){account.username}") flash("Banned Account", "danger") else: + log_audit(f"Unbanned ({account.id}){account.username}") flash("Unbanned account", "success") return redirect(request.referrer if request.referrer else url_for("main.index")) @@ -90,10 +96,12 @@ def mute(id, days=0): account = Account.query.filter(Account.id == id).first() if days == "0": account.mute_expire = 0 + log_audit(f"Unmuted ({account.id}){account.username}") flash("Unmuted Account", "success") else: muted_intil = datetime.datetime.now() + datetime.timedelta(days=int(days)) account.mute_expire = muted_intil.timestamp() + log_audit(f"Muted ({account.id}){account.username} for {days} days") flash(f"Muted account for {days} days", "danger") account.save() diff --git a/app/characters.py b/app/characters.py index f6d53a9..2223119 100644 --- a/app/characters.py +++ b/app/characters.py @@ -5,7 +5,7 @@ from datatables import ColumnDT, DataTables import datetime, time from app.models import CharacterInfo, CharacterXML, Account, db from app.schemas import CharacterInfoSchema -from app import gm_level +from app import gm_level, log_audit import xmltodict character_blueprint = Blueprint('characters', __name__) @@ -26,16 +26,19 @@ def approve_name(id, action): character = CharacterInfo.query.filter(CharacterInfo.id == id).first() if action == "approve": + log_audit(f"Approved ({character.id}){character.pending_name} from {character.name}") + flash( + f"Approved ({character.id}){character.pending_name} from {character.name}", + "success" + ) if character.pending_name: character.name = character.pending_name character.pending_name = "" character.needs_rename = False - flash( - f"Approved name {character.name}", - "success" - ) + elif action == "rename": character.needs_rename = True + log_audit(f"Marked character ({character.id}){character.name} (Pending Name: {character.pending_name if character.pending_name else 'None'}) as needing Rename") flash( f"Marked character {character.name} (Pending Name: {character.pending_name if character.pending_name else 'None'}) as needing Rename", "danger" @@ -146,6 +149,9 @@ def restrict(id, bit): abort(404) return + log_audit(f"Updated ({character_data.id}){character_data.name}'s permission map to \ + {character_data.permission_map ^ (1 << int(bit))} from {character_data.permission_map}") + character_data.permission_map ^= (1 << int(bit)) character_data.save() diff --git a/app/mail.py b/app/mail.py index 6f22ded..33e073c 100644 --- a/app/mail.py +++ b/app/mail.py @@ -3,7 +3,7 @@ from flask_user import login_required, current_user from app.models import db, Mail, CharacterInfo from datatables import ColumnDT, DataTables from app.forms import SendMailForm -from app import gm_level +from app import gm_level, log_audit from app.luclient import translate_from_locale, query_cdclient import time @@ -29,6 +29,7 @@ def send(): if form.attachment.data != "0" and form.attachment_count.data == 0: form.attachment_count.data = 1 if form.recipient.data == "0": + log_audit(f"Sending {form.subject.data}: {form.body.data} to All Characters with {form.attachment_count.data} of item {form.attachment.data}") for character in CharacterInfo.query.all(): Mail( sender_id = 0, @@ -42,6 +43,7 @@ def send(): attachment_lot = form.attachment.data, attachment_count = form.attachment_count.data ).save() + log_audit(f"Sent {form.subject.data}: {form.body.data} to ({character.id}){character.name} with {form.attachment_count.data} of item {form.attachment.data}") else: Mail( sender_id = 0, @@ -55,6 +57,7 @@ def send(): attachment_lot = form.attachment.data, attachment_count = form.attachment_count.data ).save() + log_audit(f"Sent {form.subject.data}: {form.body.data} to ({form.recipient.data}){CharacterInfo.query.filter(CharacterInfo.id == form.recipient.data).first().name} with {form.attachment_count.data} of item {form.attachment.data}") flash("Sent Mail", "success") return redirect(url_for('mail.send')) diff --git a/app/models.py b/app/models.py index b003014..df557a3 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate -from flask_user import UserMixin +from flask_user import UserMixin, current_user from wtforms import ValidationError import logging @@ -1010,3 +1010,42 @@ class Reports(db.Model): def delete(self): db.session.delete(self) db.session.commit() + +class AuditLog(db.Model): + __tablename__ = 'audit_logs' + id = db.Column( + mysql.INTEGER, + primary_key=True + ) + + account_id = db.Column( + db.Integer(), + db.ForeignKey(Account.id, ondelete='CASCADE'), + nullable=False, + ) + + account = db.relationship( + 'Account', + backref="audit_logs", + passive_deletes=True + ) + + action = db.Column( + mysql.TEXT, + nullable=False + ) + + date = db.Column( + mysql.TIMESTAMP, + nullable=False, + server_default=db.func.now() + ) + + def save(self): + db.session.add(self) + db.session.commit() + db.session.refresh(self) + + def delete(self): + db.session.delete(self) + db.session.commit() diff --git a/app/moderation.py b/app/moderation.py index a20eaf3..a9cd9d6 100644 --- a/app/moderation.py +++ b/app/moderation.py @@ -3,7 +3,7 @@ from flask_user import login_required from app.models import PetNames, db from datatables import ColumnDT, DataTables from app.forms import CreatePlayKeyForm, EditPlayKeyForm -from app import gm_level +from app import gm_level, log_audit moderation_blueprint = Blueprint('moderation', __name__) @@ -23,6 +23,7 @@ def approve_pet(id): pet_data = PetNames.query.filter(PetNames.id == id).first() pet_data.approved = 2 + log_audit(f"Approved pet name {pet_data.pet_name}") flash(f"Approved pet name {pet_data.pet_name}", "success") pet_data.save() return redirect(request.referrer if request.referrer else url_for("main.index")) @@ -36,6 +37,7 @@ def reject_pet(id): pet_data = PetNames.query.filter(PetNames.id == id).first() pet_data.approved = 0 + log_audit(f"Rejected pet name {pet_data.pet_name}") flash(f"Rejected pet name {pet_data.pet_name}", "danger") pet_data.save() return redirect(request.referrer if request.referrer else url_for("main.index")) diff --git a/app/play_keys.py b/app/play_keys.py index f1cab6a..fcebf0a 100644 --- a/app/play_keys.py +++ b/app/play_keys.py @@ -3,7 +3,7 @@ from flask_user import login_required, current_user from app.models import Account, AccountInvitation, PlayKey, db from datatables import ColumnDT, DataTables from app.forms import CreatePlayKeyForm, EditPlayKeyForm -from app import gm_level +from app import gm_level, log_audit play_keys_blueprint = Blueprint('play_keys', __name__) @@ -21,6 +21,7 @@ def index(): @gm_level(9) def create(count=1, uses=1): PlayKey.create(count=count, uses=uses) + log_audit(f"Created {count} Play Key(s) with {uses} uses!") flash(f"Created {count} Play Key(s) with {uses} uses!", "success") return redirect(url_for('play_keys.index')) @@ -32,6 +33,8 @@ def bulk_create(): form = CreatePlayKeyForm() if form.validate_on_submit(): PlayKey.create(count=form.count.data, uses=form.uses.data) + log_audit(f"Created {form.count.data} Play Key(s) with {form.uses.data} uses!") + flash(f"Created {form.count.data} Play Key(s) with {form.uses.data} uses!", "success") return redirect(url_for('play_keys.index')) return render_template('play_keys/bulk.html.j2', form=form) @@ -43,6 +46,7 @@ def bulk_create(): def delete(id): key = PlayKey.query.filter(PlayKey.id == id).first() associated_accounts = Account.query.filter(Account.play_key_id==id).all() + log_audit(f"Deleted Play Key {key.key_string}") flash(f"Deleted Play Key {key.key_string}", "danger") key.delete() return redirect(url_for('play_keys.index')) @@ -56,10 +60,16 @@ def edit(id): form = EditPlayKeyForm() if form.validate_on_submit(): + log_audit(f"Updated Play key {key.id} \ + Uses: {key.key_uses}:{form.uses.data} \ + Active: {key.active}:{form.active.data} \ + Notes: {key.notes}:{form.notes.data} \ + ") key.key_uses = form.uses.data key.active = form.active.data key.notes = form.notes.data key.save() + return redirect(url_for('play_keys.index')) form.uses.data = key.key_uses diff --git a/app/properties.py b/app/properties.py index dd78bd4..f8bdfa1 100644 --- a/app/properties.py +++ b/app/properties.py @@ -16,7 +16,7 @@ from datatables import ColumnDT, DataTables import time from app.models import Property, db, UGC, CharacterInfo, PropertyContent, Account from app.schemas import PropertySchema -from app import gm_level +from app import gm_level, log_audit from app.luclient import query_cdclient import zlib @@ -50,25 +50,29 @@ def approve(id): property_data.rejection_reason = "" if property_data.mod_approved: - flash( - f"""Approved Property + message = f"""Approved Property {property_data.name if property_data.name else query_cdclient( 'select DisplayDescription from ZoneTable where zoneID = ?', [property_data.zone_id], one=True )[0]} - from {CharacterInfo.query.filter(CharacterInfo.id==property_data.owner_id).first().name}""", + from {CharacterInfo.query.filter(CharacterInfo.id==property_data.owner_id).first().name}""" + log_audit(message) + flash( + message, "success" ) else: - flash( - f"""Unapproved Property + message = f"""Unapproved Property {property_data.name if property_data.name else query_cdclient( 'select DisplayDescription from ZoneTable where zoneID = ?', [property_data.zone_id], one=True )[0]} - from {CharacterInfo.query.filter(CharacterInfo.id==property_data.owner_id).first().name}""", + from {CharacterInfo.query.filter(CharacterInfo.id==property_data.owner_id).first().name}""" + log_audit(message) + flash( + message, "danger" ) diff --git a/migrations/versions/3132aaef7413_fix_nullables.py b/migrations/versions/3132aaef7413_fix_nullables.py new file mode 100644 index 0000000..b20606e --- /dev/null +++ b/migrations/versions/3132aaef7413_fix_nullables.py @@ -0,0 +1,38 @@ +"""fix nullables + +Revision ID: 3132aaef7413 +Revises: bd908969d8fe +Create Date: 2022-02-11 21:51:58.479066 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '3132aaef7413' +down_revision = 'bd908969d8fe' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('audit_logs', 'account_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + op.alter_column('audit_logs', 'action', + existing_type=mysql.TEXT(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('audit_logs', 'action', + existing_type=mysql.TEXT(), + nullable=True) + op.alter_column('audit_logs', 'account_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + # ### end Alembic commands ### diff --git a/migrations/versions/bd908969d8fe_add_audit_log_table.py b/migrations/versions/bd908969d8fe_add_audit_log_table.py new file mode 100644 index 0000000..f704cd7 --- /dev/null +++ b/migrations/versions/bd908969d8fe_add_audit_log_table.py @@ -0,0 +1,35 @@ +"""Add audit_log table + +Revision ID: bd908969d8fe +Revises: aee4c6c24811 +Create Date: 2022-02-11 21:48:03.798474 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'bd908969d8fe' +down_revision = 'aee4c6c24811' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_logs', + sa.Column('id', mysql.INTEGER(), nullable=False), + sa.Column('account_id', sa.Integer(), nullable=True), + sa.Column('action', mysql.TEXT(), nullable=True), + sa.Column('date', mysql.TIMESTAMP(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('audit_logs') + # ### end Alembic commands ###