diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0fc42d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+# Python
+__pycache__/
+*.py[cod]
+*.pyo
+*.pyd
+*.egg-info/
+dist/
+build/
+
+# Virtual environment
+venv/
+env/
+.venv/
+
+# Django
+*.sqlite3
+staticfiles/
+
+# Environment variables — never commit real keys
+.env
+
+# OS
+.DS_Store
+Thumbs.db
+
+# IDEs
+.vscode/
+.idea/
\ No newline at end of file
diff --git a/README.md b/README.md
index 0c40ea1..b33ceeb 100644
--- a/README.md
+++ b/README.md
@@ -1,135 +1,289 @@
-# Pulse-Check-API ("Watchdog" Sentinel)
-This challenge is designed to test your ability to bridge Computer Science fundamentals with Modern Backend Engineering.
+# Pulse-Check-API — Dead Man's Switch Monitor
-## 1. Business Context
-> **Client:** *CritMon Servers Inc.* (A Critical Infrastructure Monitoring Company).
+A production-grade backend service that monitors remote devices (solar farms, weather stations, etc.) and triggers escalating alerts when a device stops sending heartbeats.
-### The Problem
-CritMon provides monitoring for remote solar farms and unmanned weather stations in areas with poor connectivity. These devices are supposed to send "I'm alive" signals every hour.
+Built with **Python + Django + Redis**.
+
+---
+
+## Architecture Diagram
+
-Currently, CritMon has no way of knowing if a device has gone offline (due to power failure or theft) until a human manually checks the logs. They need a system that alerts *them* when a device *stops* talking.
-### The Solution
-You need to build a **Dead Man’s Switch API**. Devices will register a "monitor" with a countdown timer (e.g., 60 seconds). If the device fails to "ping" (send a heartbeat) to the API before the timer runs out, the system automatically triggers an alert.
+## How It Works
+
+1. A device registers a monitor with a timeout (e.g. 60 seconds)
+2. Redis starts a countdown (TTL key)
+3. The device sends heartbeats to reset the timer
+4. If no heartbeat arrives before the timer hits zero, Redis fires an expiry event
+5. A background listener catches the event and fires escalating alerts
+6. A maintenance technician can pause monitoring to avoid false alarms
---
-## 2. Technical Objective
-Build a backend service that manages stateful timers.
+## Setup Instructions
+
+### Requirements
+- Python 3.10+
+- Redis server
+- pip
+
+### Installation
+
+**1. Clone the repository**
+```bash
+git clone https://github.com/KingJoe-14/pulse-check-api.git
+cd pulse-check-api
+```
+
+**2. Create and activate a virtual environment**
+```bash
+python -m venv venv
+source venv/bin/activate
+```
+
+**3. Install dependencies**
+```bash
+pip install -r requirements.txt
+```
+
+**4. Create your `.env` file**
+```bash
+cp .env
+```
-* **Registration:** Allow a client to create a monitor with a specific timeout duration.
-* **Heartbeat:** Reset the countdown when a ping is received.
-* **Trigger:** Fire a webhook (or log a critical error) if the countdown reaches zero.
+**5. Start Redis**
+```bash
+sudo service redis-server start
+```
+**6. Enable Redis keyspace notifications**
+```bash
+redis-cli config set notify-keyspace-events Ex
+```
+
+**7. Run database-free check**
+```bash
+python manage.py check
+```
+
+### Running the Service
+
+You need **two terminals** running simultaneously:
+
+**Terminal 1 — API server**
+```bash
+python manage.py runserver
+```
+
+**Terminal 2 — Keyspace listener**
+```bash
+python manage.py start_listener
+```
---
-## 3. Getting Started
+## API Documentation
-1. **Fork this Repository:** Do not clone it directly. Create a fork to your own GitHub account.
-2. **Environment:** You may use **Node.js, Python, Java or Go, etc.**.
-3. **Submission:** Your final submission will be a link to your forked repository containing:
- * The source code.
- * The **Architecture Diagram**
- * The `README.md` with documentation.
+### Base URL
+http://127.0.0.1:8000
---
-## 4. The Architecture Diagram
-**Task:** Before you write any code, you must design the logic flow.
-**Deliverable:** A **Sequence Diagram** or **State Flowchart** embedded in your `README.md`.
+### 1. Register a Monitor
+**POST** `/monitors/`
+
+Registers a new device monitor and starts the countdown timer.
+
+**Request body:**
+```json
+{
+ "id": "device-123",
+ "timeout": 60,
+ "alert_email": "admin@critmon.com"
+}
+```
+
+**Responses:**
+
+| Status | Description |
+|--------|-------------|
+| 201 | Monitor created successfully |
+| 400 | Missing or invalid fields |
+| 409 | Monitor with this ID already exists |
+
+**Example response (201):**
+```json
+{
+ "message": "Monitor for device-123 created successfully",
+ "monitor": {
+ "id": "device-123",
+ "timeout": 60,
+ "alert_email": "admin@critmon.com",
+ "status": "active",
+ "created_at": "2026-06-06T19:37:30.260117+00:00"
+ }
+}
+```
---
-## 5. User Stories & Acceptance Criteria
-
-### User Story 1: Registering a Monitor
-**As a** device administrator,
-**I want to** create a new monitor for my device,
-**So that** the system knows to track its status.
-
-**Acceptance Criteria:**
-- [ ] The API accepts a `POST /monitors` request.
-- [ ] Input: `{"id": "device-123", "timeout": 60, "alert_email": "admin@critmon.com"}`.
-- [ ] The system starts a countdown timer for 60 seconds associated with `device-123`.
-- [ ] Response: `201 Created` with a confirmation message.
-
-### User Story 2: The Heartbeat (Reset)
-**As a** remote device,
-**I want to** send a signal to the server,
-**So that** my timer is reset and no alert is sent.
-
-**Acceptance Criteria:**
-- [ ] The API accepts a `POST /monitors/{id}/heartbeat` request.
-- [ ] If the ID exists and the timer has NOT expired:
- - [ ] Restart the countdown from the beginning (e.g., reset to 60 seconds).
- - [ ] Return `200 OK`.
-- [ ] If the ID does not exist:
- - [ ] Return `404 Not Found`.
-
-### User Story 3: The Alert (Failure State)
-**As a** support engineer,
-**I want to** be notified immediately if a device stops sending heartbeats,
-**So that** I can deploy a repair team.
-
-**Acceptance Criteria:**
-- [ ] If the timer for `device-123` reaches 0 seconds (no heartbeat received):
- - [ ] The system must internally "fire" an alert.
- - [ ] **Implementation:** For this project, simply `console.log` a JSON object: `{"ALERT": "Device device-123 is down!", "time": }`. (Or simulate sending an email).
- - [ ] The monitor status changes to `down`.
+### 2. Send Heartbeat
+**POST** `/monitors/{id}/heartbeat/`
+
+Resets the countdown timer. Automatically unpauses a paused monitor.
+
+**No request body needed.**
+
+**Responses:**
+
+| Status | Description |
+|--------|-------------|
+| 200 | Timer reset successfully |
+| 404 | Monitor not found |
+| 400 | Monitor is down — re-register to resume |
+
+**Example response (200):**
+```json
+{
+ "id": "device-123",
+ "status": "active",
+ "message": "Heartbeat received — timer reset",
+ "updated_at": "2026-06-06T19:45:00.000000+00:00"
+}
+```
---
-## 6. Bonus User Story (The "Snooze" Button)
-**As a** maintenance technician,
-**I want to** pause monitoring while I am repairing a device,
-**So that** I don't trigger false alarms.
+### 3. Pause a Monitor
+**POST** `/monitors/{id}/pause/`
+
+Freezes the timer completely. No alerts will fire while paused.
+Sending a heartbeat automatically resumes the monitor.
+
+**No request body needed.**
+
+**Responses:**
+
+| Status | Description |
+|--------|-------------|
+| 200 | Monitor paused successfully |
+| 400 | Monitor is already paused or is down |
+| 404 | Monitor not found |
-**Acceptance Criteria:**
-- [ ] Create a `POST /monitors/{id}/pause` endpoint.
-- [ ] When called, the timer stops completely. No alerts will fire.
-- [ ] Calling the heartbeat endpoint again automatically "un-pauses" the monitor and restarts the timer.
+**Example response (200):**
+```json
+{
+ "id": "device-123",
+ "status": "paused",
+ "message": "Monitor paused — no alerts will fire",
+ "updated_at": "2026-06-06T19:50:00.000000+00:00"
+}
+```
---
-## 7. The "Developer's Choice" Challenge
-We value engineers who look for "what's missing."
+### 4. List All Monitors
+**GET** `/monitors/`
+
+Returns all registered monitors and their current status.
+
+**Example response (200):**
+```json
+[
+ {
+ "id": "device-123",
+ "timeout": "60",
+ "alert_email": "admin@critmon.com",
+ "status": "active",
+ "created_at": "2026-06-06T19:37:30.260117+00:00",
+ "updated_at": "2026-06-06T19:45:00.000000+00:00"
+ }
+]
+```
+
+---
+
+### 5. Get Single Monitor
+**GET** `/monitors/{id}/`
+
+Returns a single monitor by ID.
-**Task:** Identify **one** additional feature that makes this system more robust or user-friendly.
-1. **Implement it.**
-2. **Document it:** Explain *why* you added it in your README.
+**Responses:**
+
+| Status | Description |
+|--------|-------------|
+| 200 | Monitor found |
+| 404 | Monitor not found |
---
-## 8. Documentation Requirements
-Your final `README.md` must replace these instructions. It must cover:
+## Alert System
+
+When a device misses its heartbeat the system fires three escalating alerts logged to the listener console:
+
+| Alert | Severity | When |
+|-------|----------|------|
+| Alert 1 | WARNING | Immediately when timer expires |
+| Alert 2 | URGENT | 30 seconds after Alert 1 |
+| Alert 3 | CRITICAL | 90 seconds after Alert 2 |
-1. **Architecture Diagram**
-2. **Setup Instructions**
-3. **API Documentation**
-4. **The Developer's Choice:** Explanation of your added feature.
+**Example alert output:**
+```json
+{"ALERT": "Device device-123 is down!", "severity": "WARNING", "email": "admin@critmon.com", "time": "2026-06-06T21:13:49.500142+00:00"}
+{"ALERT": "Device device-123 is still down!", "severity": "URGENT", "email": "admin@critmon.com", "time": "2026-06-06T21:14:19.578988+00:00"}
+{"ALERT": "Device device-123 is still down!", "severity": "CRITICAL", "email": "admin@critmon.com", "time": "2026-06-06T21:15:49.612045+00:00"}
+```
+
+Alerts stop escalating as soon as the device sends a heartbeat again.
---
-Submit your repo link via the [online](https://forms.office.com/e/rGKtfeZCsH) form.
-## 🛑 Pre-Submission Checklist
-**WARNING:** Before you submit your solution, you **MUST** pass every item on this list.
-If you miss any of these critical steps, your submission will be **automatically rejected** and you will **NOT** be invited to an interview.
+## Developer's Choice — Exponential Backoff Alerts
-### 1. 📂 Repository & Code
-- [ ] **Public Access:** Is your GitHub repository set to **Public**? (We cannot review private repos).
-- [ ] **Clean Code:** Did you remove unnecessary files (like `node_modules`, `.env` with real keys, or `.DS_Store`)?
-- [ ] **Run Check:** if we clone your repo and run `npm start` (or equivalent), does the server start immediately without crashing?
+### What it is
+Instead of firing a single alert when a device goes down, the system escalates notifications with increasing urgency over time.
-### 2. 📄 Documentation (Crucial)
-- [ ] **Architecture Diagram:** Did you include a visual Diagram (Flowchart or Sequence Diagram) in the README?
-- [ ] **README Swap:** Did you **DELETE** the original instructions (the problem brief) from this file and replace it with your own documentation?
-- [ ] **API Docs:** Is there a clear list of Endpoints and Example Requests in the README?
+### Why it was added
+A single alert is easy to miss. In a critical infrastructure context (solar farms, unmanned weather stations), a missed alert could mean hours of downtime before anyone responds. Escalating alerts ensure that:
+- A first responder sees the WARNING immediately
+- If unacknowledged, the URGENT alert creates pressure to act
+- The CRITICAL alert signals a serious outage requiring immediate deployment
-### 3. 🧹 Git Hygiene
-- [ ] **Commit History:** Does your repo have multiple commits with meaningful messages? (A single "Initial Commit" is a red flag).
+### How it works
+Redis backoff keys with expiring TTLs drive the escalation. Each alert schedules the next one by setting a new key with a delay. If the device recovers and sends a heartbeat, the backoff keys are cleared and escalation stops.
---
-**Ready?**
-If you checked all the boxes above, submit your repository link in the application form. Good luck! 🚀
+
+## Project Structure
+pulse-check-api/
+├── config/ # Django project settings
+│ ├── settings.py
+│ ├── urls.py
+│ └── wsgi.py
+├── monitors/ # Core app
+│ ├── views.py # API endpoints
+│ ├── urls.py # URL routing
+│ ├── services/
+│ │ ├── monitor_service.py # Register, heartbeat, pause logic
+│ │ └── alert_service.py # Alert firing and backoff
+│ └── management/commands/
+│ └── start_listener.py # Redis keyspace event listener
+├── redis_client/
+│ └── client.py # Redis singleton
+├── .env.example # Environment variable template
+├── requirements.txt
+└── manage.py
+
+---
+
+## Pre-Submission Checklist
+
+- ✅ Repository is public
+- ✅ No `node_modules`, `.env`, or sensitive files committed
+- ✅ Server starts with `python manage.py runserver`
+- ✅ Architecture diagram included
+- ✅ Original instructions replaced with this README
+- ✅ All endpoints documented with example requests
+- ✅ Multiple meaningful commits
diff --git a/config/__init__.py b/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/config/asgi.py b/config/asgi.py
new file mode 100644
index 0000000..ed7c431
--- /dev/null
+++ b/config/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for config project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
+
+application = get_asgi_application()
diff --git a/config/settings.py b/config/settings.py
new file mode 100644
index 0000000..c7f20a5
--- /dev/null
+++ b/config/settings.py
@@ -0,0 +1,51 @@
+from pathlib import Path
+from dotenv import load_dotenv
+import os
+
+load_dotenv()
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+SECRET_KEY = os.getenv("SECRET_KEY")
+
+DEBUG = os.getenv('DEBUG', 'True') == 'True'
+
+ALLOWED_HOSTS = ['*']
+
+INSTALLED_APPS = [
+ 'django.contrib.contenttypes',
+ 'django.contrib.auth',
+ 'django.contrib.staticfiles',
+ 'rest_framework',
+ 'monitors',
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+]
+
+ROOT_URLCONF = 'config.urls'
+
+WSGI_APPLICATION = 'config.wsgi.application'
+
+DATABASES = {}
+
+LANGUAGE_CODE = 'en-us'
+TIME_ZONE = 'UTC'
+USE_I18N = True
+USE_TZ = True
+
+STATIC_URL = 'static/'
+
+REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1')
+REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
+REDIS_DB = int(os.getenv('REDIS_DB', 0))
+
+REST_FRAMEWORK = {
+ 'DEFAULT_RENDERER_CLASSES': [
+ 'rest_framework.renderers.JSONRenderer',
+ ],
+ 'DEFAULT_AUTHENTICATION_CLASSES': [],
+ 'DEFAULT_PERMISSION_CLASSES': [],
+}
diff --git a/config/urls.py b/config/urls.py
new file mode 100644
index 0000000..1fa49fd
--- /dev/null
+++ b/config/urls.py
@@ -0,0 +1,5 @@
+from django.urls import path, include
+
+urlpatterns = [
+ path('', include('monitors.urls')),
+]
diff --git a/config/wsgi.py b/config/wsgi.py
new file mode 100644
index 0000000..e2fbd58
--- /dev/null
+++ b/config/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for config project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
+
+application = get_wsgi_application()
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..8e7ac79
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/monitors/__init__.py b/monitors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/monitors/admin.py b/monitors/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/monitors/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/monitors/apps.py b/monitors/apps.py
new file mode 100644
index 0000000..cc6bc1f
--- /dev/null
+++ b/monitors/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class MonitorsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'monitors'
diff --git a/monitors/management/__init__.py b/monitors/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/monitors/management/commands/__init__.py b/monitors/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/monitors/management/commands/start_listener.py b/monitors/management/commands/start_listener.py
new file mode 100644
index 0000000..940853a
--- /dev/null
+++ b/monitors/management/commands/start_listener.py
@@ -0,0 +1,43 @@
+from django.core.management.base import BaseCommand
+from redis_client.client import get_redis_client
+from monitors.services.alert_service import fire_alert, fire_backoff_alert
+
+
+class Command(BaseCommand):
+ help = 'Listens for Redis keyspace expiry events and fires alerts'
+
+ def handle(self, *args, **options):
+ r = get_redis_client()
+ pubsub = r.pubsub()
+
+ pubsub.psubscribe('__keyevent@0__:expired')
+
+ self.stdout.write(self.style.SUCCESS(
+ 'Keyspace listener started — waiting for expiry events...'
+ ))
+
+ for message in pubsub.listen():
+ if message['type'] != 'pmessage':
+ continue
+
+ expired_key = message['data']
+ self.stdout.write(f'Key expired: {expired_key}')
+
+ # ttl:{device_id} expired → device missed heartbeat
+ if expired_key.startswith('ttl:'):
+ device_id = expired_key[len('ttl:'):]
+ self.stdout.write(self.style.WARNING(
+ f'Monitor {device_id} missed heartbeat — firing alert'
+ ))
+ fire_alert(device_id)
+
+ # backoff:{device_id}:{level} expired → escalate alert
+ elif expired_key.startswith('backoff:'):
+ parts = expired_key.split(':')
+ if len(parts) == 3:
+ device_id = parts[1]
+ level = int(parts[2])
+ self.stdout.write(self.style.WARNING(
+ f'Firing backoff level {level} alert for {device_id}'
+ ))
+ fire_backoff_alert(device_id, level)
diff --git a/monitors/migrations/__init__.py b/monitors/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/monitors/models.py b/monitors/models.py
new file mode 100644
index 0000000..71a8362
--- /dev/null
+++ b/monitors/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/monitors/services/alert_service.py b/monitors/services/alert_service.py
new file mode 100644
index 0000000..d81e1fe
--- /dev/null
+++ b/monitors/services/alert_service.py
@@ -0,0 +1,80 @@
+import json
+from datetime import datetime, timezone
+from redis_client.client import get_redis_client
+
+
+BACKOFF_LEVELS = {
+ '1': {'delay': 30, 'severity': 'URGENT'},
+ '2': {'delay': 90, 'severity': 'CRITICAL'},
+}
+
+
+def fire_alert(device_id):
+ """
+ Fires the first alert when a device goes down.
+ Sets monitor status to down and schedules backoff alerts.
+ """
+ r = get_redis_client()
+
+ data = r.hgetall(f'monitor:{device_id}')
+ if not data:
+ return
+
+ now = datetime.now(timezone.utc).isoformat()
+
+ # Update monitor status to down
+ r.hset(f'monitor:{device_id}', mapping={
+ 'status': 'down',
+ 'updated_at': now,
+ })
+
+ # Fire Alert 1 — Warning
+ alert = {
+ 'ALERT': f'Device {device_id} is down!',
+ 'severity': 'WARNING',
+ 'email': data.get('alert_email'),
+ 'time': now,
+ }
+ print(json.dumps(alert))
+
+ # Schedule Alert 2 — set a backoff key with 30s TTL
+ r.set(f'backoff:{device_id}:1', '1', ex=30)
+
+
+def fire_backoff_alert(device_id, level):
+ """
+ Fires escalating alerts after the initial one.
+ level 1 → URGENT (fires 30s after first alert)
+ level 2 → CRITICAL (fires 90s after second alert)
+ """
+ r = get_redis_client()
+
+ data = r.hgetall(f'monitor:{device_id}')
+ if not data:
+ return
+
+ # If device recovered, stop escalating
+ if data.get('status') != 'down':
+ return
+
+ now = datetime.now(timezone.utc).isoformat()
+ level_str = str(level)
+ config = BACKOFF_LEVELS.get(level_str)
+
+ if not config:
+ return
+
+ # Fire the escalated alert
+ alert = {
+ 'ALERT': f'Device {device_id} is still down!',
+ 'severity': config['severity'],
+ 'email': data.get('alert_email'),
+ 'time': now,
+ }
+ print(json.dumps(alert))
+
+ # Schedule next level if it exists
+ next_level = str(level + 1)
+ if next_level in BACKOFF_LEVELS:
+ next_delay = BACKOFF_LEVELS[next_level]['delay']
+ r.set(f'backoff:{device_id}:{next_level}', '1', ex=next_delay)
diff --git a/monitors/services/monitor_service.py b/monitors/services/monitor_service.py
new file mode 100644
index 0000000..6e3fc59
--- /dev/null
+++ b/monitors/services/monitor_service.py
@@ -0,0 +1,123 @@
+from datetime import datetime, timezone
+from redis_client.client import get_redis_client
+
+
+def register_monitor(device_id, timeout, alert_email):
+ r = get_redis_client()
+
+ if r.exists(f'monitor:{device_id}'):
+ return None, 'Monitor with this ID already exists'
+
+ now = datetime.now(timezone.utc).isoformat()
+
+ r.hset(f'monitor:{device_id}', mapping={
+ 'id': device_id,
+ 'timeout': timeout,
+ 'alert_email': alert_email,
+ 'status': 'active',
+ 'created_at': now,
+ 'updated_at': now,
+ })
+
+ r.set(f'ttl:{device_id}', '1', ex=int(timeout))
+
+ return {
+ 'id': device_id,
+ 'timeout': int(timeout),
+ 'alert_email': alert_email,
+ 'status': 'active',
+ 'created_at': now,
+ }, None
+
+
+def heartbeat_monitor(device_id):
+ r = get_redis_client()
+
+ data = r.hgetall(f'monitor:{device_id}')
+ if not data:
+ return None, 'not_found'
+
+ current_status = data.get('status')
+ timeout = int(data.get('timeout', 60))
+
+ if current_status == 'down':
+ return None, 'down'
+
+ now = datetime.now(timezone.utc).isoformat()
+
+ # Reset the TTL key (also unpauses if paused)
+ r.set(f'ttl:{device_id}', '1', ex=timeout)
+
+ # Update status to active
+ r.hset(f'monitor:{device_id}', mapping={
+ 'status': 'active',
+ 'updated_at': now,
+ })
+
+ # Clear any backoff keys
+ r.delete(f'backoff:{device_id}')
+
+ return {
+ 'id': device_id,
+ 'status': 'active',
+ 'message': 'Heartbeat received — timer reset',
+ 'updated_at': now,
+ }, None
+
+
+def pause_monitor(device_id):
+ """
+ Pauses a monitor by removing the TTL key.
+ Redis PERSIST removes the expiry — key stays forever until deleted.
+ No alerts will fire while paused.
+ """
+ r = get_redis_client()
+
+ data = r.hgetall(f'monitor:{device_id}')
+ if not data:
+ return None, 'not_found'
+
+ current_status = data.get('status')
+
+ if current_status == 'down':
+ return None, 'down'
+
+ if current_status == 'paused':
+ return None, 'already_paused'
+
+ now = datetime.now(timezone.utc).isoformat()
+
+ # Remove the TTL — timer stops completely
+ r.persist(f'ttl:{device_id}')
+
+ # Update status to paused
+ r.hset(f'monitor:{device_id}', mapping={
+ 'status': 'paused',
+ 'updated_at': now,
+ })
+
+ return {
+ 'id': device_id,
+ 'status': 'paused',
+ 'message': 'Monitor paused — no alerts will fire',
+ 'updated_at': now,
+ }, None
+
+
+def get_monitor(device_id):
+ r = get_redis_client()
+ data = r.hgetall(f'monitor:{device_id}')
+ if not data:
+ return None
+ return data
+
+
+def get_all_monitors():
+ r = get_redis_client()
+ keys = r.keys('monitor:*')
+ monitors = []
+ for key in keys:
+ data = r.hgetall(key)
+ if data:
+ monitors.append(data)
+ return monitors
diff --git a/monitors/tests.py b/monitors/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/monitors/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/monitors/urls.py b/monitors/urls.py
new file mode 100644
index 0000000..38b6550
--- /dev/null
+++ b/monitors/urls.py
@@ -0,0 +1,14 @@
+from django.urls import path
+from monitors.views import (
+ MonitorListView,
+ MonitorDetailView,
+ HeartbeatView,
+ PauseView,
+)
+
+urlpatterns = [
+ path('monitors/', MonitorListView.as_view(), name='monitor-list'),
+ path('monitors//', MonitorDetailView.as_view(), name='monitor-detail'),
+ path('monitors//heartbeat/', HeartbeatView.as_view(), name='monitor-heartbeat'),
+ path('monitors//pause/', PauseView.as_view(), name='monitor-pause'),
+]
diff --git a/monitors/views.py b/monitors/views.py
new file mode 100644
index 0000000..1cb5031
--- /dev/null
+++ b/monitors/views.py
@@ -0,0 +1,117 @@
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework import status
+from monitors.services.monitor_service import (
+ register_monitor,
+ heartbeat_monitor,
+ pause_monitor,
+ get_monitor,
+ get_all_monitors,
+)
+
+
+class MonitorListView(APIView):
+
+ def get(self, request):
+ """GET /monitors — list all monitors"""
+ monitors = get_all_monitors()
+ return Response(monitors, status=status.HTTP_200_OK)
+
+ def post(self, request):
+ """POST /monitors — register a new monitor"""
+ data = request.data
+
+ required = ['id', 'timeout', 'alert_email']
+ for field in required:
+ if field not in data:
+ return Response(
+ {'error': f'Missing required field: {field}'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ device_id = data['id']
+ timeout = data['timeout']
+ alert_email = data['alert_email']
+
+ if not isinstance(timeout, int) or timeout <= 0:
+ return Response(
+ {'error': 'timeout must be a positive integer (seconds)'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ monitor, error = register_monitor(device_id, timeout, alert_email)
+
+ if error:
+ return Response(
+ {'error': error},
+ status=status.HTTP_409_CONFLICT
+ )
+
+ return Response(
+ {
+ 'message': f'Monitor for {device_id} created successfully',
+ 'monitor': monitor,
+ },
+ status=status.HTTP_201_CREATED
+ )
+
+
+class MonitorDetailView(APIView):
+
+ def get(self, request, device_id):
+ """GET /monitors/{id} — get a single monitor"""
+ monitor = get_monitor(device_id)
+ if not monitor:
+ return Response(
+ {'error': f'Monitor {device_id} not found'},
+ status=status.HTTP_404_NOT_FOUND
+ )
+ return Response(monitor, status=status.HTTP_200_OK)
+
+
+class HeartbeatView(APIView):
+
+ def post(self, request, device_id):
+ """POST /monitors/{id}/heartbeat — reset the timer"""
+ result, error = heartbeat_monitor(device_id)
+
+ if error == 'not_found':
+ return Response(
+ {'error': f'Monitor {device_id} not found'},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ if error == 'down':
+ return Response(
+ {'error': f'Monitor {device_id} is down — re-register to resume'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ return Response(result, status=status.HTTP_200_OK)
+
+
+class PauseView(APIView):
+
+ def post(self, request, device_id):
+ """POST /monitors/{id}/pause — freeze the timer"""
+ result, error = pause_monitor(device_id)
+
+ if error == 'not_found':
+ return Response(
+ {'error': f'Monitor {device_id} not found'},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ if error == 'down':
+ return Response(
+ {'error': f'Monitor {device_id} is down — cannot pause'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ if error == 'already_paused':
+ return Response(
+ {'error': f'Monitor {device_id} is already paused'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ return Response(result, status=status.HTTP_200_OK)
diff --git a/redis_client/__init__.py b/redis_client/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/redis_client/client.py b/redis_client/client.py
new file mode 100644
index 0000000..5ae9fd0
--- /dev/null
+++ b/redis_client/client.py
@@ -0,0 +1,22 @@
+import redis
+import os
+from dotenv import load_dotenv
+
+load_dotenv()
+
+_client = None
+
+def get_redis_client():
+ """
+ Returns a single shared Redis connection.
+ Creates it once and reuses it across the app.
+ """
+ global _client
+ if _client is None:
+ _client = redis.Redis(
+ host=os.getenv('REDIS_HOST', '127.0.0.1'),
+ port=int(os.getenv('REDIS_PORT', 6379)),
+ db=int(os.getenv('REDIS_DB', 0)),
+ decode_responses=True
+ )
+ return _client
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..63ff19c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,8 @@
+asgiref==3.11.1
+async-timeout==5.0.1
+Django==5.2.15
+djangorestframework==3.17.1
+python-dotenv==1.2.2
+redis==8.0.0
+sqlparse==0.5.5
+typing_extensions==4.15.0