Search code examples
pythondjangoaudit-loggingdjango-shell

How to log production database changes made via the Django shell


I would like to automatically generate some sort of log of all the database changes that are made via the Django shell in the production environment.

We use schema and data migration scripts to alter the production database and they are version controlled. Therefore if we introduce a bug, it's easy to track it back. But if a developer in the team changes the database via the Django shell which then introduces an issue, at the moment we can only hope that they remember what they did or/and we can find their commands in the Python shell history.

Example. Let's imagine that the following code was executed by a developer in the team via the Python shell:

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

It changes a team membership object in the database. I would like to log this somehow.

I'm aware that there are a bunch of Django packages related to audit logging, but I'm only interested in the changes that are triggered from the Django shell, and I want to log the Python code that updated the data.

So the questions I have in mind:

  • I can log the statements from IPython but how do I know which one touched the database?
  • I can listen to the pre_save signal for all model to know if data changes, but how do I know if the source was from the Python shell? How do I know what was the original Python statement?

Solution

  • This solution logs all commands in the session if any database changes were made.

    How to detect database changes

    Wrap execute_sql of SQLInsertCompiler, SQLUpdateCompiler and SQLDeleteCompiler.

    SQLDeleteCompiler.execute_sql returns a cursor wrapper.

    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)
    

    How to log commands made via the Django shell

    atexit.register() an exit handler that does readline.write_history_file().

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

    IPython

    Check whether IPython was used by comparing HistoryAccessor.get_last_session_id().

    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)
    

    Put it all together

    Add the following in manage.py before 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)