HorillaCoreModel is the abstract base class that every CRM business record extends. It provides audit columns, company scoping, JSON metadata, and change history in one place.

This post is Part 2 of 28 in the Horilla CRM Technical Blog series.

What is HorillaCoreModel?

HorillaCoreModel enables you to:

  • Automatic created_by, updated_by, and timestamp fields
  • Company foreign key for tenant isolation
  • CompanyFilteredManager on objects for safe default queries
  • all_objects manager for explicit cross-company access
  • OWNER_FIELDS for row-level permission scoping

Why Not Plain models.Model?

In a multi-company CRM, you need on every record:

  • Who created/updated it and when
  • Which company it belongs to
  • Whether it is active (soft lifecycle)
  • Optional JSON for integrations and custom data
  • Change history for compliance and debugging

Horilla centralizes this in horilla/contrib/core/models/base.py. CRM apps import models via:

from horilla.db import models # NOT django.db.models
from horilla.db import transaction # NOT django.db.transaction
from horilla.db import connection # NOT django.db.connection (when needed)

horilla.db wraps Django’s ORM and database helpers with Horilla conventions (including extension/injection support — Part 6). transaction and connection are thin re-exports with the same Django API.

HorillaCoreModel — The Fields You Get for Free

horilla/contrib/core/models/base.py (abstract base)
class HorillaCoreModel(models.Model, metaclass=ExtensionModelBase):
is_active = models.BooleanField(default=True)
additional_info = models.JSONField(blank=True, null=True)
company = models.ForeignKey(Company, on_delete=models.CASCADE, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="%(class)s_created")
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="%(class)s_updated")
history = AuditlogHistoryField()

objects = CompanyFilteredManager()
all_objects = models.Manager()

field_permissions_exclude = [
    "is_active", "additional_info", "company",
    "created_at", "updated_at", "created_by", "updated_by",
]

class Meta:
    abstract = True

Field Guide

FieldPurpose
is_activeSoft deactivation without deleting rows
additional_infoIntegration payloads, API extras, feature flags
companyTenant isolation FK
created_* / updated_*Audit attribution (auto-set in save())
historydjango-auditlog integration via AuditlogHistoryField

Two Managers

ManagerBehavior
objectsDefault — filtered by active company (see below)
all_objectsUnfiltered — admin/reporting/cross-company tools only

Never use all_objects in user-facing views unless the feature explicitly supports “all companies” mode.

Company — The Tenant Root (Exception to the Rule)

Company extends models.Model directly — it is the tenant root and is not company-filtered itself.

Companies hold branding, locale, currency, and HQ flags. When a user switches company in the UI, middleware sets request.active_company; managers and forms read that context.

CompanyFilteredManager — Automatic Tenant Scoping

class CompanyFilteredManager(models.Manager):
def get_queryset(self):
queryset = super().get_queryset()
request = getattr(_thread_local, "request", None)
if request is None:
return queryset
if hasattr(request, "session") and request.session.get("show_all_companies", False):
return queryset
company = getattr(request, "active_company", None)
if company:
queryset = queryset.filter(company=company)
return queryset

How It Works

  • Middleware stores the current HttpRequest in thread-local storage.
  • get_queryset() reads request.active_company.
  • If set, every ORM query through Model.objects is scoped to that company.
  • Power users with show_all_companies in session can bypass filtering.

Implications for Developers

# In a view — company filter applied automatically
leads = Lead.objects.filter(lead_status=status)  # safe default
 
# Cross-company report (explicit, rare)
leads = Lead.all_objects.filter(company__in=companies)
 
# Creating a record — set company from request context in forms/views
lead.company = request.active_company

Generic views in horilla.contrib.generics query through objects, so list/kanban/detail views inherit tenant isolation without boilerplate.

Lead Model — A Real HorillaCoreModel Subclass

# horilla_crm/leads/models/base.py (excerpt)
class Lead(HorillaCoreModel):
    title = models.CharField(max_length=100, blank=True)
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    lead_owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="lead")
    email = models.EmailField(...)
    lead_status = models.ForeignKey(LeadStatus, ...)
    annual_revenue = MoneyField(...)  # django-money
 
    OWNER_FIELDS = ["lead_owner"]
 
    def save(self, *args, **kwargs):
        if not self.title:
            owner_name = getattr(self.lead_owner, "username", str(self.lead_owner))
            self.title = f"{self.lead_company}/{self.first_name}/{owner_name}"
        super().save(*args, **kwargs)

OWNER_FIELDS

Declares which FK(s) define record ownership for row-level permissions (Part 4). Leads use lead_owner; opportunities might use opportunity_owner, etc.

URL Helpers on the Model

CRM models often expose URL builders used by generic templates:

def get_detail_url(self):
    return reverse_lazy("leads:leads_detail", kwargs={"pk": self.pk})
 
def get_edit_url(self):
    return reverse_lazy("leads:leads_edit", kwargs={"pk": self.pk})

DYNAMIC_METHODS = [“get_edit_url”] tells generic views which callables to expose to the template layer.

Automatic Audit on Save

HorillaCoreModel.save() sets created_by / updated_by from the current request user when available:

def save(self, *args, **kwargs):
    request = getattr(_thread_local, "request", None)
    user = getattr(request, "user", None) if request else None
    now = timezone.now()
    if not self.pk:
        if user and not user.is_anonymous:
            self.created_by = user
            self.updated_by = user
        ...
    else:
        if user and not user.is_anonymous:
            self.updated_by = user
    super().save(*args, **kwargs)

Combined with AuditlogHistoryField, you get field-level change history via instance.histories without custom logging code per model.

Money Fields and Company Currency

CRM modules use MoneyField from django-money. When a company changes currency, Horilla fires company_currency_changed; leads/opportunities/campaigns register receivers to bulk-update stored amounts.

Pattern in signals.py:

@receiver(company_currency_changed)
def handle_currency_change(sender, company, old_currency, new_currency, **kwargs):
    # bulk_update Lead, Opportunity, etc.

Model Import Rules (CRM vs HR)

RuleCRM
Base classHorillaCoreModel
Model fieldsfrom horilla.db import models
Transactionsfrom horilla.db import transaction
DB connectionfrom horilla.db import connection
Default managerCompanyFilteredManager via objects
Unfiltered accessall_objects only when intentional

Do not use HorillaModel (HR) or from django.db import models, transaction, or connection in CRM app code. Other django.db symbols (e.g. IntegrityError, close_old_connections) may still be imported from django.db when required.

Anti-Patterns

MistakeFix
Subclassing models.Model for business dataExtend HorillaCoreModel
Using Lead.all_objects in list viewsUse Lead.objects
Forgetting company on createSet from request.active_company in form/view
Skipping OWNER_FIELDS when model has an owner FKAdd OWNER_FIELDS = [“lead_owner”]

Benefits of Multi-Tenancy in Horilla CRM

  • Multi-tenancy without repeating .filter(company=…) in every view
  • Built-in audit trail via django-auditlog
  • Consistent soft lifecycle with is_active
  • Money field currency cascade through platform signals
  • Predictable import path via horilla.db

HorillaCoreModel and CompanyFilteredManager are non-negotiable for CRM modules. Get the data layer right once, and every list, API, and report inherits correct tenant boundaries.

Continue the series

Previous: Part 1 — How Horilla CRM Apps Work: A Deep Dive into registration.py, menu.py, and AppLauncher

Next: Part 3 — How horilla.contrib.generics Powers CRM List, Kanban, and Detail UIs

More posts are at Horilla Blogs; share feedback on GitHub.

Share this article