Volume II of II
Building an ISP ITSM

The Django
Build Guide

Line by line. File by file. No gaps.
Django 4.2 Celery 5 PostgreSQL Redis Zabbix API Chart.js Africa's Talking
Author
David Emiru Egwell
Platform
Ubuntu 22.04 LTS
Companion to
The ITIL Way (Vol. I)
Preface
Table of Contents
Start Here
00Prerequisites — What You Need Before Running django-admin
Part I — Project Scaffold
01Bootstrap the Project — pip, virtualenv, startproject 02Settings, .env, and Celery Configuration 03App Structure — startapp for Every Domain
Part II — Data Models (The CMDB)
04customers/ — Customer, Site, OLT, Device 05monitoring/ — ZabbixHost, ZabbixItem, MetricSample, ZabbixProblem 06tickets/ — Ticket, TicketEvent, ProblemRecord, ChangeRecord 07alerts/ — AlertRule, AlertFiring, SMSLog 08Running Migrations and Configuring Django Admin
Part III — Zabbix Integration
09Zabbix API Client — Token Management, JSON-RPC Wrapper 10Celery Sync Tasks — Hosts, Metrics, Problems 11Maintenance Windows — Suppressing Alerts During Changes
Part IV — The Alert Engine
12Alert Rules Engine — From Zabbix Problem to Open Ticket 13SMS Dispatch — Africa's Talking for Uganda and Tanzania 14SLA Engine — Deadlines, Breach Detection, Calculations 15Trend Analysis — Predicting Signal Failure Before it Happens
Part V — API and Views
16Monitoring REST API — JSON Endpoints for Chart.js 17NOC Dashboard Views — Class-Based, Context-Rich 18NOC Templates — The OLT Heat Map, Signal Chart, Problem Feed 19Customer NOC View — Everything About One Customer 20URL Routing — Every URL in the System
Part VII — Templates & Design System
24base.html — The Full codeandcore Design System as a Django Template 25noc.css — Ivory & Navy Dual-Palette for Staff and Control Views 26Production Security Fixes — Every Gap Closed
Part VI — Operations
21Management Commands — ONU Import, CMDB Audit 22Authentication and Role-Based Permissions 23Production Deployment Checklist
Start Here
00

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

ComponentVersionPurpose
Ubuntu22.04 LTSOS for both the Django server and Zabbix server
Python3.11+Django runtime
PostgreSQL14 + TimescaleDB 2Primary database
Redis7+Celery message broker and cache
Nginxlatest stableReverse proxy in front of Gunicorn
Zabbix Server6.4 LTSMetric collection engine (see Vol. I Ch. 10)

System Packages

bash
# 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

bash + SQL
# 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

bash
# Dedicated OS user — never run Django as root
useradd -m -s /bin/bash sprint
mkdir -p /opt/sprintug
chown sprint:sprint /opt/sprintug
Part I · Chapter 01
01

Bootstrap the Project

From zero to a running Django shell in one session. Every command, in order. No assumptions.

Create the Virtual Environment

bash
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

bash · requirements.txt
# 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

bash
# Create the project — note the trailing dot (current directory)
django-admin startproject config .

# Verify structure
ls -la
# manage.py  config/  venv/  requirements.txt
Why "config" not "sprintug"?

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

bash
# 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
/opt/sprintug/ ├── manage.py ├── requirements.txt ├── .env # ← create this next chapter ├── config/ │ ├── __init__.py │ ├── settings.py # ← heavily modified next chapter │ ├── celery.py # ← create next chapter │ └── urls.py ├── customers/ # CMDB: Customer, Site, OLT, Device ├── monitoring/ # Zabbix mirror: Host, Item, Sample, Problem ├── tickets/ # Ticket, TicketEvent, ProblemRecord, ChangeRecord ├── alerts/ # AlertRule, dispatch, SLA engine ├── noc/ # Dashboard views and templates ├── reports/ # SLA reports, uptime calculations └── api/ # JSON endpoints for Chart.js
Part I · Chapter 02
02

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

bash · /opt/sprintug/.env
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
Never commit .env

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

Python · config/settings.py
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

Python · 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()
Python · config/__init__.py
# This makes Celery load when Django starts
from .celery import app as celery_app
__all__ = ('celery_app',)

Verify Everything Works

bash
# 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
Part II · Chapter 04
04

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

Python · 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}"
Part II · Chapter 05
05

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

Python · 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:

SQL · Run once after first migration
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
Part II · Chapter 06
06

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

Python · 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}"
Part II · Chapter 07
07

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

Python · 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)
Part II · Chapter 08
08

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

bash
# 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

Python · 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

Python · 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'
Part III · Chapter 09
09

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

Python · monitoring/zabbix_client.py
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"]
Part III · Chapter 10
10

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

Python · monitoring/sync.py
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")
Part III · Chapter 11
11

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

Python · 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='')
Part IV · Chapter 12
12

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

Python · alerts/rules.py
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)
Part IV · Chapter 13
13

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

Python · 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 {}
Part IV · Chapter 14
14

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

Python · 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,
    }
Part IV · Chapter 15
15

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

Python · 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")
Part V · Chapter 16
16

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

Python · api/views.py
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

Python · 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'),
]
Part V · Chapter 17
17

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

Python · 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)
Part V · Chapter 18
18

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.

Template Location

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

HTML + JavaScript · 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>
Part V · Chapter 20
20

URL Routing — Every URL in the System

All URLs in one place. No hunting through five apps to find where something is mounted.

Python · config/urls.py
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')),
]
Python · noc/urls.py
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'),
]
Part VI · Chapter 21
21

Management Commands

The ONU inventory import is the single most important management command you will ever write for this system. Write it carefully.

Python · customers/management/commands/import_onu_inventory.py
"""
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}")
Part VI · Chapter 22
22

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.

GroupCan doCannot do
noc_operatorView all dashboards, acknowledge problems, add ticket commentsCreate/delete customers, approve changes
noc_engineerAll of noc_operator + create tickets, update ticket status, create change recordsApprove changes, access admin
noc_adminAll of noc_engineer + approve changes, access Django admin, manage usersSuperuser operations
Python · Run once as a management command or in Django shell
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.")
Part VI · Chapter 23
23

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

bash · Pre-flight checks
# 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 Are Live When

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.
End of Volume II
SprintUG Internet Limited · Live Unlimited with Seamless Connections
Redstone House, Plot 7 Bandali Rise, Bugolobi · davide@sprintug.com
Part VII · Chapter 24
24

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>.

TemplateExtendsModePalette
noc/overview.htmlbase.htmlstaffIvory #FAF8F5
noc/customer_detail.htmlbase.htmlstaffIvory #FAF8F5
tickets/list.htmlbase.htmlstaffIvory #FAF8F5
noc/olt_detail.htmlbase.htmlcontrolNavy #0B1D36
noc/problems.htmlbase.htmlcontrolNavy #0B1D36

templates/base.html — Complete File

HTML · templates/base.html
<!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:

Python · noc/context_processors.py
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(),
    }
Python · Add to TEMPLATES in settings.py
'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

HTML · templates/noc/overview.html (staff mode, ivory palette)
{% 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 %}
HTML · templates/noc/olt_detail.html (control mode, navy palette)
{% 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 %}
Part VII · Chapter 25
25

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.

File location

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

CSS · static/css/noc.css
/* ═══════════════════════════════════════════════════════════
   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; }
}
Part VII · Chapter 26
26

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

Python · config/settings.py — add this block
# ── 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

Python · config/settings.py — add this block
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

Python · monitoring/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

Python · tickets/models.py — replace generate_ticket_ref
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

Python · monitoring/sync.py and alerts/rules.py
# 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

Python · api/views.py — replace the top of ItemHistoryView.get()
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

nginx · /etc/nginx/sites-available/sprintug-noc
# 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;
    }
}
bash · Get a free SSL cert with certbot
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

bash · .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

IssueFileStatus
HTTPS security headersconfig/settings.pyFixed in Ch. 26 §1
LOGGING config missingconfig/settings.pyFixed in Ch. 26 §2
verify=False on MikroTikmonitoring/mikrotik.pyFixed in Ch. 26 §3
Ticket ref collision risktickets/models.pyFixed in Ch. 26 §4
Celery retries too broadmonitoring/sync.pyFixed in Ch. 26 §5
API inputs not validatedapi/views.pyFixed in Ch. 26 §6
No HTTPS on Nginx/etc/nginx/sites-available/Fixed in Ch. 26 §7
CSRF_TRUSTED_ORIGINS missing.envFixed in Ch. 26 §8
Font imports missing in templatestemplates/base.htmlFixed in Ch. 24
No base template inheritancetemplates/base.htmlFixed in Ch. 24
No full CSS design systemstatic/css/noc.cssFixed in Ch. 25
Log directory may not existsettings.py + bashFixed in Ch. 26 §2
✓ You are now production-ready

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.

End of Volume II — Complete
SprintUG Internet Limited · Live Unlimited with Seamless Connections
Redstone House, Plot 7 Bandali Rise, Bugolobi · davide@sprintug.com