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 +image -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