【问题标题】:How to log production database changes made via the Django shell如何记录通过 Django shell 所做的生产数据库更改
【发布时间】:2022-01-10 19:50:11
【问题描述】:

我想自动生成某种日志,记录在生产环境中通过 Django shell 所做的所有数据库更改。

我们使用架构和数据迁移脚本来更改生产数据库,并且它们是受版本控制的。因此,如果我们引入了一个错误,就很容易追溯它。但是,如果团队中的开发人员通过 Django shell 更改了数据库,从而引入了一个问题,目前我们只能希望他们记住他们所做的事情或/并且我们可以在 Python shell 历史记录中找到他们的命令。

示例。让我们假设以下代码是由团队中的开发人员通过 Python shell 执行的:

>>> tm = TeamMembership.objects.get(person=alice)
>>> tm.end_date = date(2022,1,1)
>>> tm.save()

它会更改数据库中的团队成员资格对象。我想以某种方式记录它。

我知道有一堆Django packages related to audit logging,但我只对从 Django shell 触发的更改感兴趣,我想记录更新数据的 Python 代码。

所以我想到的问题:

  • 我可以记录来自 IPython 的语句,但我怎么知道是哪一个访问了数据库?
  • 我可以收听所有模型的pre_save 信号以了解数据是否更改,但我如何知道源是否来自 Python shell?我怎么知道最初的 Python 语句是什么?

【问题讨论】:

  • 嘿,我不认为这是值得回答的,所以你去:chase-seibert.github.io/blog/2012/10/26/…。这基本上是关于通过覆盖 shell 命令来覆盖 shell 中的日志记录,我认为如果你对文章中的那个 shell 命令稍加修改,它会给你你需要的东西
  • 谢谢@MichałDarowny,它很有帮助。不过,我仍然缺少一些联系。很高兴我能捕捉到 IPython 的历史,但我正在寻找一种方法来了解它是否改变了生产数据。很多时候,我们使用 shell 只是为了调试客户工单。历史的那部分现在已经无关紧要了。
  • @aaron 理想情况下,我想记录触发数据库更改的 Python 语句。与 Django ORM 生成的原始 SQL 查询相比,这将为我提供更多关于开发人员想要实现的内容的上下文。
  • @aaron 是的,这就是我目前最终要做的事情。然而大多数时候,我们使用 shell 只是为了获取数据,而不是改变它。所以噪音很大。如果我能以某种方式标记更改数据的命令,那就太好了。目前我能想到的就是在日志中寻找所有出现的模式,例如.save().delete() 等。

标签: python django audit-logging django-shell


【解决方案1】:

如果进行了任何数据库更改,此解决方案会记录会话中的所有命令。

如何检测数据库更改

包装execute_sqlSQLInsertCompilerSQLUpdateCompilerSQLDeleteCompiler

SQLDeleteCompiler.execute_sql 返回一个游标包装器。

from django.db.models.sql.compiler import SQLInsertCompiler, SQLUpdateCompiler, SQLDeleteCompiler

changed = False

def check_changed(func):
    def _func(*args, **kwargs):
        nonlocal changed
        result = func(*args, **kwargs)
        if not changed and result:
            changed = not hasattr(result, 'cursor') or bool(result.cursor.rowcount)
        return result
    return _func

SQLInsertCompiler.execute_sql = check_changed(SQLInsertCompiler.execute_sql)
SQLUpdateCompiler.execute_sql = check_changed(SQLUpdateCompiler.execute_sql)
SQLDeleteCompiler.execute_sql = check_changed(SQLDeleteCompiler.execute_sql)

如何记录通过 Django shell 发出的命令

atexit.register() 一个执行 readline.write_history_file() 的退出处理程序。

import atexit
import readline

def exit_handler():
    filename = 'history.py'
    readline.write_history_file(filename)

atexit.register(exit_handler)

IPython

通过比较HistoryAccessor.get_last_session_id()来判断是否使用了IPython。

import atexit
import io
import readline

ipython_last_session_id = None
try:
    from IPython.core.history import HistoryAccessor
except ImportError:
    pass
else:
    ha = HistoryAccessor()
    ipython_last_session_id = ha.get_last_session_id()

def exit_handler():
    filename = 'history.py'
    if ipython_last_session_id and ipython_last_session_id != ha.get_last_session_id():
        cmds = '\n'.join(cmd for _, _, cmd in ha.get_range(ha.get_last_session_id()))
        with io.open(filename, 'a', encoding='utf-8') as f:
            f.write(cmds)
            f.write('\n')
    else:
        readline.write_history_file(filename)

atexit.register(exit_handler)

把它们放在一起

在manage.py中execute_from_command_line(sys.argv)之前添加以下内容。

if sys.argv[1] == 'shell':
    import atexit
    import io
    import readline

    from django.db.models.sql.compiler import SQLInsertCompiler, SQLUpdateCompiler, SQLDeleteCompiler

    changed = False

    def check_changed(func):
        def _func(*args, **kwargs):
            nonlocal changed
            result = func(*args, **kwargs)
            if not changed and result:
                changed = not hasattr(result, 'cursor') or bool(result.cursor.rowcount)
            return result
        return _func

    SQLInsertCompiler.execute_sql = check_changed(SQLInsertCompiler.execute_sql)
    SQLUpdateCompiler.execute_sql = check_changed(SQLUpdateCompiler.execute_sql)
    SQLDeleteCompiler.execute_sql = check_changed(SQLDeleteCompiler.execute_sql)

    ipython_last_session_id = None
    try:
        from IPython.core.history import HistoryAccessor
    except ImportError:
        pass
    else:
        ha = HistoryAccessor()
        ipython_last_session_id = ha.get_last_session_id()

    def exit_handler():
        if changed:
            filename = 'history.py'
            if ipython_last_session_id and ipython_last_session_id != ha.get_last_session_id():
                cmds = '\n'.join(cmd for _, _, cmd in ha.get_range(ha.get_last_session_id()))
                with io.open(filename, 'a', encoding='utf-8') as f:
                    f.write(cmds)
                    f.write('\n')
            else:
                readline.write_history_file(filename)

    atexit.register(exit_handler)

【讨论】:

  • 感谢您为此解决方案付出的努力。我刚刚试了一下,它非常接近我想要的。这是一个很大的启发,我不知道所有这些内部 API。请接受我的赏金。
【解决方案2】:

基于the answer of aaronthe built-in IPython magic %logstart的实现,这就是我们最终想出的方案。

如果任何命令通过 Django ORM 触发了数据库写入,则最后一次 IPython 会话的所有命令都会记录在历史文件中。

这是生成的历史文件的摘录:

❯ cat ~/.python_shell_write_history
# Thu, 27 Jan 2022 16:20:28
#
# New Django shell session started
#
# Thu, 27 Jan 2022 16:20:28
from people.models import *
# Thu, 27 Jan 2022 16:20:28
p = Person.objects.first()
# Thu, 27 Jan 2022 16:20:28
p
#[Out]# <Person: Test Albero Jose Maria>
# Thu, 27 Jan 2022 16:20:28
p.email
#[Out]# 'test-albero-jose-maria@gmail.com'
# Thu, 27 Jan 2022 16:20:28
p.save()

现在是我们的manage.py

#!/usr/bin/env python
import os
import sys


def shell_audit(logfname: str) -> None:
    """If any of the Python shell commands changed the Django database during the
    session, capture all commands in a logfile for future analysis."""
    import atexit

    from django.db.models.sql.compiler import (
        SQLDeleteCompiler,
        SQLInsertCompiler,
        SQLUpdateCompiler,
    )

    changed = False

    def check_changed(func):
        def _func(*args, **kwargs):
            nonlocal changed
            result = func(*args, **kwargs)
            if not changed and result:
                changed = not hasattr(result, "cursor") or bool(result.cursor.rowcount)
            return result

        return _func

    SQLInsertCompiler.execute_sql = check_changed(SQLInsertCompiler.execute_sql)
    SQLUpdateCompiler.execute_sql = check_changed(SQLUpdateCompiler.execute_sql)
    SQLDeleteCompiler.execute_sql = check_changed(SQLDeleteCompiler.execute_sql)

    def exit_handler():
        if not changed:
            return None

        from IPython.core import getipython

        shell = getipython.get_ipython()
        if not shell:
            return None

        logger = shell.logger

        # Logic borrowed from %logstart (IPython.core.magics.logging)
        loghead = ""
        log_session_head = "#\n# New Django shell session started\n#\n"
        logmode = "append"
        log_output = True
        timestamp = True
        log_raw_input = False
        logger.logstart(logfname, loghead, logmode, log_output, timestamp, log_raw_input)

        log_write = logger.log_write
        input_hist = shell.history_manager.input_hist_parsed
        output_hist = shell.history_manager.output_hist_reprs

        log_write(log_session_head)
        for n in range(1, len(input_hist)):
            log_write(input_hist[n].rstrip() + "\n")
            if n in output_hist:
                log_write(output_hist[n], kind="output")

    atexit.register(exit_handler)


if __name__ == "__main__":
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
    try:
        from django.core.management import execute_from_command_line
    except ImportError:
        # The above import may fail for some other reason. Ensure that the
        # issue is really that Django is missing to avoid masking other
        # exceptions on Python 2.
        try:
            import django  # noqa: F401
        except ImportError:
            raise ImportError(
                "Couldn't import Django. Are you sure it's installed and "
                "available on your PYTHONPATH environment variable? Did you "
                "forget to activate a virtual environment?"
            )
        raise
    if sys.argv[1] == "shell":
        logfname = os.path.expanduser("~/.python_shell_write_history")
        shell_audit(logfname)
    execute_from_command_line(sys.argv)

【讨论】:

    【解决方案3】:

    我会考虑这样的事情:

    见:

    https://docs.python.org/3/library/readline.html#readline.get_history_length

    https://docs.python.org/3/library/readline.html#readline.get_history_item

    基本上,您可以调用get_history_length 两次:在终端会话的开始和结束时。这将允许您使用get_history_item 获得更改发生位置的相关行。您最终可能会获得比您实际需要的更多的历史记录,但至少有足够的上下文来了解正在发生的事情。

    【讨论】:

    • 我不确定我是否理解 django-revision 如何在这里发挥作用。你能澄清一下吗?
    • 从我正在阅读的内容来看,django-revision 为您提供了对模型实例的版本控制。我在想的是你可以注册你的模型来使用 django-revision。当您使用 shell 进行更改然后手动创建新修订时,绝不会在运行的应用程序上自动创建修订。这将允许您快速回滚到使用 shell 触及的先前实体并查看更改了哪些字段(差异:更改之前,更改之后)。
    • 这里 django-revision 的第二个应用是只在 atexit 创建新修订时记录命令的历史记录。我想 django-revision 支持这样的东西以避免创建空的修订。这将允许您仅存储相关的历史记录部分 - 何时保存模型。
    • 有趣的想法,谢谢!
    【解决方案4】:

    您可以使用 django 的 receiver 注释。

    例如,如果你想检测任何对save方法的调用,你可以这样做:

    from django.db.models.signals import post_save
    from django.dispatch import receiver
    import logging
    
    @receiver(post_save)
    def logg_save(sender, instance, **kwargs):
        logging.debug("whatever you want to log")
    

    更多documentation for the signals

    【讨论】:

    • 这将如何记录 django shell 发出的命令?
    • 感谢您的想法。我考虑过这种方法。我想过用pre_save信号,但是基本一样。我在这里想念的正是@jlandercy 所要求的。我如何知道在 shell 中发出了什么 Python 命令?
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-04-01
    • 2012-12-08
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多