← Back to blog

· 7 min read

An activity log for Django that knows who really clicked, even under impersonation

How we built one audit and activity log for every module of the Brava Roof Tile Pro Portal: the real actor under impersonation, logging from Celery, readable sentences and honest gaps.

  • django
  • audit-log
  • python
  • drf
  • celery
  • security

Every B2B product reaches the day someone asks "who changed this quote?" and nobody can answer. In the Brava Roof Tile Pro Portal that question had a twist. Support staff can impersonate a contractor to help them, which means the person logged in and the person who clicked are not the same. A log that stores request.user would blame the contractor for whatever support did.

This post is about the activity log we built for the portal. It now spans 17 modules (quotes, leads, orders, pricebooks, proposals, chat, DocScan and more), and it is also where the analytics dashboard counts engagement. I will cover how each piece works and what I would still change.

What the log has to answer

The requirements came from real questions, not from a compliance checklist:

  • Who did it, and on whose behalf, when an admin was acting as someone else.
  • What happened, in business terms ("updated the material list for quote Q-1042"), not "PATCH /api/quotes/88".
  • Which record it touched, so the feed can link to it.
  • Events that are not database writes: viewed, shared, downloaded, converted a lead to a quote.
  • Events from code with no HTTP request: Celery tasks, the PDF extraction pipeline, websocket consumers.
  • Contractors see their own activity feed, so every row has to read like a sentence.

Why the usual tools fall short

Table comparing request middleware, model signals, ad hoc view logging and module loggers on context, intent and running outside HTTP
What each approach can see.

Request middleware can see the IP, the user agent and the impersonation flag. It cannot see intent. A URL and a method are not "shared the proposal with two people", and logging request bodies drags sensitive data into the table.

Model signals and row history (the django-simple-history style) fire anywhere a model is saved, including Celery. They record field diffs, but not why, and they miss every event that is not a save. A "viewed" event is a read.

Ad hoc calls inside views know the intent and have the request, but they drift. We had that phase. Action names and wording diverged until a refactor moved everything into per-module loggers.

The design we ended up with is the bottom row of that table: explicit loggers per module, one shared write path, and a stand-in request for code that has none.

One write path

Architecture: HTTP views, Celery tasks and sockets feed one domain logger and log_activity, which writes the ActivityLog table read by the feed, CSV export and analytics
One write path for every kind of caller.

The storage is a single model. The two user columns are the part that matters most:

models.pyPython
class ActivityLog(models.Model):
    user = models.ForeignKey(SuperUser, on_delete=models.CASCADE, related_name="activity_logs",
        help_text="The actual user who performed the action (original user if impersonating)")
    impersonated_user = models.ForeignKey(SuperUser, on_delete=models.CASCADE, null=True, blank=True,
        related_name="impersonated_activity_logs")
    action = models.CharField(max_length=50, blank=True, default="")
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
    object_id = models.CharField(max_length=255, blank=True, default="")
    content_object = GenericForeignKey("content_type", "object_id")
    data = models.JSONField(null=True, blank=True)       # new state or request data
    old_data = models.JSONField(null=True, blank=True)   # previous state
    entity_name = models.CharField(max_length=255, null=True, blank=True)  # "Quote #123"
    description = models.TextField(blank=True, default="")
    ip_address = models.GenericIPAddressField(null=True, blank=True)
    user_agent = models.TextField(blank=True, default="")
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [models.Index(fields=["user", "-created_at"]),
                   models.Index(fields=["action", "-created_at"]),
                   models.Index(fields=["content_type", "object_id"])]
The main fields of one ActivityLog row
What one row holds.

A few choices here are deliberate. object_id is a string so the generic foreign key works for any primary key type. entity_name is denormalised, so the feed still says "Quote #123" after the quote is renamed or deleted. The three indexes match the three ways the table is read: a user's feed, a filter by action, and the history of one record.

Resolving the real actor

Impersonation in the portal is carried in the JWT: is_impersonating and original_user_id. While impersonating, request.user is the contractor, so the logger swaps them back:

services.pyPython
def get_user_info(request) -> tuple[SuperUser, SuperUser | None]:
    auth = getattr(request, "auth", None)
    if getattr(auth, "is_impersonating", False):
        original_user_id = getattr(auth, "original_user_id", None)
        if original_user_id:
            actual_user = SuperUser.objects.filter(id=original_user_id).first()
            if actual_user:
                return actual_user, request.user
    return request.user, None
An impersonated request is stored with the support user as actor and the contractor as impersonated user
The row names both people.

The stored row then says the support user did it, and the description reads "A, acting on behalf of B, updated the material list for the quote record Q." Anyone reading the feed can see both people in one line.

Logging from code with no request

The write path needs three things from a request: .user, .META for IP and user agent, and .auth for impersonation. Celery tasks have none of that, so they get a small stand-in:

helpers.pyPython
def build_activity_log_owner(*, user, view_name, client_ip=None, user_agent=None, auth=None):
    return SimpleNamespace(
        request=build_request_like(user=user, client_ip=client_ip, user_agent=user_agent, auth=auth),
        activity_log_view_name=view_name,
    )

class DocScanActivityLogger(BaseModuleActivityLogger):
    VIEW_NAME = "ExtractionViewSet"

    @classmethod
    def from_job(cls, job):
        if not getattr(job, "created_by_id", None) or getattr(job, "email_message_id", None):
            return None  # jobs from inbound email are automation, not user activity
        return cls(build_activity_log_owner(user=job.created_by, view_name=cls.VIEW_NAME))

Returning None for email-originated jobs keeps automation out of people's feeds. Every payload also carries source: "manual" or "system", so a reader can tell a click from a pipeline.

Logging must never break the product

An audit write that throws inside a request can roll back the user's real work. The module loggers go through a wrapper that swallows and warns, and "viewed" events go through a deduped version so a page that polls does not write a row every few seconds:

manager.pyPython
def safe_log_once(self, *, request, action, view_name, entity_name, content_object=None,
                  dedupe_seconds: int = 60, data=None, old_data=None, **desc_kwargs):
    try:
        if dedupe_seconds > 0 and self.has_recent_content_action(
            request=request, action=action, content_object=content_object,
            within_seconds=dedupe_seconds,
        ):
            return None
        return self.log(request=request, action=action, view_name=view_name,
                        entity_name=entity_name, data=data, old_data=old_data,
                        content_object=content_object, **desc_kwargs)
    except Exception:
        logger.warning("Activity logging failed in deduped manager wrapper", exc_info=True,
                       extra={"action": action, "view_name": view_name})
        return None

View events use a 15 minute window. One row per person per record per quarter hour is enough to answer "did they open it?"

Sentences, not JSON

Description lookup order: explicit template, suffix match, generic sentence
The first template that matches wins.

Each module defines its action slugs and a description template next to each other, for example quote_materials_updated with updated the material list for the quote record {object:quote_name}. The builder picks the explicit template, then falls back to a suffix match ("created", "shared", "revoked", "viewed"), then to a generic sentence. Values are HTML-escaped, and lists of recipients collapse to "A, B, and 3 other users".

Descriptions are rendered once and stored. Old rows keep their wording when templates change, which is what you want for an audit trail. The cost is that mistakes are frozen too, so there is a management command that regenerates descriptions in batches of 500.

The serializer adds two things on the way out: a module name inferred from the action prefix (so filters and analytics use one vocabulary), and clickable links built from the record references in the payload.

Who can read what

Contractors see their own rows, staff see everything, identity events live elsewhere
Who reads which log.

Contractors and distributors only see their own rows (qs.filter(user=request.user)). Brava staff see everything, with search, filters by module and action, time ranges and a timezone-aware CSV export. An early version returned 401 for a forbidden read. It should be 403, and it is now.

Login and account-linking events do not go into this table at all. They go to structured application logs through a separate identity audit service, with a closed list of event names, a deny-list that drops anything called token, secret or password, and email addresses reduced to a 16 character SHA-256 prefix. Security events and product activity have different readers and different retention needs, so they live apart.

What I would still change

This is an activity log with some audit properties, not a tamper-proof ledger.

  • Rows are mutable. A later step updates a DocScan row's link once the property is saved, and the backfill command rewrites descriptions. There is no hash chain or write-only store.
  • on_delete=CASCADE on both user columns. Deleting a user deletes their history. SET_NULL plus the denormalised name would keep it.
  • Coverage is by convention. A new endpoint without a logger call logs nothing, and no test enforces it. A test that walks the URL conf and checks every mutating view for a logger would close that.
  • Two failure policies. The core function raises, and the wrappers swallow. Callers have to know which one they are using.
  • Dedupe is check-then-insert. Two concurrent requests can both log a view. For "viewed" that is acceptable. For anything that matters it would not be.
  • No retention policy. The table grows forever. Monthly partitions or an archive job would be the next step.

If you are building one

  1. One table, two user columns

    Store the real actor and the impersonated user separately from day one. Retrofitting this means guessing from old data.

  2. One write function, wrapped

    A single log_activity() that resolves the actor, serializes, describes and inserts. Wrap it so logging failures never fail the request.

  3. Loggers per module

    Keep action slugs and sentence templates together per domain. It stops the wording from drifting.

  4. A request shim for background work

    Celery, pipelines and sockets should call the same logger with a stand-in request, and mark the source as system.

  5. Store the sentence

    Render the human description at write time, and keep a backfill command for the day you need to fix wording.

The table was the easy part. Agreeing on what counts as an event, and how it should read to the person it is about, took most of the work.