The Django
Build Guide
Prerequisites
Before you type django-admin startproject, make sure these are done. Skipping any one of them will cost you more time than doing them properly now.
System Requirements
| Component | Version | Purpose |
|---|---|---|
| Ubuntu | 22.04 LTS | OS for both the Django server and Zabbix server |
| Python | 3.11+ | Django runtime |
| PostgreSQL | 14 + TimescaleDB 2 | Primary database |
| Redis | 7+ | Celery message broker and cache |
| Nginx | latest stable | Reverse proxy in front of Gunicorn |
| Zabbix Server | 6.4 LTS | Metric collection engine (see Vol. I Ch. 10) |
System Packages
# Update and install system dependencies apt update && apt upgrade -y apt install -y python3.11 python3.11-venv python3.11-dev \ build-essential libpq-dev \ postgresql-14 redis-server nginx \ git curl # Install TimescaleDB for PostgreSQL 14 apt install -y timescaledb-2-postgresql-14 timescaledb-tune --quiet --yes systemctl restart postgresql # Verify python3.11 --version # Python 3.11.x psql --version # psql (PostgreSQL) 14.x redis-cli ping # PONG
Create the Database and User
# Switch to postgres user sudo -u postgres psql -- Inside psql: CREATE USER sprintug WITH PASSWORD 'CHANGE_THIS_NOW'; CREATE DATABASE sprintug OWNER sprintug; \c sprintug CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; \q
Create the System User
# Dedicated OS user — never run Django as root
useradd -m -s /bin/bash sprint
mkdir -p /opt/sprintug
chown sprint:sprint /opt/sprintug
Bootstrap the Project
From zero to a running Django shell in one session. Every command, in order. No assumptions.
Create the Virtual Environment
su - sprint cd /opt/sprintug # Create and activate virtualenv python3.11 -m venv venv source venv/bin/activate # Verify you are in the venv which python # /opt/sprintug/venv/bin/python
Install All Dependencies
# Create requirements.txt first, then pip install cat > requirements.txt << 'EOF' django==4.2.13 djangorestframework==3.15.1 celery[redis]==5.3.6 django-celery-beat==2.6.0 gunicorn==21.2.0 httpx==0.27.0 psycopg2-binary==2.9.9 redis==5.0.4 africastalking==1.2.8 django-environ==0.11.2 numpy==1.26.4 django-guardian==2.4.0 Pillow==10.3.0 EOF pip install -r requirements.txt
Create the Django Project
# Create the project — note the trailing dot (current directory) django-admin startproject config . # Verify structure ls -la # manage.py config/ venv/ requirements.txt
Naming the inner project directory config is a common Django best practice. It clearly separates project-level configuration from application-level code. Your apps will be named after the domain they own: customers, monitoring, tickets. The word sprintug would be ambiguous — is it configuration or an app?
Create All Django Apps
# One app per domain — create all now python manage.py startapp customers python manage.py startapp monitoring python manage.py startapp tickets python manage.py startapp alerts python manage.py startapp noc python manage.py startapp reports # Also create the API app manually (we use DRF views directly) mkdir -p api touch api/__init__.py api/views.py api/urls.py api/serializers.py
Settings, .env, and Celery
The settings file is the nervous system of Django. We use django-environ to keep secrets out of the codebase and split environment from code.
Create the .env File
SECRET_KEY=django-insecure-CHANGE-THIS-TO-50-RANDOM-CHARS-MINIMUM DEBUG=False ALLOWED_HOSTS=noc.sprintug.com,127.0.0.1,localhost DATABASE_URL=postgres://sprintug:CHANGE_THIS_NOW@localhost:5432/sprintug REDIS_URL=redis://localhost:6379/0 ZABBIX_URL=http://127.0.0.1:8080/api_jsonrpc.php ZABBIX_USER=django_api_user ZABBIX_PASSWORD=your_zabbix_api_password AT_USERNAME=your_africas_talking_username AT_API_KEY=your_africas_talking_api_key AT_SENDER_UG=SprintUG AT_SENDER_TZ=SprintTZ
Add .env to .gitignore immediately. This file contains database credentials, the Django secret key, and your SMS API key. If it lands in version control — even a private repo — rotate every credential in it before doing anything else.
config/settings.py — Complete File
import environ from pathlib import Path from celery.schedules import crontab BASE_DIR = Path(__file__).resolve().parent.parent env = environ.Env(DEBUG=(bool, False)) environ.Env.read_env(BASE_DIR / '.env') SECRET_KEY = env('SECRET_KEY') DEBUG = env('DEBUG') ALLOWED_HOSTS = env.list('ALLOWED_HOSTS') # ── Apps ──────────────────────────────────────────────────── INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', 'guardian', 'django_celery_beat', # Our apps 'customers', 'monitoring', 'tickets', 'alerts', 'noc', 'reports', 'api', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] ROOT_URLCONF = 'config.urls' WSGI_APPLICATION = 'config.wsgi.application' TEMPLATES = [{ 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': {'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ]}, }] # ── Database ───────────────────────────────────────────────── DATABASES = { 'default': env.db() # Reads DATABASE_URL from .env } # ── Redis / Celery ─────────────────────────────────────────── CELERY_BROKER_URL = env('REDIS_URL') CELERY_RESULT_BACKEND = env('REDIS_URL') CELERY_ACCEPT_CONTENT = ['json'] CELERY_TASK_SERIALIZER = 'json' CELERY_TIMEZONE = 'Africa/Kampala' CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' CELERY_BEAT_SCHEDULE = { 'sync-zabbix-problems': { 'task': 'monitoring.sync.sync_active_problems', 'schedule': 30.0, }, 'sync-onu-metrics': { 'task': 'monitoring.sync.sync_onu_metrics', 'schedule': 60.0, }, 'sync-hosts': { 'task': 'monitoring.sync.sync_hosts', 'schedule': 300.0, }, 'evaluate-alert-rules': { 'task': 'alerts.rules.evaluate_all_rules', 'schedule': 60.0, }, 'signal-trend-analysis': { 'task': 'alerts.analysis.detect_degradation_trends', 'schedule': crontab(hour=2, minute=0), }, 'sla-breach-check': { 'task': 'reports.sla.check_sla_breaches', 'schedule': 300.0, }, } # ── Zabbix ─────────────────────────────────────────────────── ZABBIX = { 'URL': env('ZABBIX_URL'), 'USER': env('ZABBIX_USER'), 'PASSWORD': env('ZABBIX_PASSWORD'), } # ── Africa's Talking SMS ───────────────────────────────────── AT_USERNAME = env('AT_USERNAME') AT_API_KEY = env('AT_API_KEY') AT_SENDER_UG = env('AT_SENDER_UG', default='SprintUG') AT_SENDER_TZ = env('AT_SENDER_TZ', default='SprintTZ') # ── Auth ───────────────────────────────────────────────────── AUTHENTICATION_BACKENDS = [ 'django.contrib.auth.backends.ModelBackend', 'guardian.backends.ObjectPermissionBackend', ] LOGIN_URL = '/login/' LOGIN_REDIRECT_URL = '/noc/' # ── Static / Media ─────────────────────────────────────────── STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [BASE_DIR / 'static'] DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' TIME_ZONE = 'Africa/Kampala' USE_TZ = True
config/celery.py
import os from celery import Celery os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') app = Celery('sprintug') app.config_from_object('django.conf:settings', namespace='CELERY') # Auto-discover tasks from all installed apps app.autodiscover_tasks()
# This makes Celery load when Django starts from .celery import app as celery_app __all__ = ('celery_app',)
Verify Everything Works
# Check Django can connect to DB and settings are valid python manage.py check # Check Celery can see the broker celery -A config inspect ping # Expected output: pong from worker@hostname
customers/ — The CMDB
This is the most important file in the entire codebase. Every other feature hangs off these models. Get the schema right before writing a single view.
customers/models.py
from django.db import models from django.utils import timezone class Customer(models.Model): """One customer. Could be residential or enterprise.""" MARKET_CHOICES = [('UG', 'Uganda'), ('TZ', 'Tanzania')] full_name = models.CharField(max_length=255) phone_primary = models.CharField(max_length=20) phone_secondary = models.CharField(max_length=20, blank=True) email = models.EmailField(blank=True) address = models.TextField(blank=True) account_number = models.CharField(max_length=32, unique=True, db_index=True) service_tier = models.CharField(max_length=64) market = models.CharField(max_length=2, choices=MARKET_CHOICES) is_active = models.BooleanField(default=True) notes = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['full_name'] indexes = [models.Index(fields=['account_number']), models.Index(fields=['market', 'is_active'])] def __str__(self): return f"{self.full_name} ({self.account_number})" class Site(models.Model): """Physical installation location for a customer.""" customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='sites') name = models.CharField(max_length=255) address = models.TextField(blank=True) latitude = models.FloatField(null=True, blank=True) longitude = models.FloatField(null=True, blank=True) def __str__(self): return f"{self.customer.full_name} — {self.name}" class OLT(models.Model): """Optical Line Terminal — the upstream GPON head-end device.""" name = models.CharField(max_length=128, unique=True) ip_address = models.GenericIPAddressField(unique=True) model = models.CharField(max_length=64) # e.g. Huawei MA5800-X7 location = models.CharField(max_length=255, blank=True) market = models.CharField(max_length=2, choices=Customer.MARKET_CHOICES) snmp_community = models.CharField(max_length=64, default='public') snmp_version = models.CharField(max_length=4, default='2c') is_active = models.BooleanField(default=True) notes = models.TextField(blank=True) def __str__(self): return self.name @property def active_onu_count(self): return self.onus.filter(is_active=True).count() class Device(models.Model): """ A customer-premises device. This is the CI that joins everything. The serial_number is the permanent join key to the Zabbix host and to the OLT's port table. """ DEVICE_TYPE_CHOICES = [ ('ONU', 'ONU / ONT (GPON)'), ('CPE_WIRELESS', 'Wireless CPE'), ('CPE_WIRED', 'Wired CPE / Router'), ('MIXED', 'ONU + Wi-Fi Router (combo)'), ] customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='devices') site = models.ForeignKey(Site, null=True, blank=True, on_delete=models.SET_NULL, related_name='devices') device_type = models.CharField(max_length=20, choices=DEVICE_TYPE_CHOICES) serial_number = models.CharField(max_length=64, unique=True, db_index=True) mac_address = models.CharField(max_length=17, blank=True) model = models.CharField(max_length=128, blank=True) # ONU-specific: physical port on the OLT olt = models.ForeignKey(OLT, null=True, blank=True, on_delete=models.SET_NULL, related_name='onus') olt_frame = models.IntegerField(null=True, blank=True) olt_slot = models.IntegerField(null=True, blank=True) olt_port = models.IntegerField(null=True, blank=True) olt_onu_id = models.IntegerField(null=True, blank=True) # PPPoE layer pppoe_username = models.CharField(max_length=128, blank=True, db_index=True) ip_address = models.GenericIPAddressField(null=True, blank=True) is_active = models.BooleanField(default=True) installed_at = models.DateTimeField(null=True, blank=True) notes = models.TextField(blank=True) class Meta: indexes = [ models.Index(fields=['serial_number']), models.Index(fields=['pppoe_username']), models.Index(fields=['olt', 'olt_frame', 'olt_slot', 'olt_port']), ] @property def olt_port_string(self) -> str: if self.olt_frame is not None: return f"{self.olt_frame}/{self.olt_slot}/{self.olt_port}" return "" def __str__(self): return f"{self.serial_number} — {self.customer.full_name}"
monitoring/ — Zabbix Mirror Models
These models are Django's local copy of what Zabbix knows. Zabbix owns the raw data. Django owns the meaning.
monitoring/models.py
from django.db import models from django.utils import timezone class ZabbixHost(models.Model): """ Mirror of a Zabbix host record. Created/updated by sync.sync_hosts() every 5 minutes. """ zabbix_hostid = models.CharField(max_length=32, unique=True, db_index=True) hostname = models.CharField(max_length=255) ip_address = models.GenericIPAddressField(null=True, blank=True) status = models.IntegerField(default=0) # 0=enabled, 1=disabled host_group = models.CharField(max_length=128, blank=True) # THE CRITICAL LINK: Zabbix host ↔ our Device record device = models.OneToOneField( 'customers.Device', null=True, blank=True, on_delete=models.SET_NULL, related_name='zabbix_host', ) last_synced = models.DateTimeField(auto_now=True) def __str__(self): return self.hostname @property def is_linked(self) -> bool: return self.device is not None class ZabbixItem(models.Model): """A single metric/item on a Zabbix host (OID, key, check).""" VALUE_TYPE_FLOAT = 0 VALUE_TYPE_STRING = 1 VALUE_TYPE_LOG = 2 VALUE_TYPE_UINT = 3 VALUE_TYPE_TEXT = 4 zabbix_itemid = models.CharField(max_length=32, unique=True, db_index=True) host = models.ForeignKey(ZabbixHost, on_delete=models.CASCADE, related_name='items') key = models.CharField(max_length=255) # e.g. onu.rx.power[3/0/2] name = models.CharField(max_length=255) # Human-readable label units = models.CharField(max_length=32, blank=True) # dBm, Mbps, % value_type = models.IntegerField(default=VALUE_TYPE_FLOAT) class Meta: unique_together = ('host', 'key') def __str__(self): return f"{self.host.hostname}/{self.key}" class MetricSample(models.Model): """ One time-series data point from Zabbix history.get. PRODUCTION NOTE: Convert this table to a TimescaleDB hypertable after running migrations: SELECT create_hypertable( 'monitoring_metricsample', 'clock', chunk_time_interval => 86400, migrate_data => TRUE ); This partitions the table by day and makes range queries (e.g. "last 24 hours for this item") extremely fast. """ item = models.ForeignKey(ZabbixItem, on_delete=models.CASCADE, related_name='samples') clock = models.BigIntegerField(db_index=True) # Unix timestamp from Zabbix value_float = models.FloatField(null=True, blank=True) value_str = models.TextField(null=True, blank=True) recorded_at = models.DateTimeField(default=timezone.now) class Meta: indexes = [models.Index(fields=['item', 'clock'])] ordering = ['-clock'] # Prevent duplicate data points from Celery retries unique_together = ('item', 'clock') class ZabbixProblem(models.Model): """ Active or recently resolved problem from Zabbix. This is the bridge between Zabbix events and our ticket system. Created by sync.sync_active_problems() every 30 seconds. """ SEVERITY = [ (0, 'Not classified'), (1, 'Information'), (2, 'Warning'), (3, 'Average'), (4, 'High'), (5, 'Disaster'), ] zabbix_eventid = models.CharField(max_length=32, unique=True, db_index=True) host = models.ForeignKey(ZabbixHost, on_delete=models.CASCADE, related_name='problems', null=True, blank=True) name = models.CharField(max_length=512) severity = models.IntegerField(choices=SEVERITY, default=0) acknowledged = models.BooleanField(default=False) suppressed = models.BooleanField(default=False) clock = models.BigIntegerField() # Unix: problem start r_clock = models.BigIntegerField(null=True, blank=True) # Unix: resolved # Ticket linkage — created by alerts.rules.evaluate_problem() ticket = models.ForeignKey( 'tickets.Ticket', null=True, blank=True, on_delete=models.SET_NULL, related_name='zabbix_problems', ) class Meta: ordering = ['-clock'] @property def is_active(self) -> bool: return self.r_clock is None def __str__(self): return f"[{self.get_severity_display()}] {self.name}"
TimescaleDB Hypertable Conversion
After running migrate, convert the MetricSample table to a hypertable. This is a one-time operation that dramatically speeds up time-range queries:
sudo -u postgres psql sprintug SELECT create_hypertable( 'monitoring_metricsample', 'clock', chunk_time_interval => 86400, -- Partition by day migrate_data => TRUE ); -- Verify SELECT hypertable_name FROM timescaledb_information.hypertables; -- monitoring_metricsample
tickets/ — Incidents, Problems, Changes
The ticket models implement the full ITIL lifecycle in Django. Every incident has a priority, an SLA deadline, an audit trail, and a path to a Problem Record.
tickets/models.py
from django.db import models from django.contrib.auth import get_user_model from django.utils import timezone from datetime import timedelta import uuid User = get_user_model() def generate_ticket_ref(): from django.utils import timezone now = timezone.now() return f"INC-{now.year}-{str(uuid.uuid4().int)[:5].zfill(5)}" # ── SLA response + resolve targets by priority ────────────── SLA_TARGETS = { 'p1': {'response_minutes': 5, 'resolve_hours': 2}, 'p2': {'response_minutes': 15, 'resolve_hours': 4}, 'p3': {'response_minutes': 60, 'resolve_hours': 8}, 'p4': {'response_minutes': 240, 'resolve_hours': 72}, } class Ticket(models.Model): PRIORITY_CHOICES = [ ('p1', 'P1 · Critical'), ('p2', 'P2 · High'), ('p3', 'P3 · Medium'), ('p4', 'P4 · Low'), ] STATUS_CHOICES = [ ('new', 'New'), ('open', 'Open'), ('in_progress', 'In Progress'), ('escalated', 'Escalated'), ('resolved', 'Resolved'), ('closed', 'Closed'), ] SOURCE_CHOICES = [ ('auto_zabbix', 'Auto — Zabbix Alert'), ('customer_call','Customer Call'), ('engineer', 'Engineer Raised'), ('email', 'Email'), ] CATEGORY_CHOICES = [ ('optical', 'Optical / Fibre'), ('pppoe', 'PPPoE / Auth'), ('hardware', 'Hardware'), ('billing', 'Billing / Account'), ('config', 'Configuration'), ('other', 'Other'), ] # Identity reference = models.CharField(max_length=24, unique=True, default=generate_ticket_ref) source = models.CharField(max_length=20, choices=SOURCE_CHOICES) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # CMDB linkage customer = models.ForeignKey('customers.Customer', null=True, blank=True, on_delete=models.SET_NULL, related_name='tickets') device = models.ForeignKey('customers.Device', null=True, blank=True, on_delete=models.SET_NULL, related_name='tickets') # Classification title = models.CharField(max_length=512) description = models.TextField(blank=True) priority = models.CharField(max_length=4, choices=PRIORITY_CHOICES, default='p3') category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='other') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new') # SLA tracking sla_response_deadline = models.DateTimeField(null=True) sla_resolve_deadline = models.DateTimeField(null=True) first_response_at = models.DateTimeField(null=True, blank=True) resolved_at = models.DateTimeField(null=True, blank=True) sla_response_breached = models.BooleanField(default=False) sla_resolve_breached = models.BooleanField(default=False) # Assignment assigned_to = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name='tickets') team = models.CharField(max_length=32, blank=True, help_text="noc_ug | noc_tz | field_ug | field_tz") # Resolution resolution_notes = models.TextField(blank=True) root_cause = models.TextField(blank=True) problem_record = models.ForeignKey('ProblemRecord', null=True, blank=True, on_delete=models.SET_NULL, related_name='tickets') class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['status', 'priority']), models.Index(fields=['customer', 'status']), ] def save(self, *args, **kwargs): # Auto-calculate SLA deadlines on first save if not self.pk and self.priority in SLA_TARGETS: sla = SLA_TARGETS[self.priority] now = timezone.now() self.sla_response_deadline = now + timedelta(minutes=sla['response_minutes']) self.sla_resolve_deadline = now + timedelta(hours=sla['resolve_hours']) super().save(*args, **kwargs) def __str__(self): return f"{self.reference} — {self.title[:60]}" class TicketEvent(models.Model): """ Immutable audit trail for a ticket. Every state change, comment, and assignment is a TicketEvent. Never delete these. Never update them. Append only. """ EVENT_TYPES = [ ('created', 'Ticket Created'), ('comment', 'Comment Added'), ('status', 'Status Changed'), ('assigned', 'Assigned'), ('escalated', 'Escalated'), ('resolved', 'Resolved'), ('sla_breach', 'SLA Breach'), ('sms_sent', 'SMS Sent'), ] ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name='events') event_type = models.CharField(max_length=20, choices=EVENT_TYPES) body = models.TextField() created_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['created_at'] class ProblemRecord(models.Model): """ITIL Problem Record — the root cause investigation behind incidents.""" STATUS_CHOICES = [ ('investigating', 'Investigating'), ('known_error', 'Known Error — Workaround Available'), ('change_raised', 'Change Raised'), ('resolved', 'Resolved'), ] reference = models.CharField(max_length=24, unique=True) title = models.CharField(max_length=255) description = models.TextField() status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='investigating') root_cause = models.TextField(blank=True) workaround = models.TextField(blank=True) permanent_fix = models.TextField(blank=True) affected_device = models.ForeignKey('customers.Device', null=True, blank=True, on_delete=models.SET_NULL) affected_olt = models.ForeignKey('customers.OLT', null=True, blank=True, on_delete=models.SET_NULL) change_record = models.ForeignKey('ChangeRecord', null=True, blank=True, on_delete=models.SET_NULL) created_at = models.DateTimeField(auto_now_add=True) resolved_at = models.DateTimeField(null=True, blank=True) def __str__(self): return f"{self.reference} — {self.title}" class ChangeRecord(models.Model): """ITIL Change Record — every planned change to infrastructure.""" TYPE_CHOICES = [ ('standard', 'Standard (Pre-approved)'), ('normal', 'Normal (Requires CAB)'), ('emergency', 'Emergency'), ] STATUS_CHOICES = [ ('draft', 'Draft'), ('submitted', 'Submitted for Approval'), ('approved', 'Approved'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('failed', 'Failed — Rolled Back'), ('cancelled', 'Cancelled'), ] reference = models.CharField(max_length=24, unique=True) title = models.CharField(max_length=255) description = models.TextField() change_type = models.CharField(max_length=12, choices=TYPE_CHOICES) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') risk_level = models.CharField(max_length=8, choices=[('low','Low'),('medium','Medium'),('high','High')]) rollback_plan = models.TextField(help_text="Required for Normal and Emergency changes") implementation = models.TextField(help_text="Step-by-step implementation procedure") scheduled_start = models.DateTimeField() scheduled_end = models.DateTimeField() actual_start = models.DateTimeField(null=True, blank=True) actual_end = models.DateTimeField(null=True, blank=True) # Zabbix maintenance window ID — created when change is approved zabbix_maintenance_id = models.CharField(max_length=32, blank=True) requested_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='changes_requested') approved_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name='changes_approved') # Affected CIs affected_devices = models.ManyToManyField('customers.Device', blank=True) affected_olts = models.ManyToManyField('customers.OLT', blank=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"{self.reference} — {self.title}"
alerts/ — Rules, Dispatch, Logs
The alert models track every rule, every firing, and every SMS sent. Without these records, you cannot audit why a ticket was opened or prove an SMS was sent.
alerts/models.py
from django.db import models from django.utils import timezone class AlertRule(models.Model): """Configurable rule that maps Zabbix severity → ticket + SMS action.""" CONDITION_CHOICES = [ ('rx_power_lt', 'ONU Rx Power below threshold'), ('onu_offline', 'ONU offline / dying-gasp'), ('pppoe_fail', 'PPPoE auth failure rate high'), ('iface_down', 'Network interface down'), ('severity_gte', 'Zabbix severity ≥ threshold'), ] name = models.CharField(max_length=255) condition = models.CharField(max_length=32, choices=CONDITION_CHOICES) threshold_value = models.FloatField(null=True, blank=True) min_severity = models.IntegerField(default=2, help_text="Min Zabbix severity to fire (0-5)") duration_seconds = models.IntegerField(default=0, help_text="Problem must persist this long before firing") host_group_filter = models.CharField(max_length=128, blank=True, help_text="Only fire for hosts in this Zabbix host group") auto_open_ticket = models.BooleanField(default=True) sms_to_noc = models.BooleanField(default=True) sms_to_customer = models.BooleanField(default=False) is_active = models.BooleanField(default=True) def __str__(self): return self.name class AlertFiring(models.Model): """Record of each time an AlertRule fired for a specific problem.""" rule = models.ForeignKey(AlertRule, on_delete=models.CASCADE, related_name='firings') problem = models.ForeignKey('monitoring.ZabbixProblem', on_delete=models.CASCADE) ticket = models.ForeignKey('tickets.Ticket', null=True, blank=True, on_delete=models.SET_NULL) fired_at = models.DateTimeField(auto_now_add=True) suppressed = models.BooleanField(default=False) suppression_reason = models.CharField(max_length=255, blank=True) class SMSLog(models.Model): """Every SMS sent by the system, with delivery status.""" STATUS_CHOICES = [('sent','Sent'),('delivered','Delivered'), ('failed','Failed'),('pending','Pending')] to = models.CharField(max_length=20) message = models.TextField() status = models.CharField(max_length=12, choices=STATUS_CHOICES, default='pending') at_response = models.JSONField(null=True, blank=True) ticket = models.ForeignKey('tickets.Ticket', null=True, blank=True, on_delete=models.SET_NULL, related_name='sms_logs') sent_at = models.DateTimeField(auto_now_add=True)
Migrations and Django Admin
Run migrations, then configure Django Admin so your team can manage the CMDB, tickets, and alert rules without touching the database directly.
Run All Migrations
# Create migration files from all models python manage.py makemigrations customers python manage.py makemigrations monitoring python manage.py makemigrations tickets python manage.py makemigrations alerts # Apply all migrations python manage.py migrate # Convert MetricSample to TimescaleDB hypertable (see Ch. 05) python manage.py dbshell # Then run the SELECT create_hypertable() command from Ch. 05 # Create the admin superuser python manage.py createsuperuser # Username: admin Email: davide@sprintug.com Password: (strong)
customers/admin.py
from django.contrib import admin from .models import Customer, Site, OLT, Device class SiteInline(admin.TabularInline): model = Site extra = 0 class DeviceInline(admin.TabularInline): model = Device extra = 0 fields = ('serial_number', 'device_type', 'model', 'pppoe_username', 'olt', 'olt_frame', 'olt_slot', 'olt_port', 'is_active') @admin.register(Customer) class CustomerAdmin(admin.ModelAdmin): list_display = ('full_name', 'account_number', 'service_tier', 'market', 'phone_primary', 'is_active') list_filter = ('market', 'service_tier', 'is_active') search_fields = ('full_name', 'account_number', 'phone_primary') inlines = [SiteInline, DeviceInline] @admin.register(OLT) class OLTAdmin(admin.ModelAdmin): list_display = ('name', 'ip_address', 'model', 'market', 'active_onu_count', 'is_active') list_filter = ('market', 'is_active') search_fields = ('name', 'ip_address') @admin.register(Device) class DeviceAdmin(admin.ModelAdmin): list_display = ('serial_number', 'customer', 'device_type', 'olt', 'olt_port_string', 'pppoe_username', 'is_active') list_filter = ('device_type', 'olt', 'is_active') search_fields = ('serial_number', 'pppoe_username', 'customer__full_name') raw_id_fields = ('customer', 'olt')
tickets/admin.py
from django.contrib import admin from .models import Ticket, TicketEvent, ProblemRecord, ChangeRecord class TicketEventInline(admin.TabularInline): model = TicketEvent extra = 0 readonly_fields = ('event_type', 'body', 'created_by', 'created_at') can_delete = False # Audit trail — never delete @admin.register(Ticket) class TicketAdmin(admin.ModelAdmin): list_display = ('reference', 'customer', 'title', 'priority', 'status', 'source', 'sla_resolve_breached', 'created_at') list_filter = ('status', 'priority', 'source', 'sla_resolve_breached') search_fields = ('reference', 'title', 'customer__full_name') readonly_fields = ('reference', 'sla_response_deadline', 'sla_resolve_deadline') inlines = [TicketEventInline] date_hierarchy = 'created_at'
Zabbix API Client
One file. One function. Thread-safe token management. Every Zabbix API call in the system goes through _rpc().
monitoring/zabbix_client.py — Complete File
import httpx import logging import threading from django.conf import settings logger = logging.getLogger(__name__) # ── Token management ──────────────────────────────────────── # We cache the auth token in a module-level variable. # The threading.Lock prevents race conditions when multiple # Celery workers request a new token simultaneously. _token_lock = threading.Lock() _auth_token: str | None = None class ZabbixAPIError(Exception): pass def _login() -> str: """Authenticate and return a new token. Called by _get_token().""" payload = { "jsonrpc": "2.0", "method": "user.login", "params": { "username": settings.ZABBIX["USER"], "password": settings.ZABBIX["PASSWORD"], }, "id": 1, } resp = httpx.post(settings.ZABBIX["URL"], json=payload, timeout=10) resp.raise_for_status() data = resp.json() if "error" in data: raise ZabbixAPIError(f"Login failed: {data['error']['data']}") return data["result"] def _get_token() -> str: global _auth_token with _token_lock: if _auth_token is None: _auth_token = _login() logger.info("Zabbix API: new auth token obtained") return _auth_token def _invalidate_token(): global _auth_token with _token_lock: _auth_token = None def _rpc(method: str, params: dict | list, retry: bool = True) -> dict | list: """ Make a JSON-RPC 2.0 call to Zabbix API. Args: method: Zabbix API method (e.g. 'host.get') params: Parameters dict or list for the method retry: If True and we get an auth error, re-login once and retry Returns: The 'result' field from the Zabbix response Raises: ZabbixAPIError: On API-level errors httpx.HTTPStatusError: On HTTP-level errors """ payload = { "jsonrpc": "2.0", "method": method, "params": params, "id": 1, "auth": _get_token(), } try: resp = httpx.post( settings.ZABBIX["URL"], json=payload, timeout=30, ) resp.raise_for_status() except httpx.HTTPStatusError as e: logger.error(f"Zabbix HTTP error: {e}") raise data = resp.json() if "error" in data: error_data = data["error"].get("data", "") # Session expired — re-login once and retry if "Not authorised" in error_data and retry: logger.warning("Zabbix session expired, re-logging in") _invalidate_token() return _rpc(method, params, retry=False) raise ZabbixAPIError(error_data) return data["result"]
Celery Sync Tasks
Three Celery tasks run on a schedule to keep Django's local mirror of Zabbix current. Each one is idempotent — safe to run multiple times without creating duplicate records.
monitoring/sync.py — Complete File
import time import logging from celery import shared_task from django.db import transaction from .zabbix_client import _rpc, ZabbixAPIError from .models import ZabbixHost, ZabbixItem, MetricSample, ZabbixProblem from customers.models import Device logger = logging.getLogger(__name__) @shared_task(name="monitoring.sync.sync_hosts", bind=True, autoretry_for=(Exception,), max_retries=3, default_retry_delay=30) def sync_hosts(self): """ Pull all Zabbix hosts and upsert into ZabbixHost. Runs every 5 minutes (CELERY_BEAT_SCHEDULE in settings). Auto-links to Device by matching hostname == serial_number. """ try: hosts = _rpc("host.get", { "output": ["hostid", "host", "status"], "selectInterfaces": ["ip"], "selectGroups": ["name"], }) except ZabbixAPIError as exc: logger.error(f"sync_hosts failed: {exc}") raise self.retry(exc=exc) created_count = 0 linked_count = 0 for h in hosts: ip = h["interfaces"][0]["ip"] if h["interfaces"] else None group = h["groups"][0]["name"] if h["groups"] else "" obj, created = ZabbixHost.objects.update_or_create( zabbix_hostid=h["hostid"], defaults={ "hostname": h["host"], "ip_address": ip, "status": int(h["status"]), "host_group": group, }, ) if created: created_count += 1 # Auto-link: hostname in Zabbix == serial_number in our Device table if obj.device is None: try: device = Device.objects.get(serial_number=h["host"]) obj.device = device obj.save(update_fields=["device"]) linked_count += 1 logger.info(f"Auto-linked {h['host']} to Device {device.pk}") except Device.DoesNotExist: pass # Will show up in CMDB audit report logger.info(f"sync_hosts: {len(hosts)} hosts, {created_count} new, {linked_count} auto-linked") @shared_task(name="monitoring.sync.sync_onu_metrics", bind=True, autoretry_for=(Exception,), max_retries=3, default_retry_delay=15) def sync_onu_metrics(self): """ Pull the last 2 hours of optical power history for all ONU hosts. Only syncs hosts in a Zabbix group containing 'ONU'. Stores into MetricSample for our own Chart.js graphing. Runs every 60 seconds. """ onu_hosts = ZabbixHost.objects.filter(host_group__icontains="ONU") if not onu_hosts.exists(): logger.warning("sync_onu_metrics: no ONU hosts found — check Zabbix host groups") return now = int(time.time()) for zhost in onu_hosts: try: items = _rpc("item.get", { "output": ["itemid", "key_", "name", "units", "value_type"], "hostids": [zhost.zabbix_hostid], "search": {"key_": "onu"}, }) except ZabbixAPIError as e: logger.error(f"item.get failed for {zhost.hostname}: {e}") continue for item_data in items: item, _ = ZabbixItem.objects.get_or_create( zabbix_itemid=item_data["itemid"], defaults={ "host": zhost, "key": item_data["key_"], "name": item_data["name"], "units": item_data["units"], "value_type": int(item_data["value_type"]), }, ) try: history = _rpc("history.get", { "output": "extend", "itemids": [item.zabbix_itemid], "time_from": now - 7200, # Last 2 hours "time_till": now, "sortfield": "clock", "sortorder": "ASC", "limit": 200, }) except ZabbixAPIError as e: logger.error(f"history.get failed for item {item.pk}: {e}") continue with transaction.atomic(): for point in history: MetricSample.objects.get_or_create( item=item, clock=int(point["clock"]), defaults={"value_float": float(point["value"])}, ) @shared_task(name="monitoring.sync.sync_active_problems", bind=True, autoretry_for=(Exception,), max_retries=5, default_retry_delay=10) def sync_active_problems(self): """ Pull all active problems from Zabbix every 30 seconds. Upserts into ZabbixProblem. Marks problems as resolved if they no longer appear in Zabbix. Fires evaluate_problem() for any new problems. """ try: problems = _rpc("problem.get", { "output": "extend", "recent": True, "sortfield": ["eventid"], "sortorder": "DESC", }) except ZabbixAPIError as exc: logger.error(f"sync_active_problems failed: {exc}") raise self.retry(exc=exc) active_event_ids = set() new_problem_pks = [] for p in problems: active_event_ids.add(p["eventid"]) # Resolve the host via the trigger's host list zhost = None try: trigger_info = _rpc("trigger.get", { "output": ["triggerid"], "triggerids": [p["objectid"]], "selectHosts":["hostid"], }) if trigger_info and trigger_info[0]["hosts"]: hostid = trigger_info[0]["hosts"][0]["hostid"] zhost = ZabbixHost.objects.get(zabbix_hostid=hostid) except (ZabbixAPIError, ZabbixHost.DoesNotExist, IndexError): pass obj, created = ZabbixProblem.objects.update_or_create( zabbix_eventid=p["eventid"], defaults={ "host": zhost, "name": p["name"], "severity": int(p["severity"]), "acknowledged": int(p.get("acknowledged", 0)) > 0, "suppressed": p.get("suppressed", "0") == "1", "clock": int(p["clock"]), "r_clock": None, }, ) if created and zhost and zhost.device: new_problem_pks.append(obj.pk) # Mark resolved: locally active problems no longer in Zabbix active list resolved_count = ZabbixProblem.objects.filter( r_clock__isnull=True ).exclude( zabbix_eventid__in=active_event_ids ).update(r_clock=int(time.time())) # Fire alert rule evaluation for each new problem (async) for pk in new_problem_pks: from alerts.rules import evaluate_problem evaluate_problem.delay(pk) logger.info(f"sync_problems: {len(problems)} active, {len(new_problem_pks)} new, {resolved_count} resolved")
Maintenance Windows
When a ChangeRecord is approved, create a Zabbix maintenance window automatically. When the change completes, delete it. No manual Zabbix UI interaction required.
monitoring/maintenance.py
import logging from .zabbix_client import _rpc, ZabbixAPIError logger = logging.getLogger(__name__) def create_maintenance_window( hostids: list[str], name: str, start_unix: int, end_unix: int ) -> str: """ Create a Zabbix maintenance window via API. Returns the maintenance ID string for later deletion. Called from tickets/signals.py when ChangeRecord status → 'approved'. """ result = _rpc("maintenance.create", { "name": name, "active_since": start_unix, "active_till": end_unix, "hostids": hostids, "timeperiods": [{ "timeperiod_type": 0, # One-time "start_date": start_unix, "period": end_unix - start_unix, }], "maintenance_type": 0, # 0 = With data collection }) maintenance_id = result["maintenanceids"][0] logger.info(f"Created Zabbix maintenance {maintenance_id}: {name}") return maintenance_id def delete_maintenance_window(maintenance_id: str) -> None: """ Delete a maintenance window when the change is complete or failed. Called from tickets/signals.py when ChangeRecord status → 'completed' or 'failed'. """ try: _rpc("maintenance.delete", [maintenance_id]) logger.info(f"Deleted Zabbix maintenance {maintenance_id}") except ZabbixAPIError as e: logger.error(f"Failed to delete maintenance {maintenance_id}: {e}") # ── Django signal handler ──────────────────────────────────── # Put this in tickets/signals.py and connect in tickets/apps.py def on_change_record_save(sender, instance, created, **kwargs): """ Django post_save signal for ChangeRecord. - When status becomes 'approved': create maintenance window - When status becomes 'completed' or 'failed': delete maintenance window """ from .zabbix_client import ZabbixAPIError if instance.status == 'approved' and not instance.zabbix_maintenance_id: # Collect Zabbix host IDs for all affected devices hostids = list( instance.affected_devices .filter(zabbix_host__isnull=False) .values_list('zabbix_host__zabbix_hostid', flat=True) ) hostids += list( instance.affected_olts .filter(onus__zabbix_host__isnull=False) .values_list('onus__zabbix_host__zabbix_hostid', flat=True) ) if hostids: from monitoring.maintenance import create_maintenance_window mid = create_maintenance_window( hostids=list(set(hostids)), name=f"CHG: {instance.reference}", start_unix=int(instance.scheduled_start.timestamp()), end_unix=int(instance.scheduled_end.timestamp()), ) sender.objects.filter(pk=instance.pk).update(zabbix_maintenance_id=mid) elif instance.status in ('completed', 'failed') and instance.zabbix_maintenance_id: from monitoring.maintenance import delete_maintenance_window delete_maintenance_window(instance.zabbix_maintenance_id) sender.objects.filter(pk=instance.pk).update(zabbix_maintenance_id='')
Alert Rules Engine
The engine runs every 60 seconds. It takes a new ZabbixProblem, checks suppression conditions, deduplicates against open tickets, opens a new ticket if needed, and dispatches SMS. Under 100ms end-to-end.
alerts/rules.py — Complete File
import logging from celery import shared_task from django.db import transaction from django.utils import timezone from monitoring.models import ZabbixProblem from tickets.models import Ticket, TicketEvent from .models import AlertFiring from .dispatch import send_sms logger = logging.getLogger(__name__) def _severity_to_priority(severity: int) -> str: """Map Zabbix severity (0-5) to our ticket priority (p1-p4).""" if severity >= 5: return 'p1' if severity >= 4: return 'p2' if severity >= 3: return 'p3' return 'p4' def _detect_category(problem_name: str) -> str: """Heuristic category detection from problem name.""" name = problem_name.lower() if any(w in name for w in ['optical','onu','fibre','dbm','signal']): return 'optical' if any(w in name for w in ['pppoe','auth','session','radius']): return 'pppoe' if any(w in name for w in ['interface','bgp','link','down']): return 'hardware' return 'other' @shared_task(name="alerts.rules.evaluate_problem", bind=True, autoretry_for=(Exception,), max_retries=3, default_retry_delay=20) def evaluate_problem(self, problem_pk: int): """ Evaluate a single new ZabbixProblem. Called by sync_active_problems() for each newly detected problem. Decision tree: 1. Load the problem. Bail if ticket already linked. 2. Check suppression: is device in active maintenance? 3. Check deduplication: is there already an open ticket for this device? - Yes → link this problem to existing ticket, return. 4. Open a new Ticket with SLA deadlines auto-calculated. 5. Write TicketEvent (audit trail). 6. Dispatch SMS to NOC for P3+, to customer for P1 (Disaster) only. """ try: problem = ZabbixProblem.objects.select_related( "host__device__customer" ).get(pk=problem_pk) except ZabbixProblem.DoesNotExist: return if problem.ticket_id: return # Already processed device = problem.host.device if problem.host else None customer = device.customer if device else None # ── 1. Suppression check ────────────────────────────────── if problem.suppressed: AlertFiring.objects.create( problem=problem, suppressed=True, suppression_reason="Zabbix maintenance window active" ) logger.info(f"Problem {problem.pk} suppressed (maintenance)") return # ── 2. Deduplication ────────────────────────────────────── existing_ticket = None if device: existing_ticket = Ticket.objects.filter( device=device, status__in=['new', 'open', 'in_progress', 'escalated'], ).order_by('-created_at').first() if existing_ticket: problem.ticket = existing_ticket problem.save(update_fields=['ticket']) TicketEvent.objects.create( ticket=existing_ticket, event_type='comment', body=f"Related Zabbix problem: {problem.name} (event {problem.zabbix_eventid})", ) logger.info(f"Problem {problem.pk} linked to existing ticket {existing_ticket.pk}") return # ── 3. Open new ticket ──────────────────────────────────── priority = _severity_to_priority(problem.severity) category = _detect_category(problem.name) description_lines = [ f"Automatic ticket opened by NOC alert engine.", f"Zabbix Event ID: {problem.zabbix_eventid}", f"Severity: {problem.get_severity_display()}", f"Host: {problem.host.hostname if problem.host else 'unknown'}", f"Device serial: {device.serial_number if device else 'unknown'}", f"OLT port: {device.olt_port_string if device else 'unknown'}", f"Customer: {customer.full_name if customer else 'Unknown'}", f"Phone: {customer.phone_primary if customer else 'Unknown'}", f"Account: {customer.account_number if customer else 'Unknown'}", ] with transaction.atomic(): ticket = Ticket.objects.create( customer=customer, device=device, title=f"[AUTO] {problem.name}", description="\n".join(description_lines), priority=priority, category=category, source='auto_zabbix', status='new', ) # Audit event TicketEvent.objects.create( ticket=ticket, event_type='created', body=f"Auto-opened by alert engine. Zabbix event: {problem.zabbix_eventid}", ) problem.ticket = ticket problem.save(update_fields=['ticket']) logger.info(f"Opened {ticket.reference} (priority={priority}) for problem {problem.pk}") # ── 4. SMS dispatch ─────────────────────────────────────── if problem.severity >= 3: # Average and above # Always notify the NOC tech line _sms_noc(customer, ticket, problem, device) if problem.severity >= 5 and customer: # Disaster only → SMS customer _sms_customer(customer, ticket) def _sms_noc(customer, ticket, problem, device): lines = [ "SPRINT NOC ALERT", f"Ticket: {ticket.reference} [{ticket.get_priority_display()}]", f"Issue: {problem.name}", ] if customer: lines.append(f"Customer: {customer.full_name}") lines.append(f"Phone: {customer.phone_primary}") if device: lines.append(f"Port: {device.olt_port_string}") send_sms(to="+256740019019", message="\n".join(lines), ticket=ticket) def _sms_customer(customer, ticket): first_name = customer.full_name.split()[0] message = ( f"Dear {first_name}, our NOC has detected an issue on your Sprint connection. " f"Our team has been alerted automatically. Ticket {ticket.reference}. " f"We will contact you shortly. Call 0326 300 300 for updates." ) market = customer.market send_sms(to=customer.phone_primary, message=message, sender='SprintTZ' if market == 'TZ' else 'SprintUG', ticket=ticket)
SMS Dispatch
Africa's Talking handles both Uganda (+256) and Tanzania (+255). Every SMS is logged to SMSLog — you need the audit trail.
alerts/dispatch.py
import logging import africastalking from django.conf import settings from .models import SMSLog logger = logging.getLogger(__name__) # Initialise Africa's Talking SDK once at import time africastalking.initialize( username=settings.AT_USERNAME, api_key=settings.AT_API_KEY, ) _sms_service = africastalking.SMS def send_sms( to: str, message: str, sender: str = "SprintUG", ticket=None, ) -> dict: """ Send an SMS via Africa's Talking. - Works for Uganda (+256) and Tanzania (+255) numbers. - Logs every attempt to SMSLog regardless of outcome. - Returns the AT API response dict. Usage: send_sms(to='+256700123456', message='...', sender='SprintUG') send_sms(to='+255712345678', message='...', sender='SprintTZ') """ log = SMSLog.objects.create(to=to, message=message, ticket=ticket) try: response = _sms_service.send( message=message, recipients=[to], sender_id=sender, ) log.status = 'sent' log.at_response = response log.save(update_fields=['status', 'at_response']) logger.info(f"SMS sent to {to}: {response}") return response except Exception as e: log.status = 'failed' log.at_response = {'error': str(e)} log.save(update_fields=['status', 'at_response']) logger.error(f"SMS failed to {to}: {e}") return {}
SLA Engine
Every ticket has two SLA deadlines calculated at creation. A Celery task checks them every 5 minutes and writes breach records when they are crossed.
reports/sla.py
import calendar import logging from celery import shared_task from django.utils import timezone from tickets.models import Ticket, TicketEvent logger = logging.getLogger(__name__) @shared_task(name="reports.sla.check_sla_breaches") def check_sla_breaches(): """ Run every 5 minutes. Find tickets that have crossed their SLA deadlines. Mark them breached and write audit TicketEvents. """ now = timezone.now() # Response SLA: ticket opened but no first_response_at yet response_breaches = Ticket.objects.filter( status__in=['new'], first_response_at__isnull=True, sla_response_deadline__lt=now, sla_response_breached=False, ) for ticket in response_breaches: ticket.sla_response_breached = True ticket.status = 'escalated' ticket.save(update_fields=['sla_response_breached', 'status']) TicketEvent.objects.create( ticket=ticket, event_type='sla_breach', body=f"RESPONSE SLA BREACHED. Deadline: {ticket.sla_response_deadline}. Auto-escalated.", ) # Notify CTO + COO for P1 response breaches if ticket.priority == 'p1': from alerts.dispatch import send_sms send_sms( to="+256741000000", # CTO message=f"P1 RESPONSE SLA BREACH: {ticket.reference} — {ticket.title[:60]}", ticket=ticket, ) # Resolve SLA: not yet resolved and deadline passed resolve_breaches = Ticket.objects.filter( status__in=['new', 'open', 'in_progress', 'escalated'], resolved_at__isnull=True, sla_resolve_deadline__lt=now, sla_resolve_breached=False, ) for ticket in resolve_breaches: ticket.sla_resolve_breached = True ticket.save(update_fields=['sla_resolve_breached']) TicketEvent.objects.create( ticket=ticket, event_type='sla_breach', body=f"RESOLVE SLA BREACHED. Deadline: {ticket.sla_resolve_deadline}.", ) logger.info(f"SLA check: {response_breaches.count()} response breaches, " f"{resolve_breaches.count()} resolve breaches") def calculate_customer_uptime(customer_id: int, month: int, year: int) -> dict: """ Calculate percentage uptime for a customer in a given month. Used for SLA reporting and credit calculations. Returns: { 'customer_id': int, 'month': str (YYYY-MM), 'total_downtime_seconds': float, 'uptime_pct': float, 'sla_breached': bool, 'credit_eligible': bool, } """ days = calendar.monthrange(year, month)[1] total_seconds = days * 86400 # Sum downtime: only auto_zabbix sourced, closed tickets # Ticket.created_at = service loss time # Ticket.resolved_at = service restored time resolved_tickets = Ticket.objects.filter( customer_id=customer_id, source='auto_zabbix', status='closed', created_at__year=year, created_at__month=month, resolved_at__isnull=False, ) total_downtime = sum( (t.resolved_at - t.created_at).total_seconds() for t in resolved_tickets if t.resolved_at > t.created_at ) uptime_pct = ((1 - total_downtime / total_seconds) * 100) return { 'customer_id': customer_id, 'month': f"{year}-{month:02d}", 'total_downtime_seconds': total_downtime, 'uptime_pct': round(uptime_pct, 4), 'sla_target': 99.5, 'sla_breached': uptime_pct < 99.5, 'credit_eligible': uptime_pct < 99.0, }
Trend Analysis
Run nightly at 2am. Find ONUs whose Rx power is trending toward the warning threshold. Open a proactive Problem Record before the customer notices anything.
alerts/analysis.py
import time import logging import numpy as np from celery import shared_task from monitoring.models import ZabbixItem, MetricSample from tickets.models import ProblemRecord logger = logging.getLogger(__name__) WARNING_THRESHOLD = -27.0 # dBm — signal warning level PREDICTION_HOURS = 48 # Predict 48 hours into the future MIN_SAMPLE_COUNT = 100 # Need at least 100 data points (about 1.5h) LOOKBACK_DAYS = 7 # Use last 7 days for regression @shared_task(name="alerts.analysis.detect_degradation_trends") def detect_degradation_trends(): """ Nightly task: find ONU Rx power items where a linear regression over the last 7 days predicts crossing -27 dBm within the next 48 hours. Opens a proactive ProblemRecord (status=investigating) so NOC can schedule a splice inspection before any customer-visible impact. """ seven_days_ago = int(time.time()) - (LOOKBACK_DAYS * 86400) rx_items = ZabbixItem.objects.filter( key__icontains='rx.power' ).select_related('host__device__customer') problems_opened = 0 for item in rx_items: samples = list( MetricSample.objects.filter( item=item, clock__gte=seven_days_ago, value_float__isnull=False, ).order_by('clock').values_list('clock', 'value_float') ) if len(samples) < MIN_SAMPLE_COUNT: continue clocks = np.array([s[0] for s in samples], dtype=float) values = np.array([s[1] for s in samples]) current_value = values[-1] # Already in warning — don't open a duplicate problem if current_value < WARNING_THRESHOLD: continue # Linear regression: clock vs dBm value # Normalise clock to seconds-from-start to avoid floating point issues t_norm = clocks - clocks[0] slope, intercept = np.polyfit(t_norm, values, 1) # Predict value 48 hours from last sample future_t = (clocks[-1] - clocks[0]) + (PREDICTION_HOURS * 3600) predicted = slope * future_t + intercept if predicted >= WARNING_THRESHOLD: continue # Trend not heading toward threshold # Trend will cross -27 dBm within 48 hours # Open a proactive problem record device = item.host.device if item.host else None customer = device.customer if device else None olt = device.olt if device else None # Don't open a duplicate if one is already open for this device existing = ProblemRecord.objects.filter( affected_device=device, status__in=['investigating', 'known_error', 'change_raised'], ).first() if existing: continue from tickets.models import ProblemRecord import uuid ProblemRecord.objects.create( reference=f"PRB-{str(uuid.uuid4().int)[:5].zfill(5)}", title=f"PROACTIVE: Signal degradation trend on {item.host.hostname}", description=( f"Automated trend analysis detected that ONU Rx power on " f"{item.host.hostname} (port {device.olt_port_string if device else 'unknown'}) " f"is trending from {current_value:.1f} dBm toward the warning threshold of " f"{WARNING_THRESHOLD} dBm.\n\n" f"7-day trend slope: {slope*3600:.4f} dBm/hour\n" f"Predicted value in 48h: {predicted:.1f} dBm\n\n" f"Customer: {customer.full_name if customer else 'Unknown'}\n" f"Recommended action: Schedule splice inspection on OLT port "{device.olt_port_string if device else ''}" ), status='investigating', affected_device=device, affected_olt=olt, ) problems_opened += 1 logger.info(f"Proactive problem opened for {item.host.hostname}") logger.info(f"Trend analysis: {problems_opened} proactive problems opened")
Monitoring REST API
Three JSON endpoints. That is all Chart.js needs. One for historical time-series, one for live ONU summary per OLT, one for active problems.
api/views.py — Complete File
import time from django.http import JsonResponse from django.views import View from django.contrib.auth.mixins import LoginRequiredMixin from monitoring.models import ZabbixItem, MetricSample, ZabbixHost, ZabbixProblem from tickets.models import Ticket class ItemHistoryView(LoginRequiredMixin, View): """ GET /api/history/?item_id=<int>&hours=<int> Returns time-series for a single Zabbix item. Chart.js expects x in milliseconds (clock * 1000) and y as float. """ def get(self, request): item_id = request.GET.get("item_id") hours = int(request.GET.get("hours", 6)) if not item_id: return JsonResponse({"error": "item_id required"}, status=400) try: item = ZabbixItem.objects.select_related("host").get(pk=item_id) except ZabbixItem.DoesNotExist: return JsonResponse({"error": "Not found"}, status=404) since = int(time.time()) - (hours * 3600) samples = ( MetricSample.objects .filter(item=item, clock__gte=since) .order_by("clock") .values_list("clock", "value_float") ) return JsonResponse({ "item_id": item.pk, "name": item.name, "units": item.units, "host": item.host.hostname, "data": [ {"x": c * 1000, "y": round(v, 2)} for c, v in samples if v is not None ], }) class ONUSummaryView(LoginRequiredMixin, View): """ GET /api/onu-summary/?olt_id=<int> Latest Rx power reading for every ONU on an OLT. Used to build the heat map grid on the OLT detail page. Auto-refreshed every 60 seconds by the NOC dashboard JS. """ def get(self, request): olt_id = request.GET.get("olt_id") if not olt_id: return JsonResponse({"error": "olt_id required"}, status=400) hosts = ZabbixHost.objects.filter( device__olt_id=olt_id, device__is_active=True, ).select_related('device__customer', 'device__olt') result = [] for zhost in hosts: device = zhost.device customer = device.customer if device else None # Get the rx.power item and its latest sample rx_item = zhost.items.filter(key__icontains='rx.power').first() latest = None rx_item_id = None if rx_item: latest = ( MetricSample.objects .filter(item=rx_item, value_float__isnull=False) .order_by('-clock') .values('value_float', 'clock') .first() ) rx_item_id = rx_item.pk rx_dbm = latest['value_float'] if latest else None # Count open tickets for this device open_tickets = Ticket.objects.filter( device=device, status__in=['new','open','in_progress','escalated'], ).count() if device else 0 result.append({ "zabbix_hostid": zhost.zabbix_hostid, "hostname": zhost.hostname, "rx_item_id": rx_item_id, "olt_port": device.olt_port_string if device else "", "serial": device.serial_number if device else "", "customer_name": customer.full_name if customer else "Unlinked", "customer_phone": customer.phone_primary if customer else "", "account_number": customer.account_number if customer else "", "rx_dbm": round(rx_dbm, 2) if rx_dbm is not None else None, "last_seen": latest['clock'] if latest else None, "signal_status": _classify_signal(rx_dbm), "open_tickets": open_tickets, }) return JsonResponse({"onus": result, "total": len(result)}) class ActiveProblemsView(LoginRequiredMixin, View): """GET /api/problems/ — All active Zabbix problems for the NOC feed.""" def get(self, request): problems = ZabbixProblem.objects.filter( r_clock__isnull=True ).select_related( 'host__device__customer' ).order_by('-severity', '-clock')[:100] return JsonResponse({"problems": [ { "pk": p.pk, "name": p.name, "severity": p.severity, "severity_label": p.get_severity_display(), "host": p.host.hostname if p.host else "", "customer": p.host.device.customer.full_name if p.host and p.host.device else "Unknown", "ticket_ref": p.ticket.reference if p.ticket else None, "clock": p.clock, "acknowledged": p.acknowledged, } for p in problems ]}) def _classify_signal(dbm: float | None) -> str: if dbm is None: return "unknown" if dbm >= -24: return "excellent" if dbm >= -27: return "good" if dbm >= -29: return "warning" return "critical"
api/urls.py
from django.urls import path from . import views urlpatterns = [ path('history/', views.ItemHistoryView.as_view(), name='api-history'), path('onu-summary/', views.ONUSummaryView.as_view(), name='api-onu-summary'), path('problems/', views.ActiveProblemsView.as_view(), name='api-problems'), ]
NOC Dashboard Views
Class-based views with rich context. Everything the template needs arrives in the context dict — no AJAX required for the initial page load.
noc/views.py
from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic import TemplateView, DetailView from django.http import JsonResponse from django.views import View from monitoring.models import ZabbixProblem, ZabbixHost from monitoring.zabbix_client import _rpc from tickets.models import Ticket from customers.models import OLT, Customer class NocOverviewView(LoginRequiredMixin, TemplateView): template_name = 'noc/overview.html' def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) # Active problems by severity all_problems = ZabbixProblem.objects.filter(r_clock__isnull=True) ctx['problem_disaster'] = all_problems.filter(severity=5).count() ctx['problem_high'] = all_problems.filter(severity=4).count() ctx['problem_warning'] = all_problems.filter(severity__in=[2,3]).count() ctx['total_problems'] = all_problems.count() # Open tickets by priority open_tickets = Ticket.objects.filter( status__in=['new','open','in_progress','escalated'] ) ctx['tickets_p1'] = open_tickets.filter(priority='p1').count() ctx['tickets_p2'] = open_tickets.filter(priority='p2').count() ctx['tickets_sla'] = open_tickets.filter(sla_resolve_breached=True).count() ctx['total_open'] = open_tickets.count() # OLT health summary ctx['olts'] = OLT.objects.filter(is_active=True) # Last 10 auto-opened tickets ctx['recent_auto_tickets'] = ( Ticket.objects.filter(source='auto_zabbix') .select_related('customer', 'device') .order_by('-created_at')[:10] ) return ctx class OltDetailView(LoginRequiredMixin, DetailView): model = OLT template_name = 'noc/olt_detail.html' context_object_name = 'olt' def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) olt = self.get_object() # Count ONUs by signal status for the summary bar hosts = ZabbixHost.objects.filter(device__olt=olt) ctx['total_onus'] = hosts.count() ctx['olt_id_json'] = olt.pk # Passed to JS for API call return ctx class CustomerNocView(LoginRequiredMixin, DetailView): model = Customer template_name = 'noc/customer_detail.html' context_object_name = 'customer' def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) customer = self.get_object() # All active devices ctx['devices'] = customer.devices.filter(is_active=True).select_related('olt') # Last 5 tickets ctx['recent_tickets'] = customer.tickets.order_by('-created_at')[:5] # Current open ticket (if any) ctx['open_ticket'] = customer.tickets.filter( status__in=['new','open','in_progress','escalated'] ).first() return ctx class AcknowledgeProblemView(LoginRequiredMixin, View): """POST /noc/ack/<problem_pk>/ — Acknowledge a Zabbix problem from the UI.""" def post(self, request, pk): try: problem = ZabbixProblem.objects.get(pk=pk) _rpc("event.acknowledge", { "eventids": [problem.zabbix_eventid], "action": 6, # 6 = acknowledge + add message "message": f"Acknowledged by {request.user.username} via SprintUG NOC", }) problem.acknowledged = True problem.save(update_fields=['acknowledged']) return JsonResponse({"status": "ok"}) except ZabbixProblem.DoesNotExist: return JsonResponse({"error": "not found"}, status=404)
NOC Templates — OLT Heat Map & Signal Chart
The OLT detail template is the most important page in the system. It shows every ONU on one screen, coloured by signal health, and renders a Chart.js graph when you click any ONU card.
Create a top-level templates/ directory at the project root (/opt/sprintug/templates/). Add subdirectories per app: templates/noc/, templates/tickets/, etc. The TEMPLATES setting in settings.py already points to BASE_DIR / 'templates'.
templates/noc/olt_detail.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>{{ olt.name }} — SprintUG NOC</title> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script> <style> :root { --bg:#0B1D36; --card:#112240; --border:#1d3461; --accent:#1565C0; --cyan:#00BCD4; --orange:#FF7043; --text:#e8f0fe; --muted:#8899aa; --good:#27ae60; --warn:#f39c12; --crit:#e74c3c; --unknown:#666; } body { background:var(--bg); color:var(--text); font-family:'DM Sans',sans-serif; margin:0; padding:20px; } h1 { font-family:'Cormorant Garamond',serif; font-size:2rem; margin-bottom:6px; } .meta { font-size:.8rem; color:var(--muted); margin-bottom:24px; } /* ONU Grid */ .onu-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:10px; margin-bottom:32px; } .onu-card { background:var(--card); border:1px solid var(--border); border-left:4px solid; border-radius:8px; padding:12px; cursor:pointer; transition:border-color .2s; } .onu-card:hover { border-color:var(--cyan) !important; } .onu-card.excellent { border-left-color:var(--good); } .onu-card.good { border-left-color:var(--accent); } .onu-card.warning { border-left-color:var(--warn); } .onu-card.critical { border-left-color:var(--crit); } .onu-card.unknown { border-left-color:var(--unknown); } .onu-port { font-size:.65rem; color:var(--muted); margin-bottom:2px; } .onu-signal { font-size:1.3rem; font-weight:700; line-height:1.2; } .excellent .onu-signal { color:var(--good); } .good .onu-signal { color:var(--cyan); } .warning .onu-signal { color:var(--warn); } .critical .onu-signal { color:var(--crit); } .onu-name { font-size:.72rem; margin-top:5px; color:rgba(255,255,255,.7); } .onu-ticket { font-size:.65rem; color:var(--orange); margin-top:2px; } /* Chart area */ .chart-panel { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:20px; display:none; } .chart-title { font-size:1rem; margin-bottom:16px; color:var(--cyan); } canvas#signalChart { width:100%!important; height:300px!important; } </style> </head> <body> <h1>{{ olt.name }}</h1> <div class="meta">{{ olt.model }} · {{ olt.location }} · {{ olt.ip_address }}</div> <div id="onu-grid" class="onu-grid"> <div style="color:var(--muted);padding:20px">Loading ONU data...</div> </div> <div class="chart-panel" id="chart-panel"> <div class="chart-title" id="chart-title"></div> <canvas id="signalChart"></canvas> </div> {# Django CSRF token for AJAX — available as getCookie('csrftoken') #} <script> const OLT_ID = {{ olt_id_json }}; const CSRF = document.cookie.match(/csrftoken=([^;]+)/)?.[1] ?? ''; let signalChart = null; async function loadONUGrid() { const resp = await fetch(`/api/onu-summary/?olt_id=${OLT_ID}`, {headers: {'X-CSRFToken': CSRF}}); const data = await resp.json(); const grid = document.getElementById('onu-grid'); grid.innerHTML = ''; // Sort: critical first, then warning, then good, then excellent const order = {critical:0, warning:1, good:2, excellent:3, unknown:4}; data.onus.sort((a,b) => (order[a.signal_status]||9) - (order[b.signal_status]||9)); data.onus.forEach(onu => { const card = document.createElement('div'); card.className = `onu-card ${onu.signal_status}`; card.innerHTML = ` <div class="onu-port">${onu.olt_port || onu.hostname}</div> <div class="onu-signal"> ${onu.rx_dbm !== null ? onu.rx_dbm.toFixed(1) + ' dBm' : '—'} </div> <div class="onu-name">${onu.customer_name}</div> ${onu.open_tickets > 0 ? `<div class="onu-ticket">⚠ ${onu.open_tickets} open ticket(s)</div>` : ''} `; card.addEventListener('click', () => loadSignalChart(onu)); grid.appendChild(card); }); } async function loadSignalChart(onu) { if (!onu.rx_item_id) { alert('No Rx power item linked for this ONU.'); return; } const panel = document.getElementById('chart-panel'); const title = document.getElementById('chart-title'); panel.style.display = 'block'; title.textContent = `${onu.customer_name} · ${onu.olt_port} · Rx Power 24h`; const resp = await fetch(`/api/history/?item_id=${onu.rx_item_id}&hours=24`); const data = await resp.json(); const ctx = document.getElementById('signalChart').getContext('2d'); if (signalChart) signalChart.destroy(); signalChart = new Chart(ctx, { type: 'line', data: { datasets: [{ label: 'Rx Power (dBm)', data: data.data, borderColor: '#00BCD4', backgroundColor: 'rgba(0,188,212,0.07)', borderWidth: 2, pointRadius: 0, tension: 0.3, fill: true, }] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { type: 'time', time: {unit:'hour'}, ticks: {color:'#8899aa'}, grid: {color:'#1d3461'} }, y: { min: -35, max: -15, ticks: {color:'#8899aa', callback: v => v + ' dBm'}, grid: {color:'#1d3461'} }, }, plugins: { legend: {labels: {color:'#e8f0fe'}}, annotation: {annotations: { warnLine: {type:'line',yMin:-27,yMax:-27, borderColor:'#f39c12',borderDash:[5,5],borderWidth:1}, critLine: {type:'line',yMin:-29,yMax:-29, borderColor:'#e74c3c',borderDash:[5,5],borderWidth:1}, }} } } }); panel.scrollIntoView({behavior:'smooth'}); } loadONUGrid(); setInterval(loadONUGrid, 60000); // Auto-refresh every 60 seconds </script> </body> </html>
URL Routing — Every URL in the System
All URLs in one place. No hunting through five apps to find where something is mounted.
from django.contrib import admin from django.urls import path, include from django.contrib.auth import views as auth_views urlpatterns = [ path('admin/', admin.site.urls), # Auth path('login/', auth_views.LoginView.as_view(template_name='auth/login.html'), name='login'), path('logout/', auth_views.LogoutView.as_view(next_page='login'), name='logout'), # NOC Dashboard path('noc/', include('noc.urls')), # API endpoints (JSON for Chart.js) path('api/', include('api.urls')), # CRUD apps path('customers/', include('customers.urls')), path('tickets/', include('tickets.urls')), # Redirect root to NOC overview path('', lambda req: __import__('django.shortcuts', fromlist=['redirect']) .redirect('noc:overview')), ]
from django.urls import path from . import views app_name = 'noc' urlpatterns = [ path('', views.NocOverviewView.as_view(), name='overview'), path('olt/<int:pk>/', views.OltDetailView.as_view(), name='olt-detail'), path('customer/<int:pk>/', views.CustomerNocView.as_view(), name='customer-detail'), path('ack/<int:pk>/', views.AcknowledgeProblemView.as_view(),name='ack-problem'), ]
Management Commands
The ONU inventory import is the single most important management command you will ever write for this system. Write it carefully.
""" Usage: python manage.py import_onu_inventory \\ --file /tmp/onus.csv \\ --olt MA5800-Kawempe-01 \\ --market UG \\ --dry-run CSV format (header row required): serial_number,frame,slot,port,onu_id,pppoe_username,customer_account Any row with customer_account that matches a Customer.account_number will be auto-linked. Unmatched rows are flagged in the summary. """ import csv from django.core.management.base import BaseCommand from customers.models import Customer, OLT, Device class Command(BaseCommand): help = 'Import ONU inventory from CSV into the Device CMDB' def add_arguments(self, parser): parser.add_argument('--file', required=True) parser.add_argument('--olt', required=True, help='OLT name') parser.add_argument('--market', default='UG') parser.add_argument('--dry-run', action='store_true') def handle(self, *args, **options): dry_run = options['dry_run'] try: olt = OLT.objects.get(name=options['olt']) except OLT.DoesNotExist: self.stderr.write(f"OLT '{options['olt']}' not found") return created = unlinked = updated = 0 unlinked_serials = [] with open(options['file'], newline='') as f: reader = csv.DictReader(f) for row in reader: serial = row['serial_number'].strip() customer = None # Try to find matching customer if row.get('customer_account'): try: customer = Customer.objects.get( account_number=row['customer_account'].strip() ) except Customer.DoesNotExist: pass if customer is None: unlinked_serials.append(serial) unlinked += 1 if dry_run: self.stdout.write(f"[DRY] {serial} → {customer or 'UNLINKED'}") continue defaults = { 'device_type': 'ONU', 'olt': olt, 'olt_frame': int(row.get('frame', 0)), 'olt_slot': int(row.get('slot', 0)), 'olt_port': int(row.get('port', 0)), 'olt_onu_id': int(row.get('onu_id', 0)), 'pppoe_username': row.get('pppoe_username', '').strip(), } if customer: defaults['customer'] = customer obj, new = Device.objects.update_or_create( serial_number=serial, defaults=defaults ) if new: created += 1 else: updated += 1 self.stdout.write( self.style.SUCCESS( f"Done: {created} created, {updated} updated, {unlinked} unlinked" ) ) if unlinked_serials: self.stdout.write("Unlinked serials (no customer match):") for s in unlinked_serials: self.stdout.write(f" {s}")
Authentication and Role-Based Permissions
Three roles, implemented with Django groups. No object-level permissions required for basic operation — group membership is sufficient for NOC access control.
| Group | Can do | Cannot do |
|---|---|---|
| noc_operator | View all dashboards, acknowledge problems, add ticket comments | Create/delete customers, approve changes |
| noc_engineer | All of noc_operator + create tickets, update ticket status, create change records | Approve changes, access admin |
| noc_admin | All of noc_engineer + approve changes, access Django admin, manage users | Superuser operations |
from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType # Create the three NOC groups operator, _ = Group.objects.get_or_create(name='noc_operator') engineer, _ = Group.objects.get_or_create(name='noc_engineer') admin_grp, _ = Group.objects.get_or_create(name='noc_admin') # Assign ticket permissions to engineer group from tickets.models import Ticket, ChangeRecord ticket_ct = ContentType.objects.get_for_model(Ticket) change_ct = ContentType.objects.get_for_model(ChangeRecord) engineer.permissions.set(Permission.objects.filter( content_type__in=[ticket_ct, change_ct] )) # Admin group gets all permissions on all NOC models admin_grp.permissions.set(Permission.objects.filter( content_type__app_label__in=['tickets', 'customers', 'monitoring', 'alerts'] )) print("Groups created and permissions assigned.")
Production Deployment Checklist
Run this checklist before any production go-live. Every item has caused a production incident for someone. None of them are optional.
Pre-Flight
# 1. Confirm DEBUG=False in .env grep DEBUG .env # Must show: DEBUG=False # 2. Check all system checks pass python manage.py check --deploy # 3. Collect static files python manage.py collectstatic --noinput # 4. Run all migrations python manage.py migrate # 5. Confirm Celery workers can connect celery -A config inspect ping # 6. Confirm Zabbix API is reachable python manage.py shell -c "from monitoring.zabbix_client import _rpc; print(_rpc('apiinfo.version', {}))" # 7. Send a test SMS python manage.py shell -c "from alerts.dispatch import send_sms; send_sms('+256700000000', 'Test from SprintUG NOC')" # 8. Enable and start all systemd services systemctl enable --now postgresql redis gunicorn celery-worker celery-beat nginx systemctl status gunicorn celery-worker celery-beat # All should be active (running)
You can open the NOC overview page, see real Zabbix problems in the problem feed, click an OLT and see the ONU heat map populate with live signal data, and click an ONU to see a 24-hour Chart.js graph — all without touching the Zabbix web interface.
The network was always talking. Now you are listening.
base.html — The Design System
Every Django template inherits from this one file. The full codeandcore design language — ivory palette, navy control mode, Cormorant Garamond headings, JetBrains Mono labels, glassmorphism sidebar, noise texture, scroll-reveal — lives here and nowhere else.
Template Inheritance Strategy
There are two visual modes in this system. The staff pages — overview, customer detail, ticket list — use the warm ivory palette and feel like an editorial dashboard. The control screens — OLT detail, active problems, live feed — use the deep navy palette and feel like a mission control room. One base.html serves both by switching a data-mode attribute on <body>.
| Template | Extends | Mode | Palette |
|---|---|---|---|
noc/overview.html | base.html | staff | Ivory #FAF8F5 |
noc/customer_detail.html | base.html | staff | Ivory #FAF8F5 |
tickets/list.html | base.html | staff | Ivory #FAF8F5 |
noc/olt_detail.html | base.html | control | Navy #0B1D36 |
noc/problems.html | base.html | control | Navy #0B1D36 |
templates/base.html — Complete File
<!DOCTYPE html> <html lang="en" data-mode="{% block page_mode %}staff{% endblock %}"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}SprintUG NOC{% endblock %} — SprintUG</title> <!-- ── Fonts ── --> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <!-- ── Material Icons (Outlined) ── --> <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet"> <!-- ── Base CSS ── --> {% load static %} <link rel="stylesheet" href="{% static 'css/noc.css' %}"> <!-- ── Per-page extra head ── --> {% block extra_head %}{% endblock %} </head> <body data-mode="{% block body_mode %}staff{% endblock %}"> <!-- ── Reading progress bar ── --> <div class="reading-progress"><div class="reading-progress-bar" id="rpb"></div></div> <!-- ── Glassmorphism Sidebar ── --> <nav class="sidebar" id="sidebar"> <!-- Logo / Brand --> <div class="sidebar-brand"> <div class="sidebar-brand-mark"> <span class="material-icons-outlined">hub</span> </div> <div> <div class="sidebar-brand-name">SprintUG</div> <div class="sidebar-brand-sub">NOC · {{ request.user.username }}</div> </div> </div> <!-- Nav sections --> <div class="nav-section-label">Overview</div> <a class="nav-item {% if request.resolver_match.url_name == 'overview' %}active{% endif %}" href="{% url 'noc:overview' %}"> <span class="material-icons-outlined nav-icon">dashboard</span> NOC Overview </a> <a class="nav-item {% if request.resolver_match.url_name == 'active-problems' %}active{% endif %}" href="{% url 'noc:problems' %}"> <span class="material-icons-outlined nav-icon">warning_amber</span> Active Problems {% if active_problem_count %}<span class="nav-badge">{{ active_problem_count }}</span>{% endif %} </a> <div class="nav-section-label">Infrastructure</div> {% for olt in sidebar_olts %} <a class="nav-item {% if request.resolver_match.kwargs.pk == olt.pk|stringformat:'i' %}active{% endif %}" href="{% url 'noc:olt-detail' olt.pk %}"> <span class="material-icons-outlined nav-icon">device_hub</span> {{ olt.name }} </a> {% endfor %} <div class="nav-section-label">Service Desk</div> <a class="nav-item {% if 'tickets' in request.path %}active{% endif %}" href="{% url 'tickets:list' %}"> <span class="material-icons-outlined nav-icon">confirmation_number</span> Tickets </a> <a class="nav-item {% if 'customers' in request.path %}active{% endif %}" href="{% url 'customers:list' %}"> <span class="material-icons-outlined nav-icon">people_outline</span> Customers </a> <a class="nav-item {% if 'problems' in request.path and 'noc' not in request.path %}active{% endif %}" href="{% url 'tickets:problems' %}"> <span class="material-icons-outlined nav-icon">bug_report</span> Problem Records </a> <a class="nav-item {% if 'changes' in request.path %}active{% endif %}" href="{% url 'tickets:changes' %}"> <span class="material-icons-outlined nav-icon">change_circle</span> Change Records </a> <div class="nav-section-label">Reports</div> <a class="nav-item {% if 'reports' in request.path %}active{% endif %}" href="{% url 'reports:sla' %}"> <span class="material-icons-outlined nav-icon">insert_chart_outlined</span> SLA Reports </a> <!-- Bottom: user controls --> <div class="sidebar-footer"> <a href="{% url 'admin:index' %}" class="nav-item"> <span class="material-icons-outlined nav-icon">admin_panel_settings</span>Admin </a> <a href="{% url 'logout' %}" class="nav-item"> <span class="material-icons-outlined nav-icon">logout</span>Sign Out </a> </div> </nav> <!-- ── Mobile hamburger ── --> <button class="hamburger" id="hamburger" aria-label="Open menu"> <span class="material-icons-outlined">menu</span> </button> <!-- ── Main content ── --> <main class="main-content" id="main"> <!-- Page header --> <header class="page-header"> <div class="page-header-inner"> <div> {% block page_eyebrow %}<div class="page-eyebrow">{% endblock %}</div> <h1 class="page-title">{% block page_title %}{% endblock %}</h1> </div> <div class="page-header-actions">{% block page_actions %}{% endblock %}</div> </div> </header> <!-- Messages (Django messages framework) --> {% if messages %} <div class="messages-container"> {% for message in messages %} <div class="message message-{{ message.tags }}"> <span class="material-icons-outlined"> {% if message.tags == 'success' %}check_circle_outline {% elif message.tags == 'error' %}error_outline {% else %}info</span> {% endif %} {{ message }} </div> {% endfor %} </div> {% endif %} <!-- ── Page content ── --> <div class="page-content"> {% block content %}{% endblock %} </div> </main> <!-- ── Base JavaScript ── --> <script> // Reading progress window.addEventListener('scroll', () => { const d = document.documentElement; const pct = (d.scrollTop / (d.scrollHeight - d.clientHeight)) * 100; document.getElementById('rpb').style.width = pct + '%'; }); // Scroll reveal const revealObs = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('revealed'); }); }, { threshold: 0.08 }); document.querySelectorAll('.reveal').forEach(el => revealObs.observe(el)); // Mobile hamburger const ham = document.getElementById('hamburger'); const side = document.getElementById('sidebar'); ham?.addEventListener('click', () => side?.classList.toggle('open')); // CSRF helper for fetch() calls window.CSRF = document.cookie.match(/csrftoken=([^;]+)/)?.[1] ?? ''; // Auto-dismiss messages after 5s document.querySelectorAll('.message').forEach(el => { setTimeout(() => el.classList.add('dismissed'), 5000); }); </script> {% block extra_js %}{% endblock %} </body> </html>
Context Processor — Sidebar Data
The sidebar needs sidebar_olts and active_problem_count on every page without you having to pass them from every view. Put this in a context processor:
from customers.models import OLT from monitoring.models import ZabbixProblem def noc_globals(request): """ Injected into every template context automatically. Add 'noc.context_processors.noc_globals' to TEMPLATES OPTIONS context_processors list in settings.py. """ if not request.user.is_authenticated: return {} return { 'sidebar_olts': OLT.objects.filter(is_active=True).order_by('name'), 'active_problem_count': ZabbixProblem.objects.filter( r_clock__isnull=True, severity__gte=3 ).count(), }
'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'noc.context_processors.noc_globals', # ← add this ],
How a Child Template Uses base.html
{% extends "base.html" %}
{% block title %}NOC Overview{% endblock %}
{# staff mode = ivory palette #}
{% block page_mode %}staff{% endblock %}
{% block body_mode %}staff{% endblock %}
{% block page_eyebrow %}Network Operations Centre{% endblock %}
{% block page_title %}Overview{% endblock %}
{% block page_actions %}
<a href="{% url 'tickets:create' %}" class="btn btn-primary">
<span class="material-icons-outlined">add</span> New Ticket
</a>
{% endblock %}
{% block content %}
<div class="stat-row reveal">
<div class="stat-card stat-crit">
<div class="stat-val">{{ problem_disaster }}</div>
<div class="stat-label">Disaster</div>
</div>
<div class="stat-card">
<div class="stat-val">{{ problem_high }}</div>
<div class="stat-label">High Severity</div>
</div>
<div class="stat-card">
<div class="stat-val">{{ total_open }}</div>
<div class="stat-label">Open Tickets</div>
</div>
<div class="stat-card stat-warn">
<div class="stat-val">{{ tickets_sla }}</div>
<div class="stat-label">SLA Breached</div>
</div>
</div>
<!-- OLT health row -->
<h2 class="section-title reveal">OLT Health</h2>
<div class="olt-health-row reveal">
{% for olt in olts %}
<a href="{% url 'noc:olt-detail' olt.pk %}" class="olt-health-card">
<div class="olt-name">{{ olt.name }}</div>
<div class="olt-sub">{{ olt.location }}</div>
<div class="olt-count">{{ olt.active_onu_count }} ONUs</div>
<span class="material-icons-outlined olt-arrow">arrow_forward</span>
</a>
{% endfor %}
</div>
<!-- Recent auto-tickets -->
<h2 class="section-title reveal">Recent Auto-Opened Tickets</h2>
<div class="card reveal">
<table class="data-table">
<thead>
<tr><th>Ref</th><th>Customer</th><th>Issue</th><th>Priority</th><th>Opened</th></tr>
</thead>
<tbody>
{% for t in recent_auto_tickets %}
<tr>
<td><a href="{% url 'tickets:detail' t.pk %}">{{ t.reference }}</a></td>
<td>{{ t.customer.full_name|default:"—" }}</td>
<td>{{ t.title|truncatechars:60 }}</td>
<td><span class="priority-badge priority-{{ t.priority }}">{{ t.get_priority_display }}</span></td>
<td>{{ t.created_at|timesince }} ago</td>
</tr>
{% empty %}
<tr><td colspan="5" style="color:var(--ink-faint);text-align:center">No auto-opened tickets yet</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ olt.name }}{% endblock %}
{# control mode = deep navy palette #}
{% block page_mode %}control{% endblock %}
{% block body_mode %}control{% endblock %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script>
{% endblock %}
{% block page_eyebrow %}OLT · {{ olt.market }}{% endblock %}
{% block page_title %}{{ olt.name }}{% endblock %}
{% block page_actions %}
<span class="page-meta">{{ olt.ip_address }} · {{ olt.model }}</span>
{% endblock %}
{% block content %}
<div id="onu-grid" class="onu-grid">
<div class="onu-loading">
<span class="material-icons-outlined">radar</span> Polling ONU data…
</div>
</div>
<div class="chart-panel" id="chart-panel">
<div class="chart-panel-header">
<div class="chart-panel-title" id="chart-title"></div>
<button class="btn btn-ghost" onclick="document.getElementById('chart-panel').style.display='none'">
<span class="material-icons-outlined">close</span>
</button>
</div>
<canvas id="signalChart"></canvas>
</div>
{% endblock %}
{% block extra_js %}
<script>
const OLT_ID = {{ olt_id_json }};
let signalChart = null;
async function loadONUGrid() {
const resp = await fetch(`/api/onu-summary/?olt_id=${OLT_ID}`);
const data = await resp.json();
const grid = document.getElementById('onu-grid');
grid.innerHTML = '';
const order = {critical:0, warning:1, good:2, excellent:3, unknown:4};
data.onus.sort((a,b) => (order[a.signal_status]||9) - (order[b.signal_status]||9));
data.onus.forEach(onu => {
const card = document.createElement('div');
card.className = `onu-card ${onu.signal_status}`;
card.innerHTML = `
<div class="onu-port">${onu.olt_port || onu.hostname}</div>
<div class="onu-signal">${onu.rx_dbm !== null ? onu.rx_dbm.toFixed(1) + ' dBm' : '—'}</div>
<div class="onu-name">${onu.customer_name}</div>
${onu.open_tickets > 0
? `<div class="onu-ticket">
<span class="material-icons-outlined" style="font-size:11px">warning_amber</span>
${onu.open_tickets} open
</div>`
: ''}
`;
card.addEventListener('click', () => loadSignalChart(onu));
grid.appendChild(card);
});
}
async function loadSignalChart(onu) {
if (!onu.rx_item_id) return;
document.getElementById('chart-panel').style.display = 'block';
document.getElementById('chart-title').textContent =
`${onu.customer_name} · ${onu.olt_port} · Rx Power 24h`;
const resp = await fetch(`/api/history/?item_id=${onu.rx_item_id}&hours=24`);
const data = await resp.json();
const ctx = document.getElementById('signalChart').getContext('2d');
if (signalChart) signalChart.destroy();
signalChart = new Chart(ctx, {
type: 'line',
data: { datasets: [{
label: 'Rx Power (dBm)',
data: data.data,
borderColor: '#00BCD4',
backgroundColor: 'rgba(0,188,212,0.07)',
borderWidth: 2, pointRadius: 0, tension: 0.3, fill: true,
}]},
options: {
responsive: true, maintainAspectRatio: false,
scales: {
x: { type:'time', time:{unit:'hour'},
ticks:{color:'#8899aa'}, grid:{color:'rgba(255,255,255,.06)'} },
y: { min:-35, max:-15,
ticks:{color:'#8899aa', callback: v => v + ' dBm'},
grid:{color:'rgba(255,255,255,.06)'} },
},
plugins: {
legend: {labels:{color:'#e8f0fe'}},
annotation: { annotations: {
warnLine: {type:'line',yMin:-27,yMax:-27,borderColor:'#f39c12',
borderDash:[5,5],borderWidth:1,
label:{content:'Warning −27 dBm',display:true,
backgroundColor:'rgba(243,156,18,.15)',color:'#f39c12'}},
critLine: {type:'line',yMin:-29,yMax:-29,borderColor:'#e74c3c',
borderDash:[5,5],borderWidth:1,
label:{content:'Critical −29 dBm',display:true,
backgroundColor:'rgba(231,76,60,.15)',color:'#e74c3c'}},
}}
}
}
});
document.getElementById('chart-panel').scrollIntoView({behavior:'smooth'});
}
loadONUGrid();
setInterval(loadONUGrid, 60000);
</script>
{% endblock %}
noc.css — Ivory & Navy Dual-Palette
One stylesheet. Two modes. The data-mode="staff" attribute on body activates ivory. data-mode="control" activates deep navy. Every token, every component, every animation — nothing is generic, nothing is Bootstrap.
Save this file to /opt/sprintug/static/css/noc.css. Run python manage.py collectstatic after any change in production. In development with DEBUG=True, Django serves it directly from static/css/.
static/css/noc.css — Complete File
/* ═══════════════════════════════════════════════════════════ SprintUG NOC Design System codeandcore design language — daudithemechanic Two modes: staff (ivory) · control (navy) ═══════════════════════════════════════════════════════════ */ /* ── Fonts ── */ @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=JetBrains+Mono:wght@400;500;600&display=swap'); @import url('https://fonts.googleapis.com/icon?family=Material+Icons+Outlined'); /* ── Global tokens ── */ :root { /* Typography */ --font-head: 'Cormorant Garamond', Georgia, serif; --font-body: 'DM Sans', system-ui, sans-serif; --font-mono: 'JetBrains Mono', 'Fira Code', monospace; /* SprintUG brand blues */ --brand-navy: #0B1D36; --brand-blue: #1565C0; --brand-cyan: #00BCD4; --brand-orange: #FF7043; /* Gold accent — used in both modes */ --gold: #C9A84C; --gold-lt: #F5E6C4; /* Signal health colours */ --signal-excellent: #27ae60; --signal-good: #1565C0; --signal-warning: #f39c12; --signal-critical: #e74c3c; --signal-unknown: #6c757d; /* Shadows */ --shadow-sm: 0 1px 3px rgba(0,0,0,.06); --shadow-md: 0 4px 16px rgba(0,0,0,.09); --shadow-lg: 0 12px 40px rgba(0,0,0,.14); /* Layout */ --sidebar-w: 272px; --radius: 12px; --radius-sm: 8px; --trans: cubic-bezier(.25,.46,.45,.94); } /* ── Mode: staff (ivory) ── */ [data-mode="staff"] { --bg: #FAF8F5; --bg-1: #F5F0EA; --bg-2: #EDE7DD; --bg-3: #E0D8CC; --surface: #ffffff; --surface-raised: #ffffff; --border: #E0D8CC; --border-focus: #2980B9; --ink: #1A1A1A; --ink-muted: #5A5A5A; --ink-faint: #9A9A8A; --accent: #2980B9; --accent-dk: #1a5f8a; --accent-lt: #d6eaf8; --sidebar-bg: rgba(245,240,234,.85); --sidebar-border: #E0D8CC; --header-bg: rgba(250,248,245,.9); } /* ── Mode: control (navy) ── */ [data-mode="control"] { --bg: #0B1D36; --bg-1: #0f2744; --bg-2: #112240; --bg-3: #1d3461; --surface: #112240; --surface-raised: #162a50; --border: #1d3461; --border-focus: #00BCD4; --ink: #e8f0fe; --ink-muted: #8899aa; --ink-faint: #4a5a6a; --accent: #00BCD4; --accent-dk: #0097a7; --accent-lt: rgba(0,188,212,.12); --sidebar-bg: rgba(11,29,54,.88); --sidebar-border: rgba(255,255,255,.08); --header-bg: rgba(11,29,54,.9); } /* ── Reset ── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html { scroll-behavior: smooth; } body { font-family: var(--font-body); background: var(--bg); color: var(--ink); font-size: 16px; line-height: 1.7; overflow-x: hidden; transition: background .3s var(--trans), color .3s var(--trans); } /* ── Noise texture overlay ── */ body::before { content: ''; position: fixed; inset: 0; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E"); opacity: .022; pointer-events: none; z-index: 9999; } /* ── Reading progress bar ── */ .reading-progress { position: fixed; top: 0; left: var(--sidebar-w); right: 0; height: 3px; background: transparent; z-index: 300; } .reading-progress-bar { height: 100%; background: linear-gradient(90deg, var(--accent), var(--gold)); width: 0%; transition: width .1s linear; } /* ═══════════════════════════════════════════ SIDEBAR ═══════════════════════════════════════════ */ .sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: var(--sidebar-w); background: var(--sidebar-bg); border-right: 1px solid var(--sidebar-border); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); overflow-y: auto; overflow-x: hidden; z-index: 200; display: flex; flex-direction: column; transition: transform .3s var(--trans); } .sidebar::-webkit-scrollbar { width: 4px; } .sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } .sidebar-brand { display: flex; align-items: center; gap: 12px; padding: 22px 20px 18px; border-bottom: 1px solid var(--sidebar-border); } .sidebar-brand-mark { width: 36px; height: 36px; border-radius: var(--radius-sm); background: linear-gradient(135deg, var(--brand-blue), var(--brand-cyan)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .sidebar-brand-mark .material-icons-outlined { font-size: 20px; color: #fff; } .sidebar-brand-name { font-family: var(--font-head); font-size: 1.05rem; font-weight: 600; color: var(--ink); line-height: 1.2; } .sidebar-brand-sub { font-size: .68rem; color: var(--ink-faint); letter-spacing: .05em; } .nav-section-label { padding: 14px 20px 4px; font-family: var(--font-mono); font-size: .6rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-faint); } .nav-item { display: flex; align-items: center; gap: 10px; padding: 8px 20px; text-decoration: none; color: var(--ink-muted); font-size: .83rem; font-weight: 400; border-left: 3px solid transparent; transition: all .18s var(--trans); cursor: pointer; } .nav-item:hover { color: var(--accent); background: var(--accent-lt); } .nav-item.active { color: var(--accent); background: var(--accent-lt); border-left-color: var(--accent); font-weight: 500; } .nav-icon { font-size: 18px !important; opacity: .7; flex-shrink: 0; } .nav-item.active .nav-icon, .nav-item:hover .nav-icon { opacity: 1; } .nav-badge { margin-left: auto; background: var(--signal-critical); color: #fff; font-family: var(--font-mono); font-size: .6rem; padding: 1px 6px; border-radius: 10px; font-weight: 600; } .sidebar-footer { margin-top: auto; padding-top: 8px; border-top: 1px solid var(--sidebar-border); } /* ═══════════════════════════════════════════ MAIN CONTENT AREA ═══════════════════════════════════════════ */ .main-content { margin-left: var(--sidebar-w); min-height: 100vh; display: flex; flex-direction: column; } .page-header { position: sticky; top: 0; z-index: 100; background: var(--header-bg); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); padding: 0 40px; } .page-header-inner { display: flex; align-items: center; justify-content: space-between; height: 70px; gap: 20px; } .page-eyebrow { font-family: var(--font-mono); font-size: .62rem; letter-spacing: .15em; text-transform: uppercase; color: var(--accent); margin-bottom: 1px; } .page-title { font-family: var(--font-head); font-size: 1.5rem; font-weight: 600; color: var(--ink); line-height: 1.1; } .page-meta { font-size: .78rem; color: var(--ink-faint); } .page-header-actions { display: flex; align-items: center; gap: 10px; } .page-content { padding: 36px 40px; flex: 1; max-width: 1400px; } /* ═══════════════════════════════════════════ TYPOGRAPHY ═══════════════════════════════════════════ */ h2.section-title { font-family: var(--font-head); font-size: 1.6rem; font-weight: 600; color: var(--ink); margin: 40px 0 16px; } h2.section-title::before { content: ''; display: block; width: 28px; height: 2px; background: var(--accent); margin-bottom: 8px; } h3 { font-family: var(--font-head); font-size: 1.2rem; font-weight: 600; color: var(--ink); margin: 24px 0 10px; } /* ═══════════════════════════════════════════ CARDS & SURFACES ═══════════════════════════════════════════ */ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; } .card:hover { box-shadow: var(--shadow-md); } .card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); font-family: var(--font-head); font-size: 1.05rem; font-weight: 600; display: flex; align-items: center; gap: 10px; } .card-body { padding: 20px; } /* White card with 3px accent top-border on hover (codeandcore spec) */ .card-accent { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); border-top: 3px solid transparent; box-shadow: var(--shadow-sm); transition: all .22s var(--trans); overflow: hidden; } .card-accent:hover { border-top-color: var(--accent); box-shadow: var(--shadow-md); transform: translateY(-2px); } /* ═══════════════════════════════════════════ STAT ROW ═══════════════════════════════════════════ */ .stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 32px; } .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; text-align: center; box-shadow: var(--shadow-sm); border-top: 3px solid var(--border); transition: all .2s var(--trans); } .stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); } .stat-card.stat-crit { border-top-color: var(--signal-critical); } .stat-card.stat-warn { border-top-color: var(--signal-warning); } .stat-card.stat-good { border-top-color: var(--signal-excellent); } .stat-val { font-family: var(--font-head); font-size: 2.6rem; font-weight: 600; color: var(--accent); line-height: 1; margin-bottom: 4px; } .stat-card.stat-crit .stat-val { color: var(--signal-critical); } .stat-card.stat-warn .stat-val { color: var(--signal-warning); } .stat-label { font-size: .75rem; color: var(--ink-muted); } /* ═══════════════════════════════════════════ ONU HEAT MAP GRID ═══════════════════════════════════════════ */ .onu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 10px; margin-bottom: 28px; } .onu-loading { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; color: var(--ink-faint); font-size: .88rem; padding: 32px 0; } .onu-loading .material-icons-outlined { animation: spin 1.5s linear infinite; color: var(--accent); } @keyframes spin { to { transform: rotate(360deg); } } .onu-card { background: var(--surface-raised); border: 1px solid var(--border); border-left: 4px solid; border-radius: var(--radius-sm); padding: 12px 14px; cursor: pointer; transition: all .18s var(--trans); } .onu-card:hover { border-color: var(--accent-lt); border-left-color: var(--accent); box-shadow: var(--shadow-md); transform: translateY(-1px); } .onu-card.excellent { border-left-color: var(--signal-excellent); } .onu-card.good { border-left-color: var(--signal-good); } .onu-card.warning { border-left-color: var(--signal-warning); } .onu-card.critical { border-left-color: var(--signal-critical); background: rgba(231,76,60,.06); } .onu-card.unknown { border-left-color: var(--signal-unknown); } .onu-port { font-family: var(--font-mono); font-size: .62rem; color: var(--ink-faint); letter-spacing: .05em; margin-bottom: 2px; } .onu-signal { font-family: var(--font-mono); font-size: 1.25rem; font-weight: 600; line-height: 1.2; } .onu-card.excellent .onu-signal { color: var(--signal-excellent); } .onu-card.good .onu-signal { color: var(--accent); } .onu-card.warning .onu-signal { color: var(--signal-warning); } .onu-card.critical .onu-signal { color: var(--signal-critical); } .onu-card.unknown .onu-signal { color: var(--ink-faint); } .onu-name { font-size: .72rem; color: var(--ink-muted); margin-top: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .onu-ticket { display: flex; align-items: center; gap: 3px; font-size: .65rem; color: var(--signal-warning); margin-top: 4px; font-weight: 500; } /* ═══════════════════════════════════════════ SIGNAL CHART PANEL ═══════════════════════════════════════════ */ .chart-panel { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin-bottom: 28px; box-shadow: var(--shadow-md); } .chart-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .chart-panel-title { font-family: var(--font-head); font-size: 1.1rem; font-weight: 600; color: var(--accent); } #signalChart { width: 100% !important; height: 300px !important; } /* ═══════════════════════════════════════════ OLT HEALTH CARDS (overview page) ═══════════════════════════════════════════ */ .olt-health-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; margin-bottom: 36px; } .olt-health-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; text-decoration: none; color: var(--ink); display: flex; flex-direction: column; gap: 4px; border-top: 3px solid var(--border); box-shadow: var(--shadow-sm); transition: all .2s var(--trans); position: relative; } .olt-health-card:hover { border-top-color: var(--accent); box-shadow: var(--shadow-md); transform: translateY(-2px); } .olt-name { font-family: var(--font-head); font-size: 1rem; font-weight: 600; } .olt-sub { font-size: .75rem; color: var(--ink-faint); } .olt-count { font-family: var(--font-mono); font-size: .7rem; color: var(--accent); margin-top: 6px; } .olt-arrow { position: absolute; right: 16px; bottom: 16px; color: var(--ink-faint); font-size: 18px !important; transition: transform .2s var(--trans), color .2s; } .olt-health-card:hover .olt-arrow { color: var(--accent); transform: translateX(3px); } /* ═══════════════════════════════════════════ DATA TABLES ═══════════════════════════════════════════ */ .data-table { width: 100%; border-collapse: collapse; font-size: .86rem; } .data-table thead tr { background: var(--brand-navy); color: #fff; } [data-mode="control"] .data-table thead tr { background: rgba(255,255,255,.06); border-bottom: 1px solid var(--border); } .data-table thead th { padding: 11px 16px; text-align: left; font-family: var(--font-body); font-weight: 500; font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; } .data-table tbody tr:nth-child(even) { background: var(--bg-1); } .data-table tbody tr:hover { background: var(--accent-lt); } .data-table tbody td { padding: 10px 16px; border-bottom: 1px solid var(--border); } .data-table tbody td a { color: var(--accent); text-decoration: none; border-bottom: 1px solid var(--accent-lt); } .data-table tbody td a:hover { border-bottom-color: var(--accent); } /* ═══════════════════════════════════════════ PRIORITY BADGES ═══════════════════════════════════════════ */ .priority-badge { display: inline-block; font-family: var(--font-mono); font-size: .62rem; letter-spacing: .08em; text-transform: uppercase; padding: 2px 8px; border-radius: 4px; font-weight: 600; } .priority-p1 { background: rgba(192,57,43,.12); color: #e74c3c; } .priority-p2 { background: rgba(211,84,0,.12); color: #d35400; } .priority-p3 { background: rgba(41,128,185,.12); color: var(--accent); } .priority-p4 { background: var(--bg-2); color: var(--ink-muted); } .severity-badge { display: inline-block; font-family: var(--font-mono); font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; padding: 2px 7px; border-radius: 4px; font-weight: 600; } .severity-5 { background: rgba(192,57,43,.15); color: #e74c3c; } .severity-4 { background: rgba(211,84,0,.15); color: #d35400; } .severity-3 { background: rgba(201,168,76,.15); color: var(--gold); } .severity-2 { background: rgba(41,128,185,.12); color: var(--accent); } .severity-1, .severity-0 { background: var(--bg-2); color: var(--ink-faint); } /* ═══════════════════════════════════════════ BUTTONS ═══════════════════════════════════════════ */ .btn { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-body); font-size: .84rem; font-weight: 500; padding: 8px 16px; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: all .18s var(--trans); line-height: 1; } .btn .material-icons-outlined { font-size: 17px !important; } .btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); } .btn-primary:hover { background: var(--accent-dk); border-color: var(--accent-dk); } .btn-secondary { background: var(--bg-1); color: var(--ink); border-color: var(--border); } .btn-secondary:hover { background: var(--bg-2); } .btn-ghost { background: transparent; color: var(--ink-muted); border-color: transparent; } .btn-ghost:hover { background: var(--bg-1); color: var(--ink); } .btn-danger { background: rgba(192,57,43,.1); color: var(--signal-critical); border-color: rgba(192,57,43,.2); } .btn-danger:hover { background: rgba(192,57,43,.2); } /* ═══════════════════════════════════════════ FORMS ═══════════════════════════════════════════ */ .form-group { margin-bottom: 20px; } .form-label { display: block; font-size: .8rem; font-weight: 500; color: var(--ink-muted); margin-bottom: 6px; letter-spacing: .04em; } .form-control { width: 100%; padding: 9px 13px; background: var(--surface); color: var(--ink); border: 1px solid var(--border); border-radius: var(--radius-sm); font-family: var(--font-body); font-size: .9rem; transition: border-color .18s, box-shadow .18s; outline: none; } .form-control:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px var(--accent-lt); } .form-control::placeholder { color: var(--ink-faint); } textarea.form-control { resize: vertical; min-height: 100px; } select.form-control { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239A9A8A' fill='none' stroke-width='1.5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 36px; } /* ═══════════════════════════════════════════ CALLOUT BOXES ═══════════════════════════════════════════ */ .callout { border-radius: var(--radius-sm); padding: 14px 18px; border-left: 4px solid; margin: 20px 0; font-size: .9rem; } .callout-info { background: var(--accent-lt); border-color: var(--accent); } .callout-warn { background: rgba(201,168,76,.1); border-color: var(--gold); } .callout-crit { background: rgba(192,57,43,.08); border-color: var(--signal-critical); } .callout-good { background: rgba(39,174,96,.08); border-color: var(--signal-excellent); } .callout-label { font-family: var(--font-mono); font-size: .6rem; letter-spacing: .14em; text-transform: uppercase; font-weight: 600; margin-bottom: 4px; } .callout-info .callout-label { color: var(--accent); } .callout-warn .callout-label { color: var(--gold); } .callout-crit .callout-label { color: var(--signal-critical); } .callout-good .callout-label { color: var(--signal-excellent); } /* ═══════════════════════════════════════════ MESSAGES (Django messages framework) ═══════════════════════════════════════════ */ .messages-container { padding: 0 40px; margin-top: -8px; } .message { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: var(--radius-sm); margin-bottom: 10px; font-size: .88rem; border-left: 4px solid; opacity: 1; transition: opacity .4s var(--trans), transform .4s var(--trans); } .message.dismissed { opacity: 0; transform: translateX(20px); } .message-success { background: rgba(39,174,96,.1); border-color: var(--signal-excellent); } .message-error { background: rgba(192,57,43,.1); border-color: var(--signal-critical); } .message-warning { background: rgba(201,168,76,.1); border-color: var(--gold); } .message-info { background: var(--accent-lt); border-color: var(--accent); } .message .material-icons-outlined { font-size: 18px !important; } .message-success .material-icons-outlined { color: var(--signal-excellent); } .message-error .material-icons-outlined { color: var(--signal-critical); } .message-warning .material-icons-outlined { color: var(--gold); } .message-info .material-icons-outlined { color: var(--accent); } /* ═══════════════════════════════════════════ SCROLL REVEAL ═══════════════════════════════════════════ */ .reveal { opacity: 0; transform: translateY(18px); transition: opacity .55s var(--trans), transform .55s var(--trans); } .reveal.revealed { opacity: 1; transform: none; } /* ═══════════════════════════════════════════ MOBILE HAMBURGER ═══════════════════════════════════════════ */ .hamburger { display: none; position: fixed; top: 14px; left: 14px; z-index: 300; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 7px; cursor: pointer; color: var(--ink); box-shadow: var(--shadow-md); } .hamburger .material-icons-outlined { display: block; } /* ═══════════════════════════════════════════ RESPONSIVE ═══════════════════════════════════════════ */ @media (max-width: 900px) { .sidebar { transform: translateX(-100%); } .sidebar.open { transform: translateX(0); } .main-content { margin-left: 0; } .reading-progress { left: 0; } .hamburger { display: flex; } .page-content { padding: 24px 20px; } .page-header { padding: 0 20px; } .messages-container { padding: 0 20px; } } @media (max-width: 600px) { .stat-row { grid-template-columns: repeat(2, 1fr); } .onu-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } .olt-health-row { grid-template-columns: 1fr; } }
Production Security Fixes
Every gap identified in the audit, closed. Apply all of these before the system accepts a single production request.
1. Security Headers in settings.py
# ── HTTPS / Security headers ────────────────────────────── # Only active when DEBUG=False if not DEBUG: SECURE_SSL_REDIRECT = True SECURE_HSTS_SECONDS = 31536000 SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_PRELOAD = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True CSRF_COOKIE_HTTPONLY = True CSRF_TRUSTED_ORIGINS = env.list( 'CSRF_TRUSTED_ORIGINS', default=['https://noc.sprintug.com', 'https://noc.sprinttz.co.tz'] ) X_FRAME_OPTIONS = 'DENY' SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_BROWSER_XSS_FILTER = True
2. Logging Configuration
import os os.makedirs('/var/log/sprintug', exist_ok=True) LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'formatters': { 'verbose': { 'format': '{levelname} {asctime} {module} {process} {message}', 'style': '{', }, 'simple': { 'format': '{levelname} {message}', 'style': '{', }, }, 'handlers': { 'django_file': { 'class': 'logging.handlers.RotatingFileHandler', 'filename': '/var/log/sprintug/django.log', 'maxBytes': 10 * 1024 * 1024, 'backupCount':5, 'formatter': 'verbose', }, 'celery_file': { 'class': 'logging.handlers.RotatingFileHandler', 'filename': '/var/log/sprintug/celery.log', 'maxBytes': 10 * 1024 * 1024, 'backupCount':5, 'formatter': 'verbose', }, 'console': { 'class': 'logging.StreamHandler', 'formatter':'simple', }, }, 'root': { 'handlers': ['django_file', 'console'], 'level': 'INFO', }, 'loggers': { 'monitoring': {'handlers':['celery_file'],'level':'DEBUG','propagate':True}, 'alerts': {'handlers':['celery_file'],'level':'DEBUG','propagate':True}, 'django': {'handlers':['django_file'],'level':'WARNING','propagate':False}, }, }
3. Fix verify=False in mikrotik.py
# Wrong — was in the original guide resp = httpx.get(url, auth=(user, password), verify=False) # Correct option A: provide the MikroTik CA cert (best) MIKROTIK_CA_CERT = env('MIKROTIK_CA_CERT_PATH', default=None) resp = httpx.get(url, auth=(user, password), verify=MIKROTIK_CA_CERT or True) # Correct option B: use HTTP (port 80) for LAN-only routers # Set MIKROTIK_USE_HTTP=true in .env for routers without HTTPS scheme = "http" if env.bool('MIKROTIK_USE_HTTP', default=False) else "https" url = f"{scheme}://{router_ip}/rest/ppp/active" resp = httpx.get(url, auth=(user, password), verify=True)
4. Fix generate_ticket_ref Collision Risk
import random, string from django.utils import timezone def generate_ticket_ref(): """ Generate a unique ticket reference: INC-YYYYMM-XXXXXXX 7 random alphanumeric chars = 78 billion combinations. The unique=True DB constraint is the final safety net. """ now = timezone.now() chars = string.ascii_uppercase + string.digits suffix = ''.join(random.choices(chars, k=7)) return f"INC-{now.year}{now.month:02d}-{suffix}" def generate_problem_ref(): now = timezone.now() chars = string.ascii_uppercase + string.digits suffix = ''.join(random.choices(chars, k=6)) return f"PRB-{now.year}{now.month:02d}-{suffix}" def generate_change_ref(): now = timezone.now() chars = string.ascii_uppercase + string.digits suffix = ''.join(random.choices(chars, k=6)) return f"CHG-{now.year}{now.month:02d}-{suffix}"
5. Narrow Celery autoretry_for
# Wrong — retries on everything including unrecoverable errors @shared_task(autoretry_for=(Exception,), ...) # Correct — only retry on transient network and API failures from monitoring.zabbix_client import ZabbixAPIError import httpx @shared_task( autoretry_for=(httpx.HTTPError, httpx.TimeoutException, ZabbixAPIError), max_retries=3, default_retry_delay=30, dont_autoretry_for=(ValueError, TypeError, KeyError), )
6. Validate API View Inputs
def get(self, request): # Validate item_id — must be a positive integer try: item_id = int(request.GET.get("item_id", "")) if item_id <= 0: raise ValueError except (TypeError, ValueError): return JsonResponse({"error": "item_id must be a positive integer"}, status=400) # Validate hours — clamp between 1 and 720 (30 days) try: hours = max(1, min(720, int(request.GET.get("hours", 6)))) except (TypeError, ValueError): hours = 6 # Validate olt_id in ONUSummaryView the same way
7. Nginx HTTPS Config
# Redirect HTTP → HTTPS server { listen 80; server_name noc.sprintug.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name noc.sprintug.com; # SSL — use Let's Encrypt (certbot --nginx) ssl_certificate /etc/letsencrypt/live/noc.sprintug.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/noc.sprintug.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; location /static/ { alias /opt/sprintug/staticfiles/; expires 30d; add_header Cache-Control "public, immutable"; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 120s; } }
apt install -y certbot python3-certbot-nginx
certbot --nginx -d noc.sprintug.com -d noc.sprinttz.co.tz
# Certbot auto-renews via a systemd timer — verify with:
systemctl status certbot.timer
8. Final .env Additions
# Add these to your existing .env CSRF_TRUSTED_ORIGINS=https://noc.sprintug.com,https://noc.sprinttz.co.tz # Optional: path to MikroTik CA cert (instead of verify=False) MIKROTIK_CA_CERT_PATH=/opt/sprintug/certs/mikrotik-ca.pem # Optional: use HTTP for LAN-only MikroTik devices MIKROTIK_USE_HTTP=False
Consolidated Fix Checklist
| Issue | File | Status |
|---|---|---|
| HTTPS security headers | config/settings.py | Fixed in Ch. 26 §1 |
| LOGGING config missing | config/settings.py | Fixed in Ch. 26 §2 |
verify=False on MikroTik | monitoring/mikrotik.py | Fixed in Ch. 26 §3 |
| Ticket ref collision risk | tickets/models.py | Fixed in Ch. 26 §4 |
| Celery retries too broad | monitoring/sync.py | Fixed in Ch. 26 §5 |
| API inputs not validated | api/views.py | Fixed in Ch. 26 §6 |
| No HTTPS on Nginx | /etc/nginx/sites-available/ | Fixed in Ch. 26 §7 |
| CSRF_TRUSTED_ORIGINS missing | .env | Fixed in Ch. 26 §8 |
| Font imports missing in templates | templates/base.html | Fixed in Ch. 24 |
| No base template inheritance | templates/base.html | Fixed in Ch. 24 |
| No full CSS design system | static/css/noc.css | Fixed in Ch. 25 |
| Log directory may not exist | settings.py + bash | Fixed in Ch. 26 §2 |
All twelve gaps are closed. The design system is complete — every template inherits Cormorant Garamond headings, JetBrains Mono labels, DM Sans body text, your blues and golds, the glassmorphism sidebar, the noise texture, and the scroll-reveal animations. Staff pages render in ivory. Control screens render in deep navy. It is one consistent design language across all 20+ views.