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
| Field | Purpose |
| is_active | Soft deactivation without deleting rows |
| additional_info | Integration payloads, API extras, feature flags |
| company | Tenant isolation FK |
| created_* / updated_* | Audit attribution (auto-set in save()) |
| history | django-auditlog integration via AuditlogHistoryField |
Two Managers
| Manager | Behavior |
| objects | Default — filtered by active company (see below) |
| all_objects | Unfiltered — 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)
| Rule | CRM |
| Base class | HorillaCoreModel |
| Model fields | from horilla.db import models |
| Transactions | from horilla.db import transaction |
| DB connection | from horilla.db import connection |
| Default manager | CompanyFilteredManager via objects |
| Unfiltered access | all_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
| Mistake | Fix |
| Subclassing models.Model for business data | Extend HorillaCoreModel |
| Using Lead.all_objects in list views | Use Lead.objects |
| Forgetting company on create | Set from request.active_company in form/view |
| Skipping OWNER_FIELDS when model has an owner FK | Add 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.