From f48a7384adf19cf471bb6fe20f1e9c669cce0b91 Mon Sep 17 00:00:00 2001 From: ghosts6 Date: Tue, 7 Oct 2025 12:13:20 -0400 Subject: [PATCH 1/5] base setup for migration --- Dockerfile | 15 +++++++++++++++ LICENSE | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 Dockerfile create mode 100644 LICENSE diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40e5036 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY ap_monitor/requirements.txt ./ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY ap_monitor/ /app/ap_monitor/ + +WORKDIR /app/ap_monitor + +EXPOSE 8000 + +CMD ["python", "main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e239d75 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 kiarash bashokian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOTT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file From 7bb6ae8e71236b0486e2e5ff221333b85f2473ec Mon Sep 17 00:00:00 2001 From: ghosts6 Date: Sun, 12 Oct 2025 18:58:50 -0400 Subject: [PATCH 2/5] complete most of the core improvement plan --- .github/workflows/python-tests.yml | 18 +- .gitignore | 5 +- README.md | 543 ++-------- ap_monitor/app/cache.py | 18 + ap_monitor/app/db.py | 169 ++- ap_monitor/app/diagnostics.py | 90 +- ap_monitor/app/dna_api.py | 343 +++--- ap_monitor/app/main.py | 1187 +++++++-------------- ap_monitor/app/mapping.py | 223 ++-- ap_monitor/app/models.py | 187 ++-- ap_monitor/app/schemas.py | 98 +- ap_monitor/app/security.py | 17 + ap_monitor/requirements.txt | 3 +- ap_monitor/tests/conftest.py | 245 ++--- ap_monitor/tests/test_apclientcount.py | 479 --------- ap_monitor/tests/test_building_mapping.py | 268 ++--- ap_monitor/tests/test_db.py | 59 +- ap_monitor/tests/test_diagnostics.py | 207 +--- ap_monitor/tests/test_dna_api.py | 571 +++++----- ap_monitor/tests/test_dna_api_coverage.py | 95 ++ ap_monitor/tests/test_endpoints.py | 219 ++++ ap_monitor/tests/test_location_parser.py | 413 ------- ap_monitor/tests/test_main.py | 1032 +----------------- ap_monitor/tests/test_models.py | 347 +++--- ap_monitor/tests/test_update_task.py | 157 +++ script/create_and_send_ap_monitor.sh | 25 - 26 files changed, 2165 insertions(+), 4853 deletions(-) create mode 100644 ap_monitor/app/cache.py create mode 100644 ap_monitor/app/security.py delete mode 100644 ap_monitor/tests/test_apclientcount.py create mode 100644 ap_monitor/tests/test_dna_api_coverage.py create mode 100644 ap_monitor/tests/test_endpoints.py delete mode 100644 ap_monitor/tests/test_location_parser.py create mode 100644 ap_monitor/tests/test_update_task.py delete mode 100755 script/create_and_send_ap_monitor.sh diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index fe3d9fe..fe14255 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - feature/* pull_request: branches: - main @@ -36,17 +37,12 @@ jobs: DNA_USERNAME=${{ secrets.DNA_USERNAME }} DNA_PASSWORD=${{ secrets.DNA_PASSWORD }} LOG_LEVEL=${{ secrets.LOG_LEVEL }} + ENABLE_DIAGNOSTICS=true + API_KEY=test-key EOF - - name: Install dependencies - run: | - cd ap_monitor - python -m pip install --upgrade pip - pip install -r requirements.txt + - name: Build Docker image + run: docker build -t ap-monitor . - - name: Run tests - env: - TESTING: true - PYTHONPATH: ap_monitor - run: | - pytest -v ap_monitor/tests/ + - name: Run tests in Docker + run: docker run --env-file .env ap-monitor pytest -v ap_monitor/tests/ diff --git a/.gitignore b/.gitignore index 579aaa2..e51d80e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ Thumbs.db *.egg-info/ dist/ build/ -doc/ \ No newline at end of file +doc/ + +# db +*.db \ No newline at end of file diff --git a/README.md b/README.md index 7d277c2..a399f03 100644 --- a/README.md +++ b/README.md @@ -1,393 +1,121 @@ -# **AP Monitor** +# AP Monitor -**AP Monitor** is a FastAPI-based application designed to monitor wireless Access Points (APs) and client counts by integrating with Cisco DNA Center APIs. The application periodically fetches AP data, stores it in a PostgreSQL database, and provides RESTful APIs for data retrieval and manual updates. It is designed for enterprise environments and supports deployment using `systemd` or Docker for virtualization. +**AP Monitor** is a powerful and flexible FastAPI-based application designed to monitor wireless Access Points (APs) and client counts by integrating with Cisco DNA Center APIs. It provides real-time data, historical trends, and advanced diagnostics to help you manage your wireless network effectively. ---- - -## **How it Works** -- **Data Collection:** Periodically fetches AP and client count data from Cisco DNA Center APIs. -- **Database Storage:** Stores AP and client count data in a relational database (PostgreSQL in production, SQLite in tests). -- **RESTful API:** Exposes endpoints for retrieving APs, client counts, buildings, floors, rooms, and diagnostics. -- **Diagnostics:** Provides advanced endpoints for zero-count detection, health monitoring, incomplete device records, and API health. -- **Manual and Scheduled Updates:** Data can be updated on a schedule (APScheduler) or manually via API. -- **Logging:** All events and errors are logged for auditing and debugging. -- **Testing:** Comprehensive test suite using pytest, in-memory SQLite, and mock data for fast, isolated tests. - ---- - -## **Project Structure** - -``` -client_count/ -├── ap_monitor/ -│ ├── __init__.py -│ ├── app/ -│ │ ├── __init__.py -│ │ ├── db.py -│ │ ├── diagnostics.py -│ │ ├── dna_api.py -│ │ ├── main.py -│ │ ├── mapping.py -│ │ ├── models.py -│ │ ├── schemas.py -│ │ └── utils.py -│ ├── __init__.py -│ ├── __pycache__/ -│ ├── main.py -│ ├── tests/ -│ │ ├── __init__.py -│ │ ├── conftest.py -│ │ ├── test_apclientcount.py -│ │ ├── test_building_mapping.py -│ │ ├── test_db.py -│ │ ├── test_diagnostics.py -│ │ ├── test_dna_api.py -│ │ ├── test_location_parser.py -│ │ ├── test_main.py -│ │ ├── test_models.py -│ │ └── test_utils.py -│ ├── .env -│ ├── requirements.txt -├── Logs/ -├── venv/ -├── pytest.ini -├── README.md -``` +![Project Banner](https://user-images.githubusercontent.com/10666823/189179629-2d9a1b57-5e43-4b70-8488-17596713c093.png) --- -## **Environment Configuration** - -The application uses a `.env` file for configuration. This file is required for both production and testing, but **test runs override the database settings to use in-memory SQLite** for isolation and speed. - -Example `.env` (edit as needed): - -```env -# Database Configuration -DB_HOST=localhost -DB_NAME=wireless_count -DB_USER=postgres -DB_PASSWORD=your_password -DB_PORT=3306 - -APCLIENT_DB_URL=postgresql://postgres:your_password@localhost:3306/apclientcount - -# DNA Center API Configuration -DNA_API_URL=https://your-dnac-host/dna/intent/api/v1/ -DNA_USERNAME=your_username -DNA_PASSWORD=your_password - -# Application Configuration -LOG_LEVEL=INFO -ENABLE_DIAGNOSTICS=false -``` - ---- - -## **Testing Environment** - -- **Database:** Uses **in-memory SQLite** for all tests (no real PostgreSQL required). -- **Data:** Uses **mock data** for API and database calls to ensure tests are fast, isolated, and do not affect production data or external services. -- **Test Runner:** Uses `pytest` for running all tests. -- **How to Run:** - -```bash -TESTING=true PYTHONPATH=ap_monitor pytest -v ap_monitor/tests/ -``` - -- **Note:** - - The test suite does **not** require a running PostgreSQL instance or access to real Cisco DNA Center APIs. - - All database and API interactions are mocked or use in-memory data. - ---- - -## **Production Environment** - -- **Database:** Uses **PostgreSQL** for persistent, real data storage. -- **Data:** Connects to **real Cisco DNA Center APIs** for live data. -- **Virtual Environment:** Runs in a Python `venv` for dependency isolation. -- **Process Management:** Managed by `systemd` (or Docker) for reliability and automatic restarts. -- **Logging:** Application logs are stored in the `Logs/` directory. -- **How to Run:** - - Follow the setup and systemd instructions below. - ---- - -## **Prerequisites** - -Ensure the following are installed on the server: - -- **Python**: Version 3.10 or higher -- **PostgreSQL**: Version 12 or higher -- **Docker** (optional): For containerized deployment -- **Systemd**: For managing the application as a service - ---- - -## **Setup Instructions** - -### 1. Prepare a Clean Deployment Directory - -Choose a path for your new app. For example: - -```bash -mkdir -p /home/statclcn/client_count -cd /home/statclcn/client_count -``` - -### 2. Create and Activate a Virtual Environment - -```bash -python3 -m venv venv -source venv/bin/activate -``` - -### 3. Clone the Repository - -```bash -git clone https://github.com/Ghosts6/client_count -cd client_count -``` - -### 4. Configure Environment Variables - -Create a `.env` file in the root directory with the following contents. **Note:** The default PostgreSQL port is 5432, but this project uses 3306 (edit as needed): - -```env -# Database Configuration -DB_HOST=localhost -DB_NAME=wireless_count -DB_USER=postgres -DB_PASSWORD=your_password -DB_PORT=3306 - -APCLIENT_DB_URL=postgresql://postgres:your_password@localhost:3306/apclientcount - -# DNA Center API Configuration -DNA_API_URL=https://your-dnac-host/dna/intent/api/v1/ -DNA_USERNAME=your_username -DNA_PASSWORD=your_password - -# Application Configuration -LOG_LEVEL=INFO -``` - -### 5. Install Dependencies - -```bash -pip install -r ap_monitor/requirements.txt -``` - -### 6. Initialize the Database - -Run the function that creates your tables (once): - -```bash -python -c "from ap_monitor.app.db import init_db; init_db()" -``` - -### 7. Create a `systemd` Service - -Save the following configuration as `/etc/systemd/system/ap_monitor.service` (edit paths and user/group as needed): - -```ini -[Unit] -Description=AP Monitor FastAPI Application -After=network.target - -[Service] -User=statclcn -Group=statclcn -WorkingDirectory=/path/to/project -Environment="PATH=/path/to/venv/bin" -ExecStart=/path/to/client_count/venv/bin/uvicorn ap_monitor.app.main:app --host 0.0.0.0 --port 8000 -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -``` - -### 8. Start the New Service - -Bring up the new service: +## ✨ Features -```bash -sudo systemctl daemon-reload -sudo systemctl enable ap_monitor.service -sudo systemctl start ap_monitor.service -sudo systemctl status ap_monitor.service # Verify it's running -``` +- **Real-time Monitoring:** Get up-to-the-minute client counts for your entire wireless network. +- **Historical Data:** Store and query historical client count data to identify trends and patterns. +- **RESTful API:** A comprehensive RESTful API for retrieving data and managing the application. +- **WebSocket Support:** A WebSocket endpoint for receiving real-time updates of the total client count. +- **Advanced Diagnostics:** A suite of diagnostic tools to help you identify and troubleshoot issues with your wireless network. +- **Flexible Configuration:** Configure the application using environment variables to adapt it to your specific needs. +- **Docker Support:** Deploy the application using Docker for easy and consistent deployments. +- **Authentication:** Secure your API endpoints with API key authentication. +- **Caching:** An in-memory cache to improve the performance of expensive API calls. --- -## **Database Setup** +## 🚀 Getting Started -Ensure PostgreSQL is running and create the database: +### Prerequisites -```bash -createdb -h localhost -p 3306 -U postgres wireless_count -``` +- Python 3.10+ +- PostgreSQL 12+ +- Docker (optional) ---- +### Installation -## **API Endpoints** +1. **Clone the repository:** -### **Health Check** + ```bash + git clone https://github.com/your-username/client_count.git + cd client_count + ``` -- **Endpoint**: `GET /health` -- **Description**: Returns the health status of the application. -- **Example:** -```bash -curl -i http://localhost:8000/health -``` +2. **Create and activate a virtual environment:** -### **Update AP Data** + ```bash + python3 -m venv venv + source venv/bin/activate + ``` -- **Endpoint**: `POST /tasks/update-ap-data/` -- **Description**: Manually triggers an update of AP data from the DNA Center API. -- **Example:** -```bash -curl -X POST http://localhost:8000/tasks/update-ap-data/ -``` +3. **Install the dependencies:** -### **Update Client Count Data** + ```bash + pip install -r ap_monitor/requirements.txt + ``` -- **Endpoint**: `POST /tasks/update-client-count/` -- **Description**: Manually triggers an update of client count data from the DNA Center API. -- **Example:** -```bash -curl -X POST http://localhost:8000/tasks/update-client-count/ -``` +4. **Configure the application:** -### **List AP Data** + Create a `.env` file in the root directory of the project and add the following environment variables: -- **Endpoint**: `GET /aps` -- **Description**: Retrieves all AP data from the database. Supports query parameters for filtering. -- **Example:** -```bash -curl -i http://localhost:8000/aps -``` + ```env + # Database Configuration + DB_HOST=localhost + DB_NAME=wireless_count + DB_USER=postgres + DB_PASSWORD=your_password + DB_PORT=5432 -### **List Client Count Data** + # DNA Center API Configuration + DNA_API_URL=https://your-dnac-host/dna/intent/api/v1/ + DNA_USERNAME=your_username + DNA_PASSWORD=your_password -- **Endpoint**: `GET /client-counts` -- **Description**: Retrieves client count data from the database with optional filters. -- **Example:** -```bash -curl -i http://localhost:8000/client-counts -``` + # Application Configuration + LOG_LEVEL=INFO + ENABLE_DIAGNOSTICS=true + API_KEY=your-secret-api-key + ``` -### **List Buildings** +5. **Initialize the database:** -- **Endpoint**: `GET /buildings` -- **Description**: Retrieves a list of unique buildings from the client count data. -- **Example:** -```bash -curl -i http://localhost:8000/buildings -``` + ```bash + python -c "from ap_monitor.app.db import init_db; init_db()" + ``` -### **Diagnostics** +### Usage -- **Purpose**: Provides advanced diagnostics and troubleshooting endpoints for AP and client count data quality, zero-counts, and incomplete device records. Only available if `ENABLE_DIAGNOSTICS=true`. +To run the application, use the following command: -#### **Zero Count Diagnostics** - -- **Endpoint**: `GET /diagnostics/zero-counts` -- **Description**: Returns diagnostics for buildings with zero client counts and potential issues. -- **Example:** ```bash -curl -i http://localhost:8000/diagnostics/zero-counts +uvicorn ap_monitor.app.main:app --host 0.0.0.0 --port 8000 ``` -#### **Building Health Alerts** +You can now access the API at `http://localhost:8000`. -- **Endpoint**: `GET /diagnostics/health` -- **Description**: Returns health monitoring alerts for buildings (e.g., sudden drops in client count). -- **Example:** -```bash -curl -i http://localhost:8000/diagnostics/health -``` +### Docker Deployment -#### **Comprehensive Diagnostic Report** +To deploy the application using Docker, you can use the provided `Dockerfile` and `docker-compose.yml` files. -- **Endpoint**: `GET /diagnostics/report` -- **Description**: Returns a comprehensive diagnostic report including zero count analysis and health monitoring. -- **Example:** -```bash -curl -i http://localhost:8000/diagnostics/report -``` +1. **Build the Docker image:** -#### **Incomplete Devices Diagnostics** + ```bash + docker build -t ap-monitor . + ``` -- **Endpoint**: `GET /diagnostics/incomplete-devices` -- **Description**: Returns a list of APs/devices with missing required fields (incomplete records) and their details. -- **Example:** -```bash -curl -i http://localhost:8000/diagnostics/incomplete-devices -``` +2. **Run the application using Docker Compose:** -#### **API Health Diagnostics** - -- **Endpoint**: `GET /diagnostics/api_health` -- **Description**: Returns a summary of recent API error rates and details. Tracks the last 100 API errors (in memory, not persisted across restarts). -- **Response:** -``` -{ - "total_errors_tracked": 12, - "errors_last_hour": 3, - "recent_errors": [ - { - "timestamp": "2025-07-11T16:50:08.360339+00:00", - "type": "APIError", - "message": "No AP/client data available from any endpoint." - }, - ... - ] -} -``` -- `total_errors_tracked`: Number of errors currently tracked (max 100). -- `errors_last_hour`: Number of errors in the last hour. -- `recent_errors`: The 10 most recent errors (timestamp, type, message). -- **Usage:** - - `GET /diagnostics/api_health` - - Useful for monitoring API health, rate limits, and diagnosing external API issues. - -### **OpenAPI Documentation** - -- **Endpoint**: `GET /openapi.json` and `/docs` -- **Description**: Returns the OpenAPI schema and interactive API docs. -- **Example:** -```bash -curl -i http://localhost:8000/openapi.json -``` + ```bash + docker-compose up + ``` --- -## **Logging** +## API Endpoints -Application logs are stored in the `Logs/` directory: +The API is documented using OpenAPI (Swagger). You can access the interactive documentation at `http://localhost:8000/docs`. -``` -Logs/ap-monitor.log -``` +All endpoints (except `/health`) require an API key to be passed in the `X-API-Key` header. --- -## **Testing** - -The application uses `pytest` for testing. Tests are located in the `tests/` directory and cover the following areas: - -- **Models:** Tests for database models (`test_models.py`). -- **APIs:** Tests for DNA Center API integration (`test_dna_api.py`). -- **Utilities:** Tests for utility functions like logging and scheduling (`test_utils.py`). -- **Location Parsing:** Tests for location parsing logic (`test_location_parser.py`). -- **Application Functionality:** Tests for FastAPI endpoints and database interactions. - -**Test Environment Details:** -- All tests use **in-memory SQLite** (no PostgreSQL required). -- All external API calls are **mocked**. -- Tests are fast, isolated, and safe to run on any machine. +## 🧪 Running Tests To run the tests, use the following command: @@ -395,129 +123,14 @@ To run the tests, use the following command: TESTING=true PYTHONPATH=ap_monitor pytest -v ap_monitor/tests/ ``` -Api endpoint test examples: - -```bash -curl -i http://localhost:8000/ -curl -i http://localhost:8000/openapi.json -curl -i http://localhost:8000/buildings -``` - -Run app manually: - -```bash -uvicorn ap_monitor.app.main:app --host 0.0.0.0 --port 8000 --reload -``` - --- -## **Automated Cleanup with pg\_Cron** - -This PostgreSQL setup uses the `pg_cron` extension to schedule a daily cleanup job that deletes old records (older than 30 days) from two tables: - -* `clientcount` in the `apclientcount` database -* `client_counts` in the `wireless_count` database (via Unix socket) - -### Configuration Overview - -The cleanup is handled safely and automatically with the following configurations and components: - -### **1. Install `pg_cron` Extension** - -Install `pg_cron` using your package manager (example for Debian-based systems): - -```bash -sudo apt install postgresql-14-cron -``` - -Enable the extension in your PostgreSQL configuration: - -```bash -# postgresql.conf -shared_preload_libraries = 'pg_cron' -cron.database_name = 'apclientcount' -cron.host = '/var/run/postgresql' -cron.port = 3306 -``` -> 🔄 After updating the config, **restart** PostgreSQL: +## 🤝 Contributing -```bash -sudo systemctl restart postgresql -``` - -### **2. Enable `pg_cron` in the Database** - -Connect to the `apclientcount` database and enable the extension: - -```sql -CREATE EXTENSION IF NOT EXISTS pg_cron; -``` - -### **3. Create Cleanup Function** - -Define a reusable PL/pgSQL function to delete stale records: - -```sql -CREATE OR REPLACE FUNCTION public.cleanup_counts() RETURNS void AS -$$ -BEGIN - -- Local cleanup - DELETE FROM clientcount - WHERE timestamp < NOW() - INTERVAL '30 days'; - - -- Remote cleanup via Unix socket on port 3306 - PERFORM dblink_exec( - 'host=/var/run/postgresql port=3306 dbname=wireless_count user=postgres', - 'DELETE FROM client_counts WHERE time_inserted < NOW() - INTERVAL ''30 days'';' - ); -END -$$ LANGUAGE plpgsql; -``` - -### **4. Schedule the Daily Cleanup Job** +Contributions are welcome! Please feel free to submit a pull request or open an issue. -Create a daily cron job that runs at 3:00 AM: - -```sql -SELECT cron.schedule( - 'daily_cleanup', - '0 3 * * *', - $$ SELECT cleanup_counts(); $$ -); -``` - -#### **Manual Cleanup** -To run the cleanup manually, execute: - -```sql -SELECT cleanup_counts(); -``` - -### ✅ **Result** - -* The task runs every day at 3:00 AM. -* It safely cleans both local and remote tables using a secure Unix socket. -* Logs and status can be monitored via: - -```sql -SELECT jobid, - runid, - status, - return_message, - start_time, - end_time -FROM cron.job_run_details -WHERE jobid = ( - SELECT jobid - FROM cron.job - WHERE jobname = 'daily_cleanup' -) -ORDER BY start_time DESC -LIMIT 5; -``` +--- -### cancel the scheduled job: +## 📄 License -```sql -SELECT cron.unschedule('daily_cleanup'); -``` +This project is licensed under the MIT License. See the `LICENSE` file for details. \ No newline at end of file diff --git a/ap_monitor/app/cache.py b/ap_monitor/app/cache.py new file mode 100644 index 0000000..5f1a5ed --- /dev/null +++ b/ap_monitor/app/cache.py @@ -0,0 +1,18 @@ +import time +from functools import wraps + +cache = {} + +def timed_cache(ttl): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + key = f"{func.__name__}:{args}:{kwargs}" + if key in cache and time.time() - cache[key]['timestamp'] < ttl: + return cache[key]['value'] + + result = await func(*args, **kwargs) + cache[key] = {'value': result, 'timestamp': time.time()} + return result + return wrapper + return decorator diff --git a/ap_monitor/app/db.py b/ap_monitor/app/db.py index 25dc83d..7bd61d7 100644 --- a/ap_monitor/app/db.py +++ b/ap_monitor/app/db.py @@ -1,156 +1,115 @@ -import logging import os -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, declarative_base -from dotenv import load_dotenv +import logging from contextlib import contextmanager +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from .models import Base + + +# -------------------------------------------------------------------- +# Environment setup +# -------------------------------------------------------------------- -# Load .env file -load_dotenv() +dotenv_path = os.path.join(os.path.dirname(__file__), '.env') +load_dotenv(dotenv_path=dotenv_path) + + +# -------------------------------------------------------------------- +# Logger configuration +# -------------------------------------------------------------------- -# Configure logger logger = logging.getLogger(__name__) -# Get database configuration from environment variables -DB_HOST = os.getenv("DB_HOST", "localhost") -DB_NAME = os.getenv("DB_NAME", "wireless_count") -DB_USER = os.getenv("DB_USER", "postgres") -DB_PASSWORD = os.getenv("DB_PASSWORD") -DB_PORT = os.getenv("DB_PORT", "3306") -APCLIENT_DB_URL = os.getenv("APCLIENT_DB_URL") +# -------------------------------------------------------------------- +# Database configuration +# -------------------------------------------------------------------- -# Create database URLs -WIRELESS_DB_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" +DB_URL = os.getenv("DATABASE_URL") # For testing, use SQLite in-memory database if os.getenv("TESTING", "false").lower() == "true": - WIRELESS_DB_URL = "sqlite:///:memory:" - APCLIENT_DB_URL = "sqlite:///:memory:" + DB_URL = "sqlite:///:memory:" -# Create base classes for declarative models -WirelessBase = declarative_base() -APClientBase = declarative_base() - -# Initialize engines and session factories -wireless_engine = None -apclient_engine = None -WirelessSessionLocal = None -APClientSessionLocal = None +# Initialize engine and session factory +engine = None +SessionLocal = None try: - # Create SQLAlchemy engines with appropriate configuration for each database type + # Create SQLAlchemy engine with appropriate configuration if os.getenv("TESTING", "false").lower() == "true": # SQLite configuration for testing - wireless_engine = create_engine( - WIRELESS_DB_URL, - connect_args={"check_same_thread": False} - ) - apclient_engine = create_engine( - APCLIENT_DB_URL, + engine = create_engine( + DB_URL, connect_args={"check_same_thread": False} ) else: # PostgreSQL configuration for production - wireless_engine = create_engine( - WIRELESS_DB_URL, - pool_pre_ping=True, - pool_size=5, - max_overflow=10, - pool_recycle=3600 - ) - apclient_engine = create_engine( - APCLIENT_DB_URL, + engine = create_engine( + DB_URL, pool_pre_ping=True, pool_size=5, max_overflow=10, pool_recycle=3600 ) - # Create session factories - WirelessSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=wireless_engine) - APClientSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=apclient_engine) + # Create session factory + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - logger.info(f"Database connections set up successfully") + logger.info(f"Database connection set up successfully") except Exception as e: - logger.error(f"Error connecting to the databases: {e}") + logger.error(f"Error connecting to the database: {e}") raise -@contextmanager -def get_wireless_db(): - """Dependency for getting wireless_count DB session in FastAPI endpoints.""" - db = WirelessSessionLocal() - try: - yield db - finally: - db.close() +# -------------------------------------------------------------------- +# Database configuration +# -------------------------------------------------------------------- @contextmanager -def get_apclient_db(): - """Dependency for getting apclientcount DB session in FastAPI endpoints.""" - db = APClientSessionLocal() +def get_db(): + """Dependency for getting DB session in FastAPI endpoints.""" + db = SessionLocal() try: yield db finally: db.close() -def get_wireless_db_session(): - """Get a wireless database session without context management.""" - return WirelessSessionLocal() +def get_db_session(): + """Get a database session without context management.""" + return SessionLocal() -def get_apclient_db_session(): - """Get an AP client database session without context management.""" - return APClientSessionLocal() - -def get_apclient_db_dep(): - """FastAPI dependency for getting apclientcount DB session (generator, not context manager).""" - db = APClientSessionLocal() +def get_db_dep(): + """FastAPI dependency for getting DB session (generator, not context manager).""" + db = SessionLocal() try: yield db finally: db.close() -def get_wireless_db_dep(): - """FastAPI dependency for getting wireless_count DB session (generator, not context manager).""" - db = WirelessSessionLocal() - try: - yield db - finally: - db.close() +# -------------------------------------------------------------------- +# Database configuration +# -------------------------------------------------------------------- def init_db(): - """Initialize databases by creating tables.""" + """Initialize database by creating tables.""" try: logger.info("Creating database tables...") - # Import models here to avoid circular imports - from ap_monitor.app.models import ( - Campus, Building, ClientCount, # wireless_count models - ApBuilding, Floor, Room, AccessPoint, RadioType, ClientCountAP # apclientcount models - ) - - # Create tables for wireless_count - WirelessBase.metadata.create_all(bind=wireless_engine) - logger.info("Wireless count database tables created successfully") - - # Create tables for apclientcount - APClientBase.metadata.create_all(bind=apclient_engine) - logger.info("AP client count database tables created successfully") + Base.metadata.create_all(bind=engine) + logger.info("Database tables created successfully") except Exception as e: - logger.error(f"Error initializing databases: {e}") + logger.error(f"Error initializing database: {e}") raise -# Make these available at module level for testing +# -------------------------------------------------------------------- +# Public exports +# -------------------------------------------------------------------- + __all__ = [ - 'wireless_engine', - 'apclient_engine', - 'WirelessSessionLocal', - 'APClientSessionLocal', - 'WirelessBase', - 'APClientBase', - 'get_wireless_db', - 'get_apclient_db', - 'get_wireless_db_session', - 'get_apclient_db_session', - 'get_apclient_db_dep', - 'get_wireless_db_dep', - 'init_db' + 'engine', + 'SessionLocal', + 'Base', + 'get_db', + 'get_db_session', + 'get_db_dep', + 'init_db', ] \ No newline at end of file diff --git a/ap_monitor/app/diagnostics.py b/ap_monitor/app/diagnostics.py index 51f8105..197b13d 100644 --- a/ap_monitor/app/diagnostics.py +++ b/ap_monitor/app/diagnostics.py @@ -2,7 +2,7 @@ import os from datetime import datetime, timezone, timedelta from sqlalchemy import func, and_ -from .models import Building, Campus, ClientCount, ApBuilding, AccessPoint, ClientCountAP +from .models import Building, AccessPoint, ClientCount import json from logging.handlers import TimedRotatingFileHandler import gzip @@ -78,7 +78,7 @@ def log_diagnostic_report(report): diagnostics_logger.info(f"Severity: {alert['severity']}") diagnostics_logger.info(f"Message: {alert['message']}") -def analyze_zero_count_buildings(wireless_db, apclient_db, auth_manager): +def analyze_zero_count_buildings(db, auth_manager): """ Analyze buildings with zero client counts to identify potential issues. Returns a detailed report of findings. @@ -97,51 +97,31 @@ def analyze_zero_count_buildings(wireless_db, apclient_db, auth_manager): } # Find buildings with zero counts in the last hour - zero_buildings = wireless_db.query( - Building, Campus - ).join( - Campus, Building.campus_id == Campus.campus_id - ).outerjoin( - ClientCount, - and_( - Building.building_id == ClientCount.building_id, - ClientCount.time_inserted >= datetime.now(timezone.utc) - timedelta(hours=1) - ) - ).filter( - func.coalesce(ClientCount.client_count, 0) == 0 - ).all() + zero_buildings = db.query(Building).outerjoin(ClientCount, and_( + Building.id == AccessPoint.building_id, + AccessPoint.id == ClientCount.access_point_id, + ClientCount.timestamp >= datetime.now(timezone.utc) - timedelta(hours=1) + )).filter(func.coalesce(ClientCount.count, 0) == 0).all() - for building, campus in zero_buildings: + for building in zero_buildings: building_analysis = { - "building_name": building.building_name, - "campus_name": campus.campus_name, + "building_name": building.name, + "campus_name": building.campus.name if building.campus else "Unknown", "ap_status": {}, "dna_center_status": {}, "issues": [], "recommendations": [] } - # Check AP status in apclientcount DB - ap_building = apclient_db.query(ApBuilding).filter( - ApBuilding.buildingname.ilike(building.building_name) - ).first() - - if not ap_building: - building_analysis["issues"].append("Building not found in apclientcount database") - building_analysis["recommendations"].append("Verify building name mapping between databases") - report["potential_issues"].append(f"Mapping issue: {building.building_name}") - report["zero_count_buildings"].append(building_analysis) - continue - # Get AP counts and status - aps = apclient_db.query(AccessPoint).filter( - AccessPoint.buildingid == ap_building.buildingid + aps = db.query(AccessPoint).filter( + AccessPoint.building_id == building.id ).all() building_analysis["ap_status"] = { "total_aps": len(aps), - "active_aps": sum(1 for ap in aps if ap.isactive), - "inactive_aps": sum(1 for ap in aps if not ap.isactive) + "active_aps": sum(1 for ap in aps if ap.is_active), + "inactive_aps": sum(1 for ap in aps if not ap.is_active) } # Check DNA Center status @@ -149,7 +129,7 @@ def analyze_zero_count_buildings(wireless_db, apclient_db, auth_manager): dna_ap_data = fetch_ap_data(auth_manager) building_aps_in_dna = [ ap for ap in dna_ap_data - if building.building_name.lower() in ap.get("location", "").lower() + if building.name.lower() in ap.get("location", "").lower() ] building_analysis["dna_center_status"] = { @@ -175,7 +155,7 @@ def analyze_zero_count_buildings(wireless_db, apclient_db, auth_manager): building_analysis["recommendations"].append("Check AP coverage and client connectivity") except Exception as e: - logger.error(f"Error checking DNA Center status for {building.building_name}: {str(e)}") + logger.error(f"Error checking DNA Center status for {building.name}: {str(e)}") building_analysis["issues"].append(f"Error checking DNA Center: {str(e)}") building_analysis["recommendations"].append("Verify DNA Center connectivity and credentials") @@ -183,7 +163,7 @@ def analyze_zero_count_buildings(wireless_db, apclient_db, auth_manager): return report -def monitor_building_health(wireless_db, apclient_db, auth_manager): +def monitor_building_health(db, auth_manager): """ Monitor building health by comparing current client counts with historical data. Returns alerts for buildings that show unusual patterns. @@ -194,38 +174,40 @@ def monitor_building_health(wireless_db, apclient_db, auth_manager): alerts = [] # Get buildings with client counts in the last hour - recent_counts = wireless_db.query( + recent_counts = db.query( Building, ClientCount ).join( - ClientCount, Building.building_id == ClientCount.building_id + AccessPoint, Building.id == AccessPoint.building_id + ).join( + ClientCount, AccessPoint.id == ClientCount.access_point_id ).filter( - ClientCount.time_inserted >= datetime.now(timezone.utc) - timedelta(hours=1) + ClientCount.timestamp >= datetime.now(timezone.utc) - timedelta(hours=1) ).all() for building, count in recent_counts: # Get historical average (last 24 hours) - historical_avg = wireless_db.query( - func.avg(ClientCount.client_count) - ).filter( - ClientCount.building_id == building.building_id, - ClientCount.time_inserted >= datetime.now(timezone.utc) - timedelta(hours=24) + historical_avg = db.query( + func.avg(ClientCount.count) + ).join(AccessPoint).filter( + AccessPoint.building_id == building.id, + ClientCount.timestamp >= datetime.now(timezone.utc) - timedelta(hours=24) ).scalar() or 0 # If current count is zero but historical average is significant - if count.client_count == 0 and historical_avg > 10: + if count.count == 0 and historical_avg > 10: alert = { - "building_name": building.building_name, - "current_count": count.client_count, + "building_name": building.name, + "current_count": count.count, "historical_avg": round(historical_avg, 2), - "timestamp": count.time_inserted, + "timestamp": count.timestamp, "severity": "high" if historical_avg > 50 else "medium", - "message": f"Building {building.building_name} shows zero clients but had an average of {round(historical_avg, 2)} clients in the last 24 hours" + "message": f"Building {building.name} shows zero clients but had an average of {round(historical_avg, 2)} clients in the last 24 hours" } alerts.append(alert) return alerts -def generate_diagnostic_report(wireless_db, apclient_db, auth_manager): +def generate_diagnostic_report(db, auth_manager): """ Generate a comprehensive diagnostic report including zero count analysis and health monitoring. """ @@ -235,8 +217,8 @@ def generate_diagnostic_report(wireless_db, apclient_db, auth_manager): # Import here to avoid circular import from .dna_api import AuthManager - zero_count_analysis = analyze_zero_count_buildings(wireless_db, apclient_db, auth_manager) - health_alerts = monitor_building_health(wireless_db, apclient_db, auth_manager) + zero_count_analysis = analyze_zero_count_buildings(db, auth_manager) + health_alerts = monitor_building_health(db, auth_manager) report = { "timestamp": datetime.now(timezone.utc), @@ -275,4 +257,4 @@ def get_incomplete_diagnostics(): # --- Patch for dna_api fallback logic to call this after each run --- def save_incomplete_diagnostics_from_list(diagnostics_incomplete): if diagnostics_incomplete: - write_incomplete_diagnostics(diagnostics_incomplete) \ No newline at end of file + write_incomplete_diagnostics(diagnostics_incomplete) \ No newline at end of file diff --git a/ap_monitor/app/dna_api.py b/ap_monitor/app/dna_api.py index 19f41d2..fe57a91 100644 --- a/ap_monitor/app/dna_api.py +++ b/ap_monitor/app/dna_api.py @@ -1,14 +1,12 @@ import base64 import json -import ssl import os import time -from datetime import datetime, timedelta -from urllib.request import Request, urlopen -from urllib.error import HTTPError, URLError +from datetime import datetime, timedelta, timezone, timezone, timezone, timezone +import httpx from urllib.parse import urlencode from dotenv import load_dotenv -from ap_monitor.app.db import APClientSessionLocal + from ap_monitor.app.utils import setup_logging from ap_monitor.app.diagnostics import save_incomplete_diagnostics_from_list from .mapping import parse_ap_name_for_location @@ -19,20 +17,18 @@ # Configure logger logger = setup_logging() -# Create SSL context that doesn't verify certificates -ssl_context = ssl._create_unverified_context() - # DNA Center API configuration -BASE_URL = os.getenv("DNA_API_URL", "https://dnac11.netops.yorku.ca") -AUTH_URL = BASE_URL + "/dna/system/api/v1/auth/token" -SITE_HEALTH_URL = BASE_URL + "/dna/intent/api/v1/site-health" -DEVICE_HEALTH_URL = BASE_URL + "/dna/intent/api/v1/device-health" -NETWORK_DEVICE_URL = BASE_URL + "/dna/intent/api/v1/network-device" -SITE_MEMBERSHIP_URL = BASE_URL + "/dna/intent/api/v1/membership/{siteId}" -KEELE_CAMPUS_SITE_ID = 'e77b6e96-3cd3-400a-9ebd-231c827fd369' +BASE_URL = os.getenv("DNA_API_URL") +AUTH_URL = f"{BASE_URL}/dna/system/api/v1/auth/token" if BASE_URL else "" +SITE_HEALTH_URL = f"{BASE_URL}/dna/intent/api/v1/site-health" if BASE_URL else "" +DEVICE_HEALTH_URL = f"{BASE_URL}/dna/intent/api/v1/device-health" if BASE_URL else "" +NETWORK_DEVICE_URL = f"{BASE_URL}/dna/intent/api/v1/network-device" if BASE_URL else "" +SITE_MEMBERSHIP_URL = f"{BASE_URL}/dna/intent/api/v1/membership/{{siteId}}" if BASE_URL else "" + # Add at the top, after loading env -SITE_HIERARCHY = os.getenv("DNA_SITE_HIERARCHY", "Global/Keele Campus") +SITE_HIERARCHY = os.getenv("DNA_SITE_HIERARCHY") +LOCATION_HIERARCHY_PREFIX = os.getenv("LOCATION_HIERARCHY_PREFIX", "Global/Keele Campus") # Mapping of radio keys to radio IDs radio_id_map = {'radio0': 1, 'radio1': 2, 'radio2': 3} @@ -70,46 +66,42 @@ def __init__(self, auth_url=AUTH_URL, auth_headers=AUTH_HEADERS): def get_token(self, force_refresh=False): """Get a valid authentication token, refreshing if necessary.""" current_time = datetime.now() - - # Check if we need to wait before refreshing + if self.last_refresh_time: time_since_last_refresh = (current_time - self.last_refresh_time).total_seconds() if time_since_last_refresh < self.min_refresh_interval: wait_time = self.min_refresh_interval - time_since_last_refresh logger.info(f"Waiting {wait_time:.1f} seconds before refreshing token...") time.sleep(wait_time) - + if not self.token or not self.token_expiry or current_time >= self.token_expiry - timedelta(minutes=5) or force_refresh: logger.info("Refreshing authentication token") - req = Request(self.auth_url, headers=self.auth_headers, method='POST') try: - with urlopen(req, context=ssl_context) as response: - if response.status == 200: - response_data = json.load(response) - self.token = response_data.get("Token") - if not self.token: - logger.error("No token in response data") - logger.error(f"Response data: {response_data}") - raise Exception("No token in response data") - self.token_expiry = current_time + timedelta(minutes=55) - self.last_refresh_time = current_time - logger.info("Authentication token successfully refreshed") - logger.debug(f"Token expiry set to: {self.token_expiry}") - else: - logger.error(f"Failed to obtain access token. Status: {response.status}") - raise Exception(f"Failed to obtain access token: {response.status}") - except HTTPError as e: - logger.error(f"HTTP Error while obtaining access token: {e.code} - {e.reason}") - raise Exception(f"Failed to obtain access token: {e.reason}") - except URLError as e: - logger.error(f"URL Error while obtaining access token: {e.reason}") - raise Exception(f"Failed to obtain access token: {e.reason}") + with httpx.Client(verify=False) as client: + response = client.post(self.auth_url, headers=self.auth_headers) + response.raise_for_status() + response_data = response.json() + self.token = response_data.get("Token") + if not self.token: + logger.error("No token in response data") + logger.error(f"Response data: {response_data}") + raise Exception("No token in response data") + self.token_expiry = current_time + timedelta(minutes=55) + self.last_refresh_time = current_time + logger.info("Authentication token successfully refreshed") + logger.debug(f"Token expiry set to: {self.token_expiry}") + except httpx.HTTPStatusError as e: + logger.error(f"HTTP Error while obtaining access token: {e.response.status_code} - {e.response.text}") + raise Exception(f"Failed to obtain access token: {e.response.text}") + except httpx.RequestError as e: + logger.error(f"Request Error while obtaining access token: {e}") + raise Exception(f"Failed to obtain access token: {e}") except Exception as e: logger.error(f"Unexpected error while obtaining access token: {str(e)}") raise return self.token -def fetch_client_counts(auth_manager, rounded_unix_timestamp, retries=3): +def fetch_client_counts(auth_manager, site_id, timestamp, retries=3): """ Fetch wireless client count data from DNA Center API using both site-health and site-detail endpoints. @@ -126,14 +118,15 @@ def fetch_client_counts(auth_manager, rounded_unix_timestamp, retries=3): data = [] # First get the site details to get building hierarchy - site_detail_url = f"{BASE_URL}/dna/intent/api/v1/site/{KEELE_CAMPUS_SITE_ID}" + site_detail_url = f"{BASE_URL}/dna/intent/api/v1/site/{site_id}" building_map = {} try: logger.info("Fetching site details for building hierarchy") - req = Request(site_detail_url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - site_details = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(site_detail_url, headers=auth_headers, timeout=60) + response.raise_for_status() + site_details = response.json() # Process site details to create building map for site in site_details.get('response', []): @@ -158,21 +151,20 @@ def fetch_client_counts(auth_manager, rounded_unix_timestamp, retries=3): # First request to get total count params = { - "siteId": KEELE_CAMPUS_SITE_ID, + "siteId": site_id, "limit": 50, "offset": 1 } - query_string = "&".join(f"{k}={v}" for k, v in params.items()) - url = f"{site_health_url}?{query_string}" - req = Request(url, headers=auth_headers) attempt = 0 while attempt < retries: try: logger.info(f"Starting API request with offset 1") - with urlopen(req, context=ssl_context, timeout=60) as response: - response_data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(site_health_url, headers=auth_headers, params=params, timeout=60) + response.raise_for_status() + response_data = response.json() logger.info(f"API request completed successfully") if 'response' not in response_data: @@ -213,7 +205,7 @@ def fetch_client_counts(auth_manager, rounded_unix_timestamp, retries=3): processed_site = { 'location': site_name, 'clientCount': wireless_clients, - 'timestamp': rounded_unix_timestamp, + 'timestamp': timestamp, 'wiredClients': wired_clients, 'wirelessClients': wireless_clients, 'totalClients': total_clients, @@ -229,8 +221,8 @@ def fetch_client_counts(auth_manager, rounded_unix_timestamp, retries=3): } data.append(processed_site) break - except HTTPError as e: - if e.code == 429: # Too Many Requests + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: # Too Many Requests attempt += 1 if attempt >= retries: logger.error(f"Failed after {retries} attempts due to rate limiting") @@ -292,24 +284,22 @@ def test_api_connection(): params = { "deviceRole": "AP", - "siteId": KEELE_CAMPUS_SITE_ID, + "siteId": site_id, "limit": 1, "offset": 1 } - query_string = urlencode(params) - test_url = f"{DEVICE_HEALTH_URL}?{query_string}" - req = Request(test_url, headers=auth_headers) - - with urlopen(req, context=ssl_context, timeout=60) as response: - response_data = response.read().decode('utf-8') - device_info = json.loads(response_data) + test_url = f"{DEVICE_HEALTH_URL}" + with httpx.Client(verify=False) as client: + response = client.get(test_url, headers=auth_headers, params=params, timeout=60) + response.raise_for_status() + device_info = response.json() return { "status": "success", "response": device_info, - "headers": dict(response.getheaders()), - "status_code": response.status + "headers": dict(response.headers), + "status_code": response.status_code } except Exception as e: @@ -319,7 +309,7 @@ def test_api_connection(): "type": type(e).__name__ } -def fetch_ap_data(auth_manager, timestamp=None, clients_data=None): +def fetch_ap_data(auth_manager, site_id, timestamp=None, clients_data=None): """ Fetch AP data from DNA Center API with rate limit handling and fallback to client data for location. """ @@ -337,13 +327,12 @@ def fetch_ap_data(auth_manager, timestamp=None, clients_data=None): # Build request parameters params = { "deviceRole": "AP", - "siteId": KEELE_CAMPUS_SITE_ID, + "siteId": site_id, "limit": limit, "offset": offset } - query_string = "&".join(f"{k}={v}" for k, v in params.items()) - url = f"{BASE_URL}/dna/intent/api/v1/device-health?{query_string}" + url = f"{BASE_URL}/dna/intent/api/v1/device-health" try: # Get fresh token for each request @@ -353,10 +342,10 @@ def fetch_ap_data(auth_manager, timestamp=None, clients_data=None): 'Content-Type': 'application/json' } - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context) as response: - response_data = response.read().decode('utf-8') - data = json.loads(response_data) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, params=params) + response.raise_for_status() + data = response.json() if 'response' not in data: raise KeyError("Missing 'response' in API response") @@ -379,8 +368,8 @@ def fetch_ap_data(auth_manager, timestamp=None, clients_data=None): time.sleep(5) # 5 seconds between requests retry_count = 0 # Reset retry count on successful request - except HTTPError as e: - if e.code == 429: # Too Many Requests + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: # Too Many Requests retry_count += 1 if retry_count > max_retries: logger.error(f"Failed after {max_retries} retries due to rate limiting") @@ -474,14 +463,15 @@ def get_ap_data(auth_manager=None, retries=3): token = auth_manager.get_token() auth_headers = {'x-auth-token': token} - req = Request(NETWORK_DEVICE_URL, headers=auth_headers) attempt = 0 while attempt < retries: try: logger.info("Fetching network device data from DNA Center API") - with urlopen(req, context=ssl_context, timeout=60) as response: - response_data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(NETWORK_DEVICE_URL, headers=auth_headers, timeout=60) + response.raise_for_status() + response_data = response.json() devices = response_data.get('response', []) # Filter for access points @@ -508,111 +498,6 @@ def get_ap_data(auth_manager=None, retries=3): raise time.sleep(2 ** attempt) # Exponential backoff -def insert_apclientcount_data(device_info_list, timestamp, session=None): - """Insert AP and client count data into the database.""" - from ap_monitor.app.models import ApBuilding, Floor, Room, AccessPoint, ClientCountAP, RadioType - close_session = False - if session is None: - session = APClientSessionLocal() - close_session = True - try: - radioId_map = {r.radioname: r.radioid for r in session.query(RadioType).all()} - for device in device_info_list: - ap_name = device['name'] - location = device.get('location', '') - - # Location parsing logic - handle multiple formats - location_parts = [p.strip() for p in location.split('/') if p.strip()] if location else [] - building_name = None - floor_name = None - room_name = None - # Robust parsing for all real-world formats - if len(location_parts) >= 4: - building_name = location_parts[2] - floor_name = location_parts[3] - if len(location_parts) > 4: - room_name = location_parts[4] - elif len(location_parts) == 3: - building_name = location_parts[1] - floor_name = location_parts[2] - elif len(location_parts) == 2: - building_name = location_parts[0] - floor_name = location_parts[1] - else: - logger.warning(f"Skipping device {ap_name} due to invalid location format: {location}") - continue - - # Building - building = session.query(ApBuilding).filter_by(building_name=building_name).first() - if not building: - building = ApBuilding(building_name=building_name) - session.add(building) - session.flush() - - # Floor - floor = session.query(Floor).filter_by(floorname=floor_name, building_id=building.building_id).first() - if not floor: - floor = Floor(floorname=floor_name, building_id=building.building_id) - session.add(floor) - session.flush() - - # Room (optional) - room = None - if room_name: - room = session.query(Room).filter_by(roomname=room_name, floorid=floor.floorid).first() - if not room: - room = Room(roomname=room_name, floorid=floor.floorid) - session.add(room) - session.flush() - - # Rest of the function remains the same... - # Access Point - mac_address = device['macAddress'] - ap = session.query(AccessPoint).filter_by(macaddress=mac_address).first() - is_active = device['reachabilityHealth'] == "UP" - if not ap: - ap = AccessPoint( - apname=ap_name, - macaddress=mac_address, - ipaddress=device.get('ipAddress'), - modelname=device.get('model'), - isactive=is_active, - floorid=floor.floorid, - building_id=building.building_id, - roomid=room.roomid if room else None - ) - session.add(ap) - session.flush() - else: - ap.isactive = is_active - - # ClientCountAP - for radio, count in device.get('clientCount', {}).items(): - radio_id = radioId_map.get(radio) - if radio_id is None: - logger.warning(f"Unexpected radio key: {radio}") - continue - cc = session.query(ClientCountAP).filter_by(apid=ap.apid, radioid=radio_id, timestamp=timestamp).first() - if cc: - cc.clientcount = count - else: - cc = ClientCountAP( - apid=ap.apid, - radioid=radio_id, - clientcount=count, - timestamp=timestamp - ) - session.add(cc) - session.commit() - logger.info(f"Inserted/updated AP and client count data in apclientcount DB for {len(device_info_list)} devices.") - except Exception as e: - session.rollback() - logger.error(f"Error inserting data into apclientcount DB: {e}") - raise - finally: - if close_session: - session.close() - def fetch_clients(auth_manager, retries=3, page_limit=100, max_clients=None, delay=1.0, site_id=None, site_hierarchy=None): """ Fetch all client devices from the DNA Center API using pagination and required filter. @@ -639,14 +524,15 @@ def fetch_clients(auth_manager, retries=3, page_limit=100, max_clients=None, del filter_param = {'siteHierarchy': site_hierarchy} while True: params = {**filter_param, 'limit': page_limit, 'offset': offset} - url = f"{BASE_URL}/dna/data/api/v1/clients?{urlencode(params)}" + url = f"{BASE_URL}/dna/data/api/v1/clients" attempt = 0 while attempt < retries: try: logger.info(f"Fetching clients: offset={offset}, limit={page_limit}, filter={filter_param}") - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, params=params, timeout=60) + response.raise_for_status() + data = response.json() clients = data.get('response', []) if not clients: logger.info(f"No more clients returned at offset {offset}.") @@ -700,15 +586,16 @@ def fetch_clients_count_for_ap(auth_manager, mac=None, name=None, site_id=None, params['macAddress'] = mac if name: params['connectedNetworkDeviceName'] = name - url = f"{BASE_URL}/dna/data/api/v1/clients/count?{urlencode(params)}" + url = f"{BASE_URL}/dna/data/api/v1/clients/count" attempt = 0 current_delay = delay while attempt < retries: throttle_clients_count() try: - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=30) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, params=params, timeout=30) + response.raise_for_status() + data = response.json() logger.debug(f"/clients/count response for AP {mac or name}: {data}") if isinstance(data, dict) and 'response' in data and 'count' in data['response']: return data['response']['count'] @@ -717,8 +604,8 @@ def fetch_clients_count_for_ap(auth_manager, mac=None, name=None, site_id=None, else: logger.warning(f"Unexpected /clients/count response for AP {mac or name}: {data}") return None - except HTTPError as e: - if hasattr(e, 'code') and e.code == 429: + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: logger.warning(f"429 Too Many Requests for AP {mac or name}, backing off for {current_delay}s") time.sleep(current_delay) current_delay *= backoff_factor @@ -736,13 +623,15 @@ def fetch_clients_count_by_site(auth_manager, site_id, retries=3): """Fetch client count for a specific site from the DNA Center API.""" token = auth_manager.get_token() auth_headers = {'x-auth-token': token} - url = f"{BASE_URL}/dna/data/api/v1/clients/count?siteId={site_id}" + url = f"{BASE_URL}/dna/data/api/v1/clients/count" + params = {"siteId": site_id} attempt = 0 while attempt < retries: try: - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, params=params, timeout=60) + response.raise_for_status() + data = response.json() return data.get('response', {}) except Exception as e: attempt += 1 @@ -760,9 +649,10 @@ def fetch_site_health_summaries(auth_manager, retries=3): attempt = 0 while attempt < retries: try: - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, timeout=60) + response.raise_for_status() + data = response.json() return data.get('response', []) except Exception as e: attempt += 1 @@ -776,13 +666,15 @@ def fetch_network_devices(auth_manager, retries=3): """Fetch network devices (APs) from the DNA Center API.""" token = auth_manager.get_token() auth_headers = {'x-auth-token': token} - url = f"{BASE_URL}/dna/data/api/v1/networkDevices?role=ACCESS" + url = f"{BASE_URL}/dna/data/api/v1/networkDevices" + params = {"role": "ACCESS"} attempt = 0 while attempt < retries: try: - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, params=params, timeout=60) + response.raise_for_status() + data = response.json() return data.get('response', []) except Exception as e: attempt += 1 @@ -932,7 +824,7 @@ def fetch_ap_client_data_with_fallback(auth_manager, site_id=None, retries=3): if location_invalid and merged['name']: building, floor, ap_number = parse_ap_name_for_location(merged['name']) if building and floor and ap_number: - merged['location'] = f"Global/Keele Campus/{building}/{floor}/{ap_number}" + merged['location'] = f"{LOCATION_HIERARCHY_PREFIX}/{building}/{floor}/{ap_number}" source_map['location'] = 'ap_name_parsing' # --- Check for missing required fields --- missing_required = [f for f in required_fields if not merged.get(f)] @@ -970,15 +862,17 @@ def fetch_ap_config_summary(auth_manager, retries=3, key=None): """ token = auth_manager.get_token() auth_headers = {'x-auth-token': token} - url = f"{BASE_URL}/dna/intent/api/v1/wireless/accesspoint-configuration/summary?limit=500" + url = f"{BASE_URL}/dna/intent/api/v1/wireless/accesspoint-configuration/summary" + params = {"limit": 500} if key: - url += f"&key={key}" + params["key"] = key attempt = 0 while attempt < retries: try: - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, params=params, timeout=60) + response.raise_for_status() + data = response.json() if isinstance(data, dict): return data.get('response', []) elif isinstance(data, list): @@ -998,13 +892,15 @@ def fetch_device_health(auth_manager, retries=3): """Fetch device health from /device-health. Handles both dict and list responses.""" token = auth_manager.get_token() auth_headers = {'x-auth-token': token} - url = f"{BASE_URL}/dna/intent/api/v1/device-health?deviceRole=AP&limit=500" + url = f"{BASE_URL}/dna/intent/api/v1/device-health" + params = {"deviceRole": "AP", "limit": 500} attempt = 0 while attempt < retries: try: - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, params=params, timeout=60) + response.raise_for_status() + data = response.json() if isinstance(data, dict): return data.get('response', []) elif isinstance(data, list): @@ -1028,9 +924,10 @@ def fetch_all_clients_count(auth_manager, retries=3): attempt = 0 while attempt < retries: try: - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, timeout=60) + response.raise_for_status() + data = response.json() if isinstance(data, dict): return [data.get('response', data)] if data else [] elif isinstance(data, list): @@ -1050,13 +947,15 @@ def fetch_site_health(auth_manager, retries=3): """Fetch site health from /site-health. Handles both dict and list responses.""" token = auth_manager.get_token() auth_headers = {'x-auth-token': token} - url = f"{BASE_URL}/dna/intent/api/v1/site-health?limit=50" + url = f"{BASE_URL}/dna/intent/api/v1/site-health" + params = {"limit": 50} attempt = 0 while attempt < retries: try: - req = Request(url, headers=auth_headers) - with urlopen(req, context=ssl_context, timeout=60) as response: - data = json.load(response) + with httpx.Client(verify=False) as client: + response = client.get(url, headers=auth_headers, params=params, timeout=60) + response.raise_for_status() + data = response.json() if isinstance(data, dict): return data.get('response', []) elif isinstance(data, list): @@ -1086,4 +985,4 @@ def update_ap_data_task_with_fallback(auth_manager): # Fetch AP data, passing client data for fallback ap_data = fetch_ap_data(auth_manager, clients_data=clients_data) logger.info(f"Fetched {len(ap_data)} AP records after applying fallback location logic") - # ... continue with processing ap_data as before ... \ No newline at end of file + # ... continue with processing ap_data as before ... diff --git a/ap_monitor/app/main.py b/ap_monitor/app/main.py index 2706d2f..b6d9d02 100644 --- a/ap_monitor/app/main.py +++ b/ap_monitor/app/main.py @@ -1,50 +1,42 @@ import logging from datetime import datetime, timedelta, timezone from typing import List, Optional, Tuple -from fastapi import FastAPI, Depends, HTTPException, Query +from fastapi import FastAPI, Depends, HTTPException, Query, WebSocket from contextlib import asynccontextmanager from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy import func, and_ from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.date import DateTrigger -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base import os from zoneinfo import ZoneInfo # Python 3.9+ import time +import asyncio from urllib.error import HTTPError from ap_monitor.app.db import ( - get_wireless_db, - get_apclient_db, - get_wireless_db_session, - get_apclient_db_session, + get_db_dep, init_db, - WirelessBase, - APClientBase, - get_apclient_db_dep, - get_wireless_db_dep + get_db_session ) from ap_monitor.app.models import ( - Campus, Building, ClientCount, - ApBuilding, Floor, Room, AccessPoint, RadioType, ClientCountAP + Campus, Building, Floor, Room, AccessPoint, RadioType, ClientCount ) from ap_monitor.app.dna_api import ( AuthManager, fetch_client_counts, fetch_ap_data, radio_id_map, fetch_ap_client_data_with_fallback ) from ap_monitor.app.utils import setup_logging, calculate_next_run_time +from ap_monitor.app.security import get_api_key +from ap_monitor.app.cache import timed_cache from ap_monitor.app.schemas import ( - CampusCreate, CampusResponse, - BuildingCreate, BuildingResponse, - ClientCountCreate, ClientCountResponse, - ApBuildingCreate, ApBuildingResponse, - FloorCreate, FloorResponse, - RoomCreate, RoomResponse, - AccessPointCreate, AccessPointResponse, - RadioTypeCreate, RadioTypeResponse, - ClientCountAPCreate, ClientCountAPResponse + CampusCreate, Campus as CampusResponse, + BuildingCreate, Building as BuildingResponse, + ClientCountCreate, ClientCount as ClientCountResponse, + FloorCreate, Floor as FloorResponse, + RoomCreate, Room as RoomResponse, + AccessPointCreate, AccessPoint as AccessPointResponse, + RadioTypeCreate, RadioType as RadioTypeResponse ) from .diagnostics import ( analyze_zero_count_buildings, @@ -53,7 +45,7 @@ is_diagnostics_enabled, get_incomplete_diagnostics ) -from ap_monitor.app.mapping import parse_ap_name_for_location, normalize_building_name +from ap_monitor.app.mapping import normalize_building_name, parse_ap_name_for_location # --- API Health Tracking --- from collections import deque @@ -142,35 +134,14 @@ async def lifespan(app: FastAPI): init_db() logger.info("Database initialized successfully") - # Initialize radio table if empty - with get_wireless_db() as db: - if db.query(RadioType).count() == 0: - logger.info("Initializing radio data...") - radios = [ - RadioType(radioid=1, radioname="radio0"), - RadioType(radioid=2, radioname="radio1"), - RadioType(radioid=3, radioname="radio2") - ] - db.add_all(radios) - db.commit() - logger.info("Radio data initialized successfully") - # Schedule tasks next_run = calculate_next_run_time() logger.info(f"First scheduled run at: {next_run.strftime('%Y-%m-%d %H:%M:%S %Z')} (Server time: {datetime.now(TORONTO_TZ).strftime('%Y-%m-%d %H:%M:%S %Z')})") # Clean up any existing jobs - cleanup_job("update_ap_data_task") cleanup_job("update_client_count_task") # Add new jobs - scheduler.add_job( - func=update_ap_data_task, - trigger=DateTrigger(run_date=next_run), - id="update_ap_data_task", - name="Update AP Data Task", - replace_existing=True, - ) scheduler.add_job( func=update_client_count_task, trigger=DateTrigger(run_date=next_run), @@ -206,513 +177,213 @@ async def lifespan(app: FastAPI): lifespan=lifespan ) -def parse_location(location: str) -> tuple: - """ - Parse location string to extract building and floor names. - Returns (building_name, floor_name) tuple. - """ - invalid_set = {None, '', 'invalid', 'none', 'unknown'} - if not location or not isinstance(location, str): - logger.warning(f"Skipping device with empty or invalid location: {location}") - return None, None - - # Reject locations with leading or trailing slashes - if location.startswith('/') or location.endswith('/'): - logger.warning(f"Skipping device with leading or trailing slash in location: {location}") - return None, None - - # Remove leading/trailing slashes and split - location = location.strip('/') - parts = [p.strip() for p in location.split('/') if p.strip()] - - # Validate minimum required parts - if len(parts) < 2: - logger.warning(f"Skipping device with insufficient location parts: {location}") - return None, None - - # Global/Keele Campus// - if len(parts) >= 2 and parts[0] == "Global" and parts[1] == "Keele Campus": - if len(parts) < 4: - logger.warning(f"Skipping device with invalid Global/Keele Campus location format: {location}") - return None, None - building = parts[2] - floor = parts[3] - # / (only if not Global/Keele Campus) - elif len(parts) >= 2: - building = parts[0] - floor = parts[1] + + +def _get_or_create(db: Session, model, defaults=None, **kwargs): + """Get or create a database record.""" + instance = db.query(model).filter_by(**kwargs).first() + if instance: + return instance, False else: - logger.warning(f"Skipping device with invalid location format: {location}") - return None, None - - # Validate building and floor names - if not building or str(building).strip().lower() in invalid_set: - logger.warning(f"Skipping device with invalid building name: {building}") - return None, None - if not floor or str(floor).strip().lower() in invalid_set: - logger.warning(f"Skipping device with invalid floor name: {floor}") - return None, None - - # Additional validation for specific cases - if building.lower() == 'invalid' or floor.lower() == 'invalid': - logger.warning(f"Skipping device with explicitly invalid building/floor: {location}") - return None, None - - # Validate that building and floor are not empty strings after stripping - if not building.strip() or not floor.strip(): - logger.warning(f"Skipping device with empty building or floor after stripping: {location}") - return None, None - - return building, floor - -def update_ap_data_task(db: Session = None, auth_manager_obj=None, fetch_ap_data_func=None, retries=0): - """Background task to update AP data in the database, with retry on maintenance errors.""" - from ap_monitor.app.db import get_apclient_db_session - global MAINTENANCE_UNTIL - auth_manager_obj = auth_manager_obj or auth_manager - fetch_ap_data_func = fetch_ap_data_func or fetch_ap_data - close_db = False - if db is None: - db = get_apclient_db_session() - close_db = True + params = {**kwargs, **(defaults or {})} + instance = model(**params) + db.add(instance) + db.flush() # Flush to get the ID + return instance, True + + +def _get_or_create_building(db: Session, canonical_building_name: str) -> Building: try: - # Check for global maintenance window - now = datetime.now(timezone.utc) - if MAINTENANCE_UNTIL and now < MAINTENANCE_UNTIL: - logger.warning(f"In maintenance window until {MAINTENANCE_UNTIL.isoformat()}, skipping update_ap_data_task.") - next_run = MAINTENANCE_UNTIL - reschedule_job("update_ap_data_task", update_ap_data_task, next_run) - return - logger.info(f"Running scheduled task: update_ap_data_task (retry {retries})") - logger.debug(f"Database session being used: {db}") - rounded_unix_timestamp = int(now.timestamp() * 1000) - aps = fetch_ap_data_func(auth_manager_obj, rounded_unix_timestamp) - logger.info(f"Fetched {len(aps)} APs from DNAC API") - - # Process AP data - for ap in aps: - ap_name = ap.get('name') - - # Try different location fields in order of preference - location = ap.get('location') - if not location or len(location.split('/')) < 2: - location = ap.get('snmpLocation') - if not location or len(location.split('/')) < 2: - location = ap.get('locationName') - - building_name, floor_name = parse_location(location) - if not building_name or not floor_name: - continue - - # Building - building = db.query(ApBuilding).filter_by(buildingname=building_name).first() - if not building: - building = ApBuilding(buildingname=building_name) - db.add(building) - db.flush() - - # Floor - floor = db.query(Floor).filter_by(floorname=floor_name, buildingid=building.buildingid).first() - if not floor: - floor = Floor(floorname=floor_name, buildingid=building.buildingid) - db.add(floor) - db.flush() + campus_id = int(os.getenv("DEFAULT_CAMPUS_ID", 1)) + except (ValueError, TypeError): + campus_id = 1 + building, created = _get_or_create( + db, + Building, + name=canonical_building_name, + defaults={'campus_id': campus_id} + ) + if created: + logger.info(f"Created new building: {canonical_building_name}") + return building + +def _get_or_create_floor(db: Session, floor_name: str, building_id: int) -> Floor: + floor, created = _get_or_create( + db, + Floor, + name=floor_name, + building_id=building_id + ) + if created: + logger.info(f"Created new floor: {floor_name} in building ID {building_id}") + return floor + +def _get_or_create_access_point(db: Session, ap_data: dict, building_id: int, floor_id: int) -> AccessPoint: + mac_address = ap_data.get('macAddress') + is_active = ap_data.get('raw', {}).get('reachabilityStatus', ap_data.get('raw', {}).get('reachabilityHealth')) == "UP" + + ap_record, created = _get_or_create( + db, + AccessPoint, + mac_address=mac_address, + defaults={ + 'name': ap_data.get('name'), + 'ip_address': ap_data.get('raw', {}).get('managementIpAddress', ap_data.get('raw', {}).get('ipAddress')), + 'model': ap_data.get('raw', {}).get('platformId', ap_data.get('raw', {}).get('model')), + 'is_active': is_active, + 'floor_id': floor_id, + 'building_id': building_id + } + ) - # Access Point - mac_address = ap.get('macAddress') - ap_record = db.query(AccessPoint).filter_by(macaddress=mac_address).first() - is_active = ap.get('reachabilityHealth') == "UP" - - if not ap_record: - logger.debug(f"Creating new AccessPoint: {ap.get('name')} with MAC: {mac_address}") - ap_record = AccessPoint( - apname=ap.get('name'), - macaddress=mac_address, - ipaddress=ap.get('ipAddress'), - modelname=ap.get('model'), - isactive=is_active, - floorid=floor.floorid, - buildingid=building.buildingid - ) - db.add(ap_record) + if created: + logger.info(f"Created new access point: {ap_record.name}") + else: + # Update existing record + ap_record.is_active = is_active + ap_record.floor_id = floor_id + ap_record.building_id = building_id + logger.debug(f"Updated existing access point: {ap_record.name}") + + return ap_record + + +def _create_client_count(db: Session, ap_record_id: int, count_data: dict, timestamp: datetime): + if isinstance(count_data, dict): + for radio_name, radio_count in count_data.items(): + radio_type = db.query(RadioType).filter_by(name=radio_name).first() + if not radio_type: + radio_type = RadioType(name=radio_name) + db.add(radio_type) db.flush() - else: - logger.debug(f"Updating existing AccessPoint: {ap.get('name')}") - ap_record.isactive = is_active - ap_record.floorid = floor.floorid - ap_record.buildingid = building.buildingid - - # Create client count records for each radio - client_counts = ap.get('clientCount', {}) - for radio_name, count in client_counts.items(): - radio = db.query(RadioType).filter_by(radioname=radio_name).first() - if not radio: - logger.warning(f"Unexpected radio key: {radio_name}") - continue - - # Check for existing record - cc = db.query(ClientCountAP).filter_by( - apid=ap_record.apid, - radioid=radio.radioid, - timestamp=now - ).first() - - if cc: - cc.clientcount = count - else: - cc = ClientCountAP( - apid=ap_record.apid, - radioid=radio.radioid, - clientcount=count, - timestamp=now - ) - db.add(cc) - - db.commit() - logger.info("AP data updated successfully in apclientcount DB") - - except HTTPError as e: - if e.code in (404, 500): - # Set global maintenance window for 1 hour - MAINTENANCE_UNTIL = datetime.now(timezone.utc) + timedelta(hours=1) - logger.error(f"Maintenance window or server error detected (HTTP {e.code}). Entering maintenance until {MAINTENANCE_UNTIL.isoformat()}.") - log_api_error("HTTPError", f"Maintenance window or server error (HTTP {e.code}): {e}") - next_run = MAINTENANCE_UNTIL - reschedule_job("update_ap_data_task", update_ap_data_task, next_run) - return - if db: - db.rollback() - logger.error(f"Error updating AP data: {e}") - log_api_error("HTTPError", e) - # Do not re-raise, just log and continue to reschedule - except Exception as e: - if db: - db.rollback() - logger.error(f"Error updating AP data: {e}") - log_api_error("Exception", e) - raise # Re-raise to trigger scheduler's error handling - finally: - if close_db and db: - db.close() - # Only reschedule if not in maintenance - if not (MAINTENANCE_UNTIL and datetime.now(timezone.utc) < MAINTENANCE_UNTIL): - next_run = calculate_next_run_time() - reschedule_job("update_ap_data_task", update_ap_data_task, next_run) + client_count = ClientCount( + count=radio_count, + timestamp=timestamp, + access_point_id=ap_record_id, + radio_type_id=radio_type.id + ) + db.add(client_count) + else: + client_count = ClientCount( + count=count_data, + timestamp=timestamp, + access_point_id=ap_record_id + ) + db.add(client_count) + + +def process_ap_data(db: Session, ap_data: dict, timestamp: datetime): + """Process a single AP data dictionary and update the database.""" + required_fields = ['macAddress', 'name', 'location', 'clientCount'] + missing_required = [f for f in required_fields if not ap_data.get(f)] + if missing_required: + logger.warning(f"Skipping AP {ap_data.get('name')} (MAC: {ap_data.get('macAddress')}) due to missing required fields: {missing_required}") + return + + ap_name = ap_data.get('name') + building_name, floor_name, _ = parse_ap_name_for_location(ap_name) + if not building_name or not floor_name: + logger.warning(f"Skipping AP {ap_name} due to invalid location from AP name") + return + + canonical_building_name = normalize_building_name(building_name) + if not canonical_building_name: + logger.warning(f"Skipping AP {ap_name} due to unmapped building name: {building_name}") + return -def update_client_count_task(db: Session = None, auth_manager_obj=None, fetch_client_counts_func=None, fetch_ap_data_func=None, wireless_db=None, retries=0): + try: + building = _get_or_create_building(db, canonical_building_name) + floor = _get_or_create_floor(db, floor_name, building.id) + ap_record = _get_or_create_access_point(db, ap_data, building.id, floor.id) + _create_client_count(db, ap_record.id, ap_data.get('clientCount', 0), timestamp) + logger.debug(f"Successfully processed AP {ap_name}") + except SQLAlchemyError as e: + logger.error(f"Database error while processing AP {ap_name}: {e}") + db.rollback() + raise + + +def update_client_count_task(db: Session = None, auth_manager_obj=None, fetch_ap_data_func=None, retries=0): """Update client count data from DNA Center API, with retry on maintenance errors.""" global MAINTENANCE_UNTIL close_db = False - close_wireless_db = False try: - # Check for global maintenance window now = datetime.now(timezone.utc) if MAINTENANCE_UNTIL and now < MAINTENANCE_UNTIL: logger.warning(f"In maintenance window until {MAINTENANCE_UNTIL.isoformat()}, skipping update_client_count_task.") - next_run = MAINTENANCE_UNTIL - reschedule_job("update_client_count_task", update_client_count_task, next_run) + reschedule_job("update_client_count_task", update_client_count_task, MAINTENANCE_UNTIL) return - # Get database sessions if not provided + if db is None: - db = get_apclient_db_session() + db = get_db_session() close_db = True - if wireless_db is None: - wireless_db = get_wireless_db_session() - close_wireless_db = True - rounded_unix_timestamp = int(now.timestamp()) + auth_manager_obj = auth_manager_obj or auth_manager - # Use new fallback logic for fetching AP/client data ap_data_list = fetch_ap_client_data_with_fallback(auth_manager_obj) - if isinstance(ap_data_list, dict): - logger.error("fetch_ap_client_data_with_fallback returned a dict (likely API error or rate limit): %r", ap_data_list) - log_api_error("APIError", ap_data_list) - return + if not isinstance(ap_data_list, list): - logger.error("fetch_ap_client_data_with_fallback did not return a list! Got: %s", type(ap_data_list)) - log_api_error("APIError", f"Type: {type(ap_data_list)} Value: {ap_data_list}") + error_message = f"Expected a list from fetch_ap_client_data_with_fallback, but got {type(ap_data_list).__name__}" + logger.error(error_message) + log_api_error("APIError", error_message) return + if not ap_data_list: - logger.error("No AP/client data available from any endpoint. Skipping update.") + logger.warning("No AP/client data available from any endpoint. Skipping update.") log_api_error("APIError", "No AP/client data available from any endpoint.") return - building_totals = {} - wireless_buildings = {b.building_name.lower(): b for b in wireless_db.query(Building).all()} - # --- Process data based on required fields --- - incomplete_aps = [] # Track APs with missing non-critical fields - for ap in ap_data_list: - # Required fields for processing - required_fields = ['macAddress', 'name', 'location', 'clientCount'] - missing_required = [f for f in required_fields if not ap.get(f)] - if missing_required: - logger.warning(f"Skipping AP {ap.get('name')} (MAC: {ap.get('macAddress')}) due to missing required fields: {missing_required}") - continue - # Check for missing non-critical fields - non_critical_fields = ['model', 'status', 'ipAddress'] - missing_non_critical = [f for f in non_critical_fields if not ap.get(f)] - ap_status = ap.get('status', 'ok') - if missing_non_critical: - ap_status = 'incomplete' - logger.info(f"AP {ap.get('name')} (MAC: {ap.get('macAddress')}) is incomplete, missing: {missing_non_critical}") - incomplete_aps.append({**ap, 'missing_fields': missing_non_critical}) - ap_name = ap.get('name') - location = ap.get('location') - building_name, floor_name = parse_location(location) - if not building_name or not floor_name: - logger.warning(f"Skipping AP {ap_name} due to invalid location: {location}") - continue - # --- Normalize building name to canonical DB name --- - canonical_building_name = normalize_building_name(building_name) - if not canonical_building_name: - logger.warning(f"Skipping AP {ap_name} due to unmapped building name: {building_name}") - continue - building = db.query(ApBuilding).filter_by(buildingname=canonical_building_name).first() - if not building: - building = ApBuilding(buildingname=canonical_building_name) - db.add(building) - db.flush() - floor = db.query(Floor).filter_by(floorname=floor_name, buildingid=building.buildingid).first() - if not floor: - floor = Floor(floorname=floor_name, buildingid=building.buildingid) - db.add(floor) - db.flush() - mac_address = ap.get('macAddress') - ap_record = db.query(AccessPoint).filter_by(macaddress=mac_address).first() - is_active = ap.get('raw', {}).get('reachabilityStatus', ap.get('raw', {}).get('reachabilityHealth')) == "UP" - if not ap_record: - ap_record = AccessPoint( - apname=ap_name, - macaddress=mac_address, - ipaddress=ap.get('raw', {}).get('managementIpAddress', ap.get('raw', {}).get('ipAddress')), - modelname=ap.get('raw', {}).get('platformId', ap.get('raw', {}).get('model')), - isactive=is_active, - floorid=floor.floorid, - buildingid=building.buildingid - ) - db.add(ap_record) - db.flush() - else: - ap_record.isactive = is_active - ap_record.floorid = floor.floorid - ap_record.buildingid = building.buildingid - count = ap.get('clientCount', 0) - if isinstance(count, dict): - count = sum(count.values()) - building_totals.setdefault(canonical_building_name, 0) - building_totals[canonical_building_name] += count or 0 - # Insert/update ClientCountAP for radio0 (fallback) - radio = db.query(RadioType).filter_by(radioname='radio0').first() - if radio: - cc = db.query(ClientCountAP).filter_by( - apid=ap_record.apid, - radioid=radio.radioid, - timestamp=now - ).first() - if cc: - cc.clientcount = count or 0 - else: - cc = ClientCountAP( - apid=ap_record.apid, - radioid=radio.radioid, - clientcount=count or 0, - timestamp=now - ) - db.add(cc) - # --- Update wireless_count DB with building totals --- - for building_name, total_clients in building_totals.items(): - canonical_building_name = normalize_building_name(building_name) - if not canonical_building_name: - logger.warning(f"Skipping update for unmapped building name: {building_name}") - continue - building = wireless_buildings.get(canonical_building_name.lower()) - if building: - client_count = ClientCount( - building_id=building.building_id, - client_count=total_clients, - time_inserted=now - ) - wireless_db.add(client_count) - logger.info(f"Updated client count for building {canonical_building_name}: {total_clients}") - else: - logger.warning(f"Building {canonical_building_name} not found in wireless_count database") - for building_name, building in wireless_buildings.items(): - if building_name.lower() not in {k.lower() for k in building_totals}: - client_count = ClientCount( - building_id=building.building_id, - client_count=0, - time_inserted=now - ) - wireless_db.add(client_count) - logger.info(f"Created zero count record for building {building_name}") + + logger.info(f"Fetched {len(ap_data_list)} APs from API. Processing...") + for ap_data in ap_data_list: + process_ap_data(db, ap_data, now) + db.commit() - wireless_db.commit() - logger.info("Client count data updated successfully in both databases") - if incomplete_aps: - logger.warning(f"{len(incomplete_aps)} APs were incomplete and may need further data recovery. See logs for details.") + logger.info("Client count data updated successfully") + except HTTPError as e: + if db: + db.rollback() if e.code in (404, 500): - # Set global maintenance window for 1 hour MAINTENANCE_UNTIL = datetime.now(timezone.utc) + timedelta(hours=1) logger.error(f"Maintenance window or server error detected (HTTP {e.code}). Entering maintenance until {MAINTENANCE_UNTIL.isoformat()}.") log_api_error("HTTPError", f"Maintenance window or server error (HTTP {e.code}): {e}") - next_run = MAINTENANCE_UNTIL - reschedule_job("update_client_count_task", update_client_count_task, next_run) + reschedule_job("update_client_count_task", update_client_count_task, MAINTENANCE_UNTIL) return + logger.error(f"HTTP error updating client count data: {e}") + log_api_error("HTTPError", str(e)) + except SQLAlchemyError as e: + logger.error(f"Database error during client count update: {e}") if db: db.rollback() - if wireless_db: - wireless_db.rollback() - logger.error(f"Error updating client count data: {str(e)}") - log_api_error("HTTPError", e) - # Do not re-raise, just log and continue to reschedule except Exception as e: + logger.error(f"An unexpected error occurred during client count update: {e}", exc_info=True) + log_api_error("Exception", str(e)) if db: db.rollback() - if wireless_db: - wireless_db.rollback() - logger.error(f"Error updating client count data: {str(e)}") - log_api_error("Exception", e) - raise finally: if close_db and db: db.close() - if close_wireless_db and wireless_db: - wireless_db.close() - # Only reschedule if not in maintenance if not (MAINTENANCE_UNTIL and datetime.now(timezone.utc) < MAINTENANCE_UNTIL): next_run = calculate_next_run_time() reschedule_job("update_client_count_task", update_client_count_task, next_run) -def insert_apclientcount_data(device_info_list, timestamp, session=None): - """Insert AP client count data into the database.""" - if session is None: - session = next(get_apclient_db()) - - try: - for device_info in device_info_list: - ap_name = device_info.get('name') - - # Try different location fields in order of preference - location = device_info.get('location') - if not location or len(location.split('/')) < 2: - location = device_info.get('snmpLocation') - if not location or len(location.split('/')) < 2: - location = device_info.get('locationName') - - # Parse and validate location - building_name, floor_name = parse_location(location) - if building_name is None or floor_name is None: - logger.warning(f"Skipping device with invalid location: {location}") - continue - - # Additional validation before proceeding - if not building_name.strip() or not floor_name.strip(): - logger.warning(f"Skipping device with empty building or floor after validation: {location}") - continue - - # Get or create building - building = session.query(ApBuilding).filter_by(buildingname=building_name).first() - if not building: - building = ApBuilding(buildingname=building_name) - session.add(building) - session.flush() - - # Get or create floor - floor = session.query(Floor).filter_by(buildingid=building.buildingid, floorname=floor_name).first() - if not floor: - floor = Floor(buildingid=building.buildingid, floorname=floor_name) - session.add(floor) - session.flush() - - # Get or create room (optional) - room_name = "Unknown Room" # Default room name - if location and len(location.split('/')) > 4: - room_name = location.split('/')[4].strip() - - room = session.query(Room).filter_by(floorid=floor.floorid, roomname=room_name).first() - if not room: - room = Room(floorid=floor.floorid, roomname=room_name) - session.add(room) - session.flush() - - # Get or create access point - mac_address = device_info["macAddress"] - ap = session.query(AccessPoint).filter_by(macaddress=mac_address).first() - - if not ap: - logger.debug(f"Creating new AccessPoint: {ap_name} with MAC: {mac_address}") - ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname=ap_name, - macaddress=mac_address, - ipaddress=device_info["ipAddress"], - modelname=device_info["model"], - isactive=device_info["reachabilityHealth"] == "UP" - ) - session.add(ap) - session.flush() - else: - logger.debug(f"Updating existing AccessPoint: {ap_name}") - ap.apname = ap_name - ap.ipaddress = device_info["ipAddress"] - ap.modelname = device_info["model"] - ap.isactive = device_info["reachabilityHealth"] == "UP" - ap.buildingid = building.buildingid - ap.floorid = floor.floorid - ap.roomid = room.roomid - session.flush() - - # Update client counts - client_counts = device_info.get("clientCount", {}) - for radio_name, count in client_counts.items(): - radio = session.query(RadioType).filter_by(radioname=radio_name).first() - if not radio: - logger.warning(f"Skipping unexpected radio key: {radio_name}") - continue - - client_count = session.query(ClientCountAP).filter_by( - apid=ap.apid, - radioid=radio.radioid, - timestamp=timestamp - ).first() - - if client_count: - client_count.clientcount = count - else: - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=count, - timestamp=timestamp - ) - session.add(client_count) - - session.commit() - logger.info("AP data updated successfully in apclientcount DB") - - except Exception as e: - logger.error(f"Error updating client count data: {str(e)}") - session.rollback() - raise @app.get("/aps", response_model=List[dict], tags=["Access Points"]) -def get_aps(db: Session = Depends(get_wireless_db_dep)): +def get_aps(db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): """Get all access points from the database.""" try: logger.info("Fetching AP data from the database") aps = db.query(AccessPoint).all() logger.info(f"Retrieved {len(aps)} AP records") return [{ - "apid": ap.apid, - "apname": ap.apname, - "macaddress": str(ap.macaddress), - "ipaddress": str(ap.ipaddress) if ap.ipaddress else None, - "modelname": ap.modelname, - "isactive": ap.isactive, - "buildingid": ap.buildingid, - "floorid": ap.floorid, - "roomid": ap.roomid + "id": ap.id, + "name": ap.name, + "mac_address": str(ap.mac_address), + "ip_address": str(ap.ip_address) if ap.ip_address else None, + "model": ap.model, + "is_active": ap.is_active, + "building_id": ap.building_id, + "floor_id": ap.floor_id, + "room_id": ap.room_id } for ap in aps] except SQLAlchemyError as e: logger.error(f"Database error in /aps: {e}") @@ -727,26 +398,27 @@ def get_client_counts( radio_id: Optional[int] = None, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, - db: Session = Depends(get_apclient_db_dep) + db: Session = Depends(get_db_dep), + api_key: str = Depends(get_api_key) ): - """Get AP client count data with optional filters (AP client DB).""" + """Get AP client count data with optional filters.""" try: - query = db.query(ClientCountAP) + query = db.query(ClientCount) if ap_id: - query = query.filter(ClientCountAP.apid == ap_id) + query = query.filter(ClientCount.access_point_id == ap_id) if radio_id: - query = query.filter(ClientCountAP.radioid == radio_id) + query = query.filter(ClientCount.radio_type_id == radio_id) if start_time: - query = query.filter(ClientCountAP.timestamp >= start_time) + query = query.filter(ClientCount.timestamp >= start_time) if end_time: - query = query.filter(ClientCountAP.timestamp <= end_time) + query = query.filter(ClientCount.timestamp <= end_time) counts = query.all() return [ { - "count_id": c.countid, - "apid": c.apid, - "radioid": c.radioid, - "client_count": c.clientcount, + "id": c.id, + "access_point_id": c.access_point_id, + "radio_type_id": c.radio_type_id, + "count": c.count, "timestamp": c.timestamp.isoformat() if c.timestamp else None } for c in counts @@ -756,15 +428,18 @@ def get_client_counts( raise HTTPException(status_code=500, detail=str(e)) @app.get("/buildings", response_model=List[dict], tags=["Buildings"]) -def get_buildings(db: Session = Depends(get_wireless_db_dep)): +@timed_cache(ttl=300) +async def get_buildings(db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): """Get list of buildings with their details.""" try: logger.info("Fetching list of buildings") buildings = db.query(Building).all() return [{ - "building_id": b.building_id, - "building_name": b.building_name, - # Add more fields as needed + "id": b.id, + "name": b.name, + "campus_id": b.campus_id, + "latitude": b.latitude, + "longitude": b.longitude } for b in buildings] except SQLAlchemyError as e: logger.error(f"Database error in /buildings: {e}") @@ -773,16 +448,54 @@ def get_buildings(db: Session = Depends(get_wireless_db_dep)): logger.error(f"Unexpected error in /buildings: {e}") raise HTTPException(status_code=500, detail="Internal server error") + +@app.get("/buildings/{building_id}/client-count", tags=["Buildings"]) +def get_building_client_count(building_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): + """Get the total client count for a specific building.""" + try: + logger.info(f"Fetching client count for building ID {building_id}") + total_count = db.query(func.sum(ClientCount.count)) \ + .join(AccessPoint) \ + .filter(AccessPoint.building_id == building_id) \ + .scalar() + return {"building_id": building_id, "total_client_count": total_count or 0} + except SQLAlchemyError as e: + logger.error(f"Database error in /buildings/{{building_id}}/client-count: {e}") + raise HTTPException(status_code=500, detail="Database error") + except Exception as e: + logger.error(f"Unexpected error in /buildings/{{building_id}}/client-count: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/campus/{campus_id}/client-count", tags=["Campus"]) +def get_campus_client_count(campus_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): + """Get the total client count for a specific campus.""" + try: + logger.info(f"Fetching client count for campus ID {campus_id}") + total_count = db.query(func.sum(ClientCount.count)) \ + .join(AccessPoint) \ + .join(Building) \ + .filter(Building.campus_id == campus_id) \ + .scalar() + return {"campus_id": campus_id, "total_client_count": total_count or 0} + except SQLAlchemyError as e: + logger.error(f"Database error in /campus/{{campus_id}}/client-count: {e}") + raise HTTPException(status_code=500, detail="Database error") + except Exception as e: + logger.error(f"Unexpected error in /campus/{{campus_id}}/client-count: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @app.get("/floors/{building_id}", response_model=List[dict], tags=["Floors"]) -def get_floors(building_id: int, db: Session = Depends(get_wireless_db_dep)): +@timed_cache(ttl=300) +async def get_floors(building_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): """Get floors for a specific building.""" try: - floors = db.query(Floor).filter_by(buildingid=building_id).all() + floors = db.query(Floor).filter_by(building_id=building_id).all() return [{ - "floorid": f.floorid, - "floorname": f.floorname, - "room_count": len(f.rooms), - "ap_count": len(f.accesspoints) + "id": f.id, + "name": f.name, + "building_id": f.building_id } for f in floors] except SQLAlchemyError as e: logger.error(f"Database error in /floors: {e}") @@ -791,15 +504,35 @@ def get_floors(building_id: int, db: Session = Depends(get_wireless_db_dep)): logger.error(f"Unexpected error in /floors: {e}") raise HTTPException(status_code=500, detail="Internal server error") + +@app.get("/floors/{floor_id}/client-count", tags=["Floors"]) +def get_floor_client_count(floor_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): + """Get the total client count for a specific floor.""" + try: + logger.info(f"Fetching client count for floor ID {floor_id}") + total_count = db.query(func.sum(ClientCount.count)) \ + .join(AccessPoint) \ + .filter(AccessPoint.floor_id == floor_id) \ + .scalar() + return {"floor_id": floor_id, "total_client_count": total_count or 0} + except SQLAlchemyError as e: + logger.error(f"Database error in /floors/{{floor_id}}/client-count: {e}") + raise HTTPException(status_code=500, detail="Database error") + except Exception as e: + logger.error(f"Unexpected error in /floors/{{floor_id}}/client-count: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @app.get("/rooms/{floor_id}", response_model=List[dict], tags=["Rooms"]) -def get_rooms(floor_id: int, db: Session = Depends(get_wireless_db_dep)): +@timed_cache(ttl=300) +async def get_rooms(floor_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): """Get rooms for a specific floor.""" try: - rooms = db.query(Room).filter_by(floorid=floor_id).all() + rooms = db.query(Room).filter_by(floor_id=floor_id).all() return [{ - "roomid": r.roomid, - "roomname": r.roomname, - "ap_count": len(r.accesspoints) + "id": r.id, + "name": r.name, + "floor_id": r.floor_id } for r in rooms] except SQLAlchemyError as e: logger.error(f"Database error in /rooms: {e}") @@ -808,14 +541,34 @@ def get_rooms(floor_id: int, db: Session = Depends(get_wireless_db_dep)): logger.error(f"Unexpected error in /rooms: {e}") raise HTTPException(status_code=500, detail="Internal server error") + +@app.get("/rooms/{room_id}/client-count", tags=["Rooms"]) +def get_room_client_count(room_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): + """Get the total client count for a specific room.""" + try: + logger.info(f"Fetching client count for room ID {room_id}") + total_count = db.query(func.sum(ClientCount.count)) \ + .join(AccessPoint) \ + .filter(AccessPoint.room_id == room_id) \ + .scalar() + return {"room_id": room_id, "total_client_count": total_count or 0} + except SQLAlchemyError as e: + logger.error(f"Database error in /rooms/{{room_id}}/client-count: {e}") + raise HTTPException(status_code=500, detail="Database error") + except Exception as e: + logger.error(f"Unexpected error in /rooms/{{room_id}}/client-count: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @app.get("/radio-types", response_model=List[dict], tags=["Radio Types"]) -def get_radio_types(db: Session = Depends(get_wireless_db_dep)): +@timed_cache(ttl=300) +async def get_radio_types(db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): """Get all radio types.""" try: radio_types = db.query(RadioType).all() return [{ - "radioid": rt.radioid, - "radioname": rt.radioname + "id": rt.id, + "name": rt.name } for rt in radio_types] except SQLAlchemyError as e: logger.error(f"Database error in /radio-types: {e}") @@ -824,182 +577,8 @@ def get_radio_types(db: Session = Depends(get_wireless_db_dep)): logger.error(f"Unexpected error in /radio-types: {e}") raise HTTPException(status_code=500, detail="Internal server error") -@app.post("/wireless/campuses/", response_model=CampusResponse) -def create_campus(campus: CampusCreate, db: Session = Depends(get_wireless_db_dep)): - """Create a new campus.""" - db_campus = Campus(campus_name=campus.campus_name) - db.add(db_campus) - db.commit() - db.refresh(db_campus) - return db_campus - -@app.get("/wireless/campuses/", response_model=List[CampusResponse]) -def get_campuses(db: Session = Depends(get_wireless_db_dep)): - """Get all campuses.""" - return db.query(Campus).all() - -@app.post("/wireless/buildings/", response_model=BuildingResponse) -def create_building(building: BuildingCreate, db: Session = Depends(get_wireless_db_dep)): - """Create a new building.""" - db_building = Building(**building.dict()) - db.add(db_building) - db.commit() - db.refresh(db_building) - return db_building - -@app.get("/wireless/buildings/", response_model=List[BuildingResponse]) -def get_wireless_buildings(campus_id: Optional[int] = None, db: Session = Depends(get_wireless_db_dep)): - """Get all buildings, optionally filtered by campus.""" - query = db.query(Building) - if campus_id: - query = query.filter(Building.campus_id == campus_id) - return query.all() - -@app.post("/wireless/client-counts/", response_model=ClientCountResponse) -def create_client_count(count: ClientCountCreate, db: Session = Depends(get_wireless_db_dep)): - """Create a new client count.""" - db_count = ClientCount(**count.dict()) - db.add(db_count) - db.commit() - db.refresh(db_count) - return db_count - -@app.get("/wireless/client-counts/", response_model=List[ClientCountResponse]) -def get_wireless_client_counts( - building_id: Optional[int] = None, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - db: Session = Depends(get_wireless_db_dep) -): - """Get client counts with optional filters.""" - query = db.query(ClientCount) - if building_id: - query = query.filter(ClientCount.building_id == building_id) - if start_time: - query = query.filter(ClientCount.time_inserted >= start_time) - if end_time: - query = query.filter(ClientCount.time_inserted <= end_time) - return query.all() - -@app.post("/ap/buildings/", response_model=ApBuildingResponse) -def create_ap_building(building: ApBuildingCreate, db: Session = Depends(get_apclient_db_dep)): - """Create a new AP building.""" - db_building = ApBuilding(**building.dict()) - db.add(db_building) - db.commit() - db.refresh(db_building) - return db_building - -@app.get("/ap/buildings/", response_model=List[ApBuildingResponse]) -def get_ap_buildings(db: Session = Depends(get_apclient_db_dep)): - """Get all AP buildings.""" - return db.query(ApBuilding).all() - -@app.post("/ap/floors/", response_model=FloorResponse) -def create_floor(floor: FloorCreate, db: Session = Depends(get_apclient_db_dep)): - """Create a new floor.""" - db_floor = Floor(**floor.dict()) - db.add(db_floor) - db.commit() - db.refresh(db_floor) - return db_floor - -@app.get("/ap/floors/", response_model=List[FloorResponse]) -def get_ap_floors(building_id: Optional[int] = None, db: Session = Depends(get_apclient_db_dep)): - """Get all floors, optionally filtered by building.""" - query = db.query(Floor) - if building_id: - query = query.filter(Floor.buildingid == building_id) - return query.all() - -@app.post("/ap/rooms/", response_model=RoomResponse) -def create_room(room: RoomCreate, db: Session = Depends(get_apclient_db_dep)): - """Create a new room.""" - db_room = Room(**room.dict()) - db.add(db_room) - db.commit() - db.refresh(db_room) - return db_room - -@app.get("/ap/rooms/", response_model=List[RoomResponse]) -def get_ap_rooms(floor_id: Optional[int] = None, db: Session = Depends(get_apclient_db_dep)): - """Get all rooms, optionally filtered by floor.""" - query = db.query(Room) - if floor_id: - query = query.filter(Room.floorid == floor_id) - return query.all() - -@app.post("/ap/access-points/", response_model=AccessPointResponse) -def create_access_point(ap: AccessPointCreate, db: Session = Depends(get_apclient_db_dep)): - """Create a new access point.""" - db_ap = AccessPoint(**ap.dict()) - db.add(db_ap) - db.commit() - db.refresh(db_ap) - return db_ap - -@app.get("/ap/access-points/", response_model=List[AccessPointResponse]) -def get_ap_access_points( - building_id: Optional[int] = None, - floor_id: Optional[int] = None, - room_id: Optional[int] = None, - db: Session = Depends(get_apclient_db_dep) -): - """Get all access points with optional filters.""" - query = db.query(AccessPoint) - if building_id: - query = query.filter(AccessPoint.buildingid == building_id) - if floor_id: - query = query.filter(AccessPoint.floorid == floor_id) - if room_id: - query = query.filter(AccessPoint.roomid == room_id) - return query.all() - -@app.post("/ap/radio-types/", response_model=RadioTypeResponse) -def create_radio_type(radio: RadioTypeCreate, db: Session = Depends(get_apclient_db_dep)): - """Create a new radio type.""" - db_radio = RadioType(**radio.dict()) - db.add(db_radio) - db.commit() - db.refresh(db_radio) - return db_radio - -@app.get("/ap/radio-types/", response_model=List[RadioTypeResponse]) -def get_ap_radio_types(db: Session = Depends(get_apclient_db_dep)): - """Get all radio types.""" - return db.query(RadioType).all() - -@app.post("/ap/client-counts/", response_model=ClientCountAPResponse) -def create_client_count_ap(count: ClientCountAPCreate, db: Session = Depends(get_apclient_db_dep)): - """Create a new AP client count.""" - db_count = ClientCountAP(**count.dict()) - db.add(db_count) - db.commit() - db.refresh(db_count) - return db_count - -@app.get("/ap/client-counts/", response_model=List[ClientCountAPResponse]) -def get_ap_client_counts( - ap_id: Optional[int] = None, - radio_id: Optional[int] = None, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - db: Session = Depends(get_apclient_db_dep) -): - """Get AP client counts with optional filters.""" - query = db.query(ClientCountAP) - if ap_id: - query = query.filter(ClientCountAP.apid == ap_id) - if radio_id: - query = query.filter(ClientCountAP.radioid == radio_id) - if start_time: - query = query.filter(ClientCountAP.timestamp >= start_time) - if end_time: - query = query.filter(ClientCountAP.timestamp <= end_time) - return query.all() - @app.post("/tasks/update-client-count/", response_model=dict) -def trigger_update_client_count(): +def trigger_update_client_count(api_key: str = Depends(get_api_key)): """Trigger the client count update task.""" try: update_client_count_task() @@ -1008,16 +587,6 @@ def trigger_update_client_count(): logger.error(f"Error triggering client count update task: {e}") raise HTTPException(status_code=500, detail=str(e)) -@app.post("/tasks/update-ap-data/", response_model=dict) -def trigger_update_ap_data(): - """Trigger the AP data update task.""" - try: - update_ap_data_task() - return {"message": "AP data update task started"} - except Exception as e: - logger.error(f"Error triggering AP data update task: {e}") - raise HTTPException(status_code=500, detail=str(e)) - # Add health check endpoint @app.get("/health") def health_check(): @@ -1051,88 +620,75 @@ def health_check(): def calculate_next_run_time(): """Calculate the next run time for scheduled tasks.""" now = datetime.now(TORONTO_TZ) - # Add 5 minutes to current time - next_run = now + timedelta(minutes=5) + # Add interval to current time + interval = int(os.getenv("SCHEDULER_INTERVAL_MINUTES", 5)) + next_run = now + timedelta(minutes=interval) next_run = next_run.replace(second=0, microsecond=0) return next_run @app.get("/diagnostics/zero-counts") -async def get_zero_count_diagnostics(): +@timed_cache(ttl=300) +async def get_zero_count_diagnostics(db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): """Get diagnostics for buildings with zero client counts.""" try: - with get_wireless_db() as db: - # Get current timestamp - current_time = datetime.now(TORONTO_TZ) - - # Get all buildings - buildings = db.query(Building).all() + # Get current timestamp + current_time = datetime.now(TORONTO_TZ) + + # Get all buildings + buildings = db.query(Building).all() + + zero_count_buildings = [] + + for building in buildings: + # Get latest client count + latest_count = db.query(ClientCount).join(AccessPoint).filter(AccessPoint.building_id == building.id).order_by(ClientCount.timestamp.desc()).first() - zero_count_buildings = [] + if not latest_count: + continue + + # Get historical average (last 24 hours) + one_day_ago = current_time - timedelta(days=1) + historical_counts = db.query(ClientCount).join(AccessPoint).filter(AccessPoint.building_id == building.id, ClientCount.timestamp >= one_day_ago).all() - for building in buildings: - # Get latest client count - latest_count = db.query(ClientCount)\ - .filter(ClientCount.building_id == building.building_id)\ - .order_by(ClientCount.time_inserted.desc())\ - .first() + if not historical_counts: + continue - if not latest_count: - continue - - # Get historical average (last 24 hours) - one_day_ago = current_time - timedelta(days=1) - historical_counts = db.query(ClientCount)\ - .filter( - ClientCount.building_id == building.building_id, - ClientCount.time_inserted >= one_day_ago - ).all() + historical_avg = sum(count.count for count in historical_counts) / len(historical_counts) + + # If current count is 0 but historical average is significant + if latest_count.count == 0 and historical_avg > 5: + # Get AP status + ap_status = { + 'total_aps': len(building.access_points), + 'active_aps': sum(1 for ap in building.access_points if ap.is_active), + 'inactive_aps': sum(1 for ap in building.access_points if not ap.is_active) + } - if not historical_counts: - continue - - historical_avg = sum(count.client_count for count in historical_counts) / len(historical_counts) + # Determine severity + if historical_avg > 50: + severity = 'high' + elif historical_avg > 20: + severity = 'medium' + else: + severity = 'low' - # If current count is 0 but historical average is significant - if latest_count.client_count == 0 and historical_avg > 5: - # Get AP status - ap_status = { - 'total_aps': len(building.access_points), - 'active_aps': sum(1 for ap in building.access_points if ap.status == 'active'), - 'inactive_aps': sum(1 for ap in building.access_points if ap.status == 'inactive') - } - - # Get DNA Center status - dna_status = { - 'total_aps_in_dna': len(building.access_points), - 'aps_with_clients': sum(1 for ap in building.access_points if ap.client_count > 0) - } - - # Determine severity - if historical_avg > 50: - severity = 'high' - elif historical_avg > 20: - severity = 'medium' - else: - severity = 'low' - - zero_count_buildings.append({ - 'building_name': building.building_name, - 'campus_name': building.campus.campus_name if building.campus else 'Unknown', - 'current_count': latest_count.client_count, - 'historical_average': round(historical_avg, 2), - 'severity': severity, - 'ap_status': ap_status, - 'dna_center_status': dna_status, - 'last_updated': latest_count.time_inserted.isoformat() - }) - - return { - 'timestamp': current_time.isoformat(), - 'total_buildings_analyzed': len(buildings), - 'buildings_with_zero_counts': len(zero_count_buildings), - 'zero_count_buildings': zero_count_buildings - } - + zero_count_buildings.append({ + 'building_name': building.name, + 'campus_name': building.campus.name if building.campus else 'Unknown', + 'current_count': latest_count.count, + 'historical_average': round(historical_avg, 2), + 'severity': severity, + 'ap_status': ap_status, + 'last_updated': latest_count.timestamp.isoformat() + }) + + return { + 'timestamp': current_time.isoformat(), + 'total_buildings_analyzed': len(buildings), + 'buildings_with_zero_counts': len(zero_count_buildings), + 'zero_count_buildings': zero_count_buildings + } + except Exception as e: logger.error(f"Error in get_zero_count_diagnostics: {str(e)}") raise HTTPException( @@ -1141,45 +697,46 @@ async def get_zero_count_diagnostics(): ) @app.get("/diagnostics/health") -async def get_building_health(): +@timed_cache(ttl=300) +async def get_building_health(db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): """ Get health monitoring alerts for buildings. Only works when ENABLE_DIAGNOSTICS=true. """ try: - with get_wireless_db() as wireless_db, get_apclient_db() as apclient_db: - alerts = monitor_building_health(wireless_db, apclient_db, auth_manager) - if not is_diagnostics_enabled(): - raise HTTPException( - status_code=403, - detail="Diagnostics are not enabled. Set ENABLE_DIAGNOSTICS=true to enable." - ) - return {"alerts": alerts} + alerts = monitor_building_health(db, auth_manager) + if not is_diagnostics_enabled(): + raise HTTPException( + status_code=403, + detail="Diagnostics are not enabled. Set ENABLE_DIAGNOSTICS=true to enable." + ) + return {"alerts": alerts} except Exception as e: logger.error(f"Error generating health alerts: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/diagnostics/report") -async def get_diagnostic_report(): +@timed_cache(ttl=300) +async def get_diagnostic_report(db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): """ Get a comprehensive diagnostic report including zero count analysis and health monitoring. Only works when ENABLE_DIAGNOSTICS=true. """ try: - with get_wireless_db() as wireless_db, get_apclient_db() as apclient_db: - report = generate_diagnostic_report(wireless_db, apclient_db, auth_manager) - if "message" in report and report["message"] == "Diagnostics are not enabled": - raise HTTPException( - status_code=403, - detail="Diagnostics are not enabled. Set ENABLE_DIAGNOSTICS=true to enable." - ) - return report + report = generate_diagnostic_report(db, auth_manager) + if "message" in report and report["message"] == "Diagnostics are not enabled": + raise HTTPException( + status_code=403, + detail="Diagnostics are not enabled. Set ENABLE_DIAGNOSTICS=true to enable." + ) + return report except Exception as e: logger.error(f"Error generating diagnostic report: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/diagnostics/incomplete-devices") -async def get_incomplete_devices(): +@timed_cache(ttl=300) +async def get_incomplete_devices(api_key: str = Depends(get_api_key)): """Get diagnostics for incomplete APs/devices (missing required fields).""" if not is_diagnostics_enabled(): raise HTTPException(status_code=403, detail="Diagnostics are not enabled. Set ENABLE_DIAGNOSTICS=true to enable.") @@ -1187,9 +744,23 @@ async def get_incomplete_devices(): return {"incomplete_devices": data, "count": len(data)} @app.get("/diagnostics/api_health", tags=["Diagnostics"]) -def get_api_health(): +@timed_cache(ttl=300) +async def get_api_health(api_key: str = Depends(get_api_key)): """ Get a summary of recent API error rates and details. Tracks the last 100 API errors in memory. Returns total errors tracked, errors in the last hour, and the 10 most recent errors. """ return get_api_error_summary() + +@app.websocket("/ws/client-count") +async def websocket_client_count(websocket: WebSocket, db: Session = Depends(get_db_dep)): + await websocket.accept() + try: + while True: + total_count = db.query(func.sum(ClientCount.count)).scalar() + await websocket.send_json({"total_client_count": total_count or 0}) + await asyncio.sleep(10) + except Exception as e: + logger.error(f"WebSocket error: {e}") + finally: + await websocket.close() \ No newline at end of file diff --git a/ap_monitor/app/mapping.py b/ap_monitor/app/mapping.py index e567550..4488587 100644 --- a/ap_monitor/app/mapping.py +++ b/ap_monitor/app/mapping.py @@ -1,190 +1,117 @@ -# Complete mapping from short form to full building name (from doc/db/building.txt) -SHORT_TO_FULL_BUILDING = { - "ace": "Accolade Building East", - "acw": "Accolade Building West", - "ao": "Archives of Ontario", - "atk": "Atkinson", - "bc": "Norman Bethune College", - "bcss": "Bennett Centre for Student Services", - "brg": "Bergeron Centre for Engineering Excellence", - "bsb": "Behavioural Sciences Building", - "bu": "Burton Auditorium", - "cb": "Chemistry Building", - "cc": "Calumet College", - "cfa": "Joan & Martin Goldfarb Centre for Fine Arts", - "cft": "Centre for Film and Theatre / Joseph F. Green Studio Theatre", - "clh": "Curtis Lecture Halls", - "csq": "Central Square", - "cub": "Central Utilities Building", - "db": "Victor Phillip Dahdaleh Building", - "elc": "Executive Learning Centre", - "fan": "Founders Annex North", - "fas": "Founders Annex South", - "fc": "Founders College", - "frq": "Farquharson Life Sciences", - "gh": "Glendon Hall", - "hc": "Lorna R. Marsden Honour Court & Welcome Centre", - "hne": "Health, Nursing and Environmental Studies Building", - "hr": "Hilliard Residence", - "k": "Kinsmen Building", - "kt": "Kaneff Tower", - "las": "Lassonde Building", - "lmp": "LA&PS @ IBM (Markham campus)", - "lsb": "Life Sciences Building", - "lum": "Lumbers Building", - "mb": "Rob & Cheryl McEwen Graduate Study & Research Building", - "mc": "McLaughlin College", - "oc": "Off Campus", - "osg": "Ignat Kaneff Building (Osgoode Hall Law School)", - "prb": "Physical Resources Building", - "pse": "Petrie Science & Engineering Building", - "ross": "Ross Building", - "say": "Seneca @ York (Stephen E. Quinlan Building)", - "sc": "Stong College", - "scl": "Scott Library", - "shr": "Sherman Health Science Research Centre", - "slh": "Stedman Lecture Halls", - "ssb": "Seymour Schulich Building", - "ssc": "Second Student Centre", - "stc": "First Student Centre", - "stl": "Steacie Science & Engineering Library", - "tc": "Tennis Canada – Sobeys Stadium", - "tfc": "Track & Field Centre", - "tm": "Tait McKenzie Centre", - "vc": "Vanier College", - "vh": "Vari Hall", - "wc": "Winters College", - "wob": "West Office Building", - "wsc": "William Small Centre", - "yh": "York Hall", - "yl": "York Lanes", - # Add common campus-specific short forms - "studc": "Student Centre", - "beth": "Bethune Residence", - "as380": "Atkinson", - "tel": "Victor Phillip Dahdaleh", - "psci": "Petrie Science and Engineering", - "scott": "Scott Library", - "vanier": "Vanier College", - "winters": "Winters College", - "lumbers": "Lumbers", - "life": "Life Sciences", - "pond": "Pond Road Residence", - "osgoode": "Osgoode", - "tait": "Tait Mackenzie", - "st": "Stong College", -} +""" +This module handles the mapping of building and floor names from various short forms +to their canonical representations in the database. -# Mapping for floor/area tokens -FLOOR_MAP = { - "b": "Basement", - "g": "Ground", - "f": "Floor", - "r": "Room", - "fl": "Floor", - "bsmt": "Basement", - "gr": "Ground", -} +The mappings are loaded from the database into memory on application startup +to avoid repeated database queries. +""" -# Canonical building names from wireless_count DB (first 55 lines of building.txt) -CANONICAL_BUILDING_NAMES = { - 'Lab', - 'Lassonde', - 'Life Sciences', - 'Lumbers', - 'McEwen', - 'McLaughlin College', - 'Northwest Gate', - 'Osgoode', - 'PSI - Parking Structure I (BCSS)', - 'Passy 10', - 'Passy 12', - 'Passy 14', - 'Passy 16', - 'Passy 18', - 'Passy 2', - 'Passy 4', - 'Passy 6', - 'Passy 8', - 'Petrie Science and Engineering', - 'Physical Resources', - 'Pond Road Residence', - 'Ross', - 'School of Continuing Studies', - 'Scott Library', - 'Scott Religious Centre', - 'Second Student Centre', - 'Seymour Schulich', - 'Sherman', - 'Shoreham', - 'Steacie - DataCentre040', - 'Steacie Lab', - 'Steacie Science and Engineering', - 'Stedman Lecture Halls', - 'Stong College', - 'Stong Residence', - 'Student Centre', - 'Tait Mackenzie', - 'Tatham Residence', - 'Toronto Track and Field Centre', - 'Vanier College', - 'Vanier Residence', - 'Vari Hall', - 'Victor Phillip Dahdaleh', - 'Victor Phillip Dahdaleh - DataCentre5028', - 'West Office', - 'William Small', - 'Winters College', - 'Winters Residence', - 'York Lanes', - 'York Lions Stadium', - 'York Stadium', - 'Anees - Test', - 'Goldfarb Gallery', - 'Keele Campus', -} +import logging +import os +from sqlalchemy.orm import Session +from .models import BuildingMapping, FloorMapping, CanonicalBuildingName +from .db import get_db_session -def normalize_building_name(name): +# Configure logger +logger = logging.getLogger(__name__) + +# In-memory caches for the mappings +SHORT_TO_FULL_BUILDING = {} +FLOOR_MAP = {} +CANONICAL_BUILDING_NAMES = set() + +def load_mappings_from_db(): + """ + Load all mappings from the database into memory. + This should be called at application startup. + """ + global SHORT_TO_FULL_BUILDING, FLOOR_MAP, CANONICAL_BUILDING_NAMES + + db: Session = get_db_session() + try: + # Load building mappings + building_mappings = db.query(BuildingMapping).all() + SHORT_TO_FULL_BUILDING = {bm.short_name: bm.full_name for bm in building_mappings} + logger.info(f"Loaded {len(SHORT_TO_FULL_BUILDING)} building mappings from the database.") + + # Load floor mappings + floor_mappings = db.query(FloorMapping).all() + FLOOR_MAP = {fm.short_name: fm.full_name for fm in floor_mappings} + logger.info(f"Loaded {len(FLOOR_MAP)} floor mappings from the database.") + + # Load canonical building names + canonical_names = db.query(CanonicalBuildingName).all() + CANONICAL_BUILDING_NAMES = {cn.name for cn in canonical_names} + logger.info(f"Loaded {len(CANONICAL_BUILDING_NAMES)} canonical building names from the database.") + + except Exception as e: + logger.error(f"Error loading mappings from the database: {e}") + # Depending on the application's requirements, you might want to raise the exception + # or handle it in a way that allows the application to continue with partial functionality. + raise + finally: + db.close() + +def normalize_building_name(name: str) -> str | None: """ Normalize and map a building name or short form to the canonical name in the DB. Returns the canonical name if found, else None. """ if not name or not isinstance(name, str): return None + name = name.strip() + # Try direct match if name in CANONICAL_BUILDING_NAMES: return name + # Try case-insensitive match for canon in CANONICAL_BUILDING_NAMES: if name.lower() == canon.lower(): return canon + # Try mapping from short form mapped = SHORT_TO_FULL_BUILDING.get(name.lower()) if mapped and mapped in CANONICAL_BUILDING_NAMES: return mapped + # Try partial/contains match (for common variants) for canon in CANONICAL_BUILDING_NAMES: if name.lower() in canon.lower() or canon.lower() in name.lower(): return canon + # Try removing common suffixes/variants for canon in CANONICAL_BUILDING_NAMES: if name.lower().replace('building', '').strip() == canon.lower().replace('building', '').strip(): return canon + return None -def parse_ap_name_for_location(ap_name): +def parse_ap_name_for_location(ap_name: str) -> tuple[str | None, str | None, str | None]: """ - Parse AP name like 'k388-studc-b-1' to infer building and floor/area. + Parse AP name like 'l888-exp-b-1' to infer building and floor/area. Returns (building, floor/area, ap_number) or (None, None, None) if not parseable. """ if not ap_name or not isinstance(ap_name, str): return None, None, None + parts = ap_name.lower().split('-') + if len(parts) < 4: return None, None, None - # Example: k388-studc-b-1 + _, short_building, floor_token, ap_number = parts[:4] + building = SHORT_TO_FULL_BUILDING.get(short_building, short_building.title()) floor = FLOOR_MAP.get(floor_token, floor_token.title()) - return building, floor, ap_number \ No newline at end of file + + return building, floor, ap_number + +# Load the mappings when the module is imported. +# This assumes the database is available when the application starts. +if os.getenv("TESTING", "false").lower() != "true": + try: + load_mappings_from_db() + except Exception as e: + logger.error(f"Failed to load mappings on startup: {e}") \ No newline at end of file diff --git a/ap_monitor/app/models.py b/ap_monitor/app/models.py index dacdea6..0c909f2 100644 --- a/ap_monitor/app/models.py +++ b/ap_monitor/app/models.py @@ -1,106 +1,127 @@ -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, BigInteger, Numeric -from sqlalchemy.dialects.postgresql import MACADDR, INET -from sqlalchemy.orm import relationship +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, BigInteger, Numeric, TypeDecorator +from sqlalchemy.orm import relationship, declarative_base from sqlalchemy.sql import func -from ap_monitor.app.db import WirelessBase, APClientBase import os +class MACADDR(TypeDecorator): + impl = String + + def __init__(self, *args, **kwargs): + super(MACADDR, self).__init__(*args, **kwargs) + +Base = declarative_base() + if os.getenv("TESTING", "false").lower() == "true": # Use String for SQLite testing - MACADDR_TYPE = String(17) # MAC addresses - INET_TYPE = String(45) # IPv6 addresses + MACADDR_TYPE = String + INET_TYPE = String else: + from sqlalchemy.dialects.postgresql import MACADDR, INET MACADDR_TYPE = MACADDR INET_TYPE = INET -# wireless_count DB models -class Campus(WirelessBase): +class Campus(Base): __tablename__ = "campuses" - campus_id = Column(Integer, primary_key=True, autoincrement=True) - campus_name = Column(String(100), nullable=False, unique=True) + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False, unique=True) buildings = relationship("Building", back_populates="campus", cascade="all, delete-orphan") -class Building(WirelessBase): +class Building(Base): __tablename__ = "buildings" - building_id = Column(Integer, primary_key=True, autoincrement=True) - building_name = Column(String(100), nullable=False) - campus_id = Column(Integer, ForeignKey("campuses.campus_id"), nullable=False) - latitude = Column(Numeric(15, 10), nullable=False) - longitude = Column(Numeric(15, 10), nullable=False) + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + campus_id = Column(Integer, ForeignKey("campuses.id"), nullable=False) + latitude = Column(Numeric(15, 10), nullable=True) + longitude = Column(Numeric(15, 10), nullable=True) campus = relationship("Campus", back_populates="buildings") - client_counts = relationship("ClientCount", back_populates="building", cascade="all, delete-orphan") - - __table_args__ = ( - {'extend_existing': True}, - ) - -class ClientCount(WirelessBase): - __tablename__ = "client_counts" - count_id = Column(Integer, primary_key=True, autoincrement=True) - building_id = Column(Integer, ForeignKey("buildings.building_id"), nullable=False) - client_count = Column(Integer, nullable=False) - time_inserted = Column(DateTime(timezone=True), server_default=func.now(), index=True) - building = relationship("Building", back_populates="client_counts") - -# apclientcount DB models -class ApBuilding(APClientBase): - __tablename__ = "buildings" - buildingid = Column(Integer, primary_key=True, autoincrement=True) - buildingname = Column(String(255), nullable=False, unique=True) floors = relationship("Floor", back_populates="building", cascade="all, delete-orphan") + access_points = relationship("AccessPoint", back_populates="building", cascade="all, delete-orphan") -class Floor(APClientBase): +class Floor(Base): __tablename__ = "floors" - floorid = Column(Integer, primary_key=True, autoincrement=True) - buildingid = Column(Integer, ForeignKey("buildings.buildingid")) - floorname = Column(String(50), nullable=False) - building = relationship("ApBuilding", back_populates="floors") + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), nullable=False) + building_id = Column(Integer, ForeignKey("buildings.id")) + building = relationship("Building", back_populates="floors") rooms = relationship("Room", back_populates="floor", cascade="all, delete-orphan") - accesspoints = relationship("AccessPoint", back_populates="floor", cascade="all, delete-orphan") - - __table_args__ = ( - {'extend_existing': True}, - ) + access_points = relationship("AccessPoint", back_populates="floor", cascade="all, delete-orphan") -class Room(APClientBase): +class Room(Base): __tablename__ = "rooms" - roomid = Column(Integer, primary_key=True, autoincrement=True) - floorid = Column(Integer, ForeignKey("floors.floorid")) - roomname = Column(String(100), nullable=False) + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + floor_id = Column(Integer, ForeignKey("floors.id")) floor = relationship("Floor", back_populates="rooms") - accesspoints = relationship("AccessPoint", back_populates="room", cascade="all, delete-orphan") - -class AccessPoint(APClientBase): - __tablename__ = "accesspoints" - apid = Column(Integer, primary_key=True, autoincrement=True) - buildingid = Column(Integer, ForeignKey("buildings.buildingid")) - floorid = Column(Integer, ForeignKey("floors.floorid")) - roomid = Column(Integer, ForeignKey("rooms.roomid")) - apname = Column(String(40), nullable=False) - macaddress = Column(MACADDR_TYPE, unique=True) - ipaddress = Column(INET_TYPE) - modelname = Column(String(60)) - isactive = Column(Boolean, default=True) - floor = relationship("Floor", back_populates="accesspoints") - room = relationship("Room", back_populates="accesspoints") - clientcounts = relationship("ClientCountAP", back_populates="accesspoint", cascade="all, delete-orphan") - -class RadioType(APClientBase): - __tablename__ = "radiotypes" - radioid = Column(Integer, primary_key=True, autoincrement=True) - radioname = Column(String(50), nullable=False, unique=True) - clientcounts = relationship("ClientCountAP", back_populates="radio", cascade="all, delete-orphan") - -class ClientCountAP(APClientBase): - __tablename__ = "clientcount" - countid = Column(Integer if os.getenv("TESTING", "false").lower() == "true" else BigInteger, primary_key=True, autoincrement=True) - apid = Column(Integer, ForeignKey("accesspoints.apid")) - radioid = Column(Integer, ForeignKey("radiotypes.radioid")) - clientcount = Column(Integer, nullable=False) + access_points = relationship("AccessPoint", back_populates="room", cascade="all, delete-orphan") + +class AccessPoint(Base): + __tablename__ = "access_points" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(40), nullable=False) + mac_address = Column(MACADDR_TYPE, unique=True) + ip_address = Column(INET_TYPE) + model = Column(String(60)) + is_active = Column(Boolean, default=True) + building_id = Column(Integer, ForeignKey("buildings.id")) + floor_id = Column(Integer, ForeignKey("floors.id")) + room_id = Column(Integer, ForeignKey("rooms.id")) + building = relationship("Building", back_populates="access_points") + floor = relationship("Floor", back_populates="access_points") + room = relationship("Room", back_populates="access_points") + client_counts = relationship("ClientCount", back_populates="access_point", cascade="all, delete-orphan") + +class RadioType(Base): + __tablename__ = "radio_types" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), nullable=False, unique=True) + client_counts = relationship("ClientCount", back_populates="radio_type", cascade="all, delete-orphan") + +class ClientCount(Base): + __tablename__ = "client_counts" + id = Column(BigInteger if os.getenv("TESTING", "false").lower() != "true" else Integer, primary_key=True, autoincrement=True) + count = Column(Integer, nullable=False) timestamp = Column(DateTime(timezone=True), nullable=False, index=True) - accesspoint = relationship("AccessPoint", back_populates="clientcounts") - radio = relationship("RadioType", back_populates="clientcounts") + access_point_id = Column(Integer, ForeignKey("access_points.id")) + radio_type_id = Column(Integer, ForeignKey("radio_types.id")) + access_point = relationship("AccessPoint", back_populates="client_counts") + radio_type = relationship("RadioType", back_populates="client_counts") + +class APStatus(Base): + __tablename__ = "ap_statuses" + id = Column(Integer, primary_key=True, autoincrement=True) + ap_name = Column(String(40), nullable=False) + status = Column(String(20), nullable=False) + last_seen = Column(DateTime(timezone=True), nullable=False) + timestamp = Column(DateTime(timezone=True), server_default=func.now()) + +class ZeroCountAP(Base): + __tablename__ = "zero_count_aps" + id = Column(Integer, primary_key=True, autoincrement=True) + ap_name = Column(String(40), nullable=False) + last_reported = Column(DateTime(timezone=True), nullable=False) + last_client_count_timestamp = Column(DateTime(timezone=True)) + last_client_count = Column(Integer) + last_active_timestamp = Column(DateTime(timezone=True)) + last_active_status = Column(String(20)) + is_resolved = Column(Boolean, default=False) + notes = Column(String(255)) + first_occurrence = Column(DateTime(timezone=True), server_default=func.now()) + last_occurrence = Column(DateTime(timezone=True), onupdate=func.now()) + occurrence_count = Column(Integer, default=1) + +class BuildingMapping(Base): + __tablename__ = "building_mappings" + id = Column(Integer, primary_key=True, autoincrement=True) + short_name = Column(String(50), nullable=False, unique=True) + full_name = Column(String(100), nullable=False) + +class FloorMapping(Base): + __tablename__ = "floor_mappings" + id = Column(Integer, primary_key=True, autoincrement=True) + short_name = Column(String(50), nullable=False, unique=True) + full_name = Column(String(100), nullable=False) - __table_args__ = ( - {'extend_existing': True}, - ) \ No newline at end of file +class CanonicalBuildingName(Base): + __tablename__ = "canonical_building_names" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False, unique=True) \ No newline at end of file diff --git a/ap_monitor/app/schemas.py b/ap_monitor/app/schemas.py index b9d31e6..b7653bb 100644 --- a/ap_monitor/app/schemas.py +++ b/ap_monitor/app/schemas.py @@ -3,111 +3,87 @@ from datetime import datetime from decimal import Decimal -# Wireless Count Schemas class CampusBase(BaseModel): - campus_name: str = Field(..., description="Name of the campus") + name: str = Field(..., description="Name of the campus") class CampusCreate(CampusBase): pass -class CampusResponse(CampusBase): - campus_id: int +class Campus(CampusBase): + id: int model_config = ConfigDict(from_attributes=True) class BuildingBase(BaseModel): - building_name: str = Field(..., description="Name of the building") + name: str = Field(..., description="Name of the building") campus_id: int = Field(..., description="ID of the campus this building belongs to") - latitude: Decimal = Field(..., description="Latitude coordinate of the building") - longitude: Decimal = Field(..., description="Longitude coordinate of the building") + latitude: Optional[Decimal] = Field(None, description="Latitude coordinate of the building") + longitude: Optional[Decimal] = Field(None, description="Longitude coordinate of the building") class BuildingCreate(BuildingBase): pass -class BuildingResponse(BuildingBase): - building_id: int - model_config = ConfigDict(from_attributes=True) - -class ClientCountBase(BaseModel): - building_id: int = Field(..., description="ID of the building") - client_count: int = Field(..., description="Number of clients") - -class ClientCountCreate(ClientCountBase): - pass - -class ClientCountResponse(ClientCountBase): - count_id: int - time_inserted: datetime - model_config = ConfigDict(from_attributes=True) - -# AP Client Count Schemas -class ApBuildingBase(BaseModel): - buildingname: str = Field(..., description="Name of the building") - -class ApBuildingCreate(ApBuildingBase): - pass - -class ApBuildingResponse(ApBuildingBase): - buildingid: int +class Building(BuildingBase): + id: int model_config = ConfigDict(from_attributes=True) class FloorBase(BaseModel): - floorname: str = Field(..., description="Name of the floor") - buildingid: int = Field(..., description="ID of the building this floor belongs to") + name: str = Field(..., description="Name of the floor") + building_id: int = Field(..., description="ID of the building this floor belongs to") class FloorCreate(FloorBase): pass -class FloorResponse(FloorBase): - floorid: int +class Floor(FloorBase): + id: int model_config = ConfigDict(from_attributes=True) class RoomBase(BaseModel): - roomname: str = Field(..., description="Name of the room") - floorid: int = Field(..., description="ID of the floor this room belongs to") + name: str = Field(..., description="Name of the room") + floor_id: int = Field(..., description="ID of the floor this room belongs to") class RoomCreate(RoomBase): pass -class RoomResponse(RoomBase): - roomid: int +class Room(RoomBase): + id: int model_config = ConfigDict(from_attributes=True) class AccessPointBase(BaseModel): - apname: str = Field(..., description="Name of the access point") - macaddress: str = Field(..., description="MAC address of the access point") - ipaddress: Optional[str] = Field(None, description="IP address of the access point") - modelname: Optional[str] = Field(None, description="Model name of the access point") - isactive: bool = Field(True, description="Whether the access point is active") - buildingid: int = Field(..., description="ID of the building") - floorid: int = Field(..., description="ID of the floor") - roomid: Optional[int] = Field(None, description="ID of the room") + name: str = Field(..., description="Name of the access point") + mac_address: str = Field(..., description="MAC address of the access point") + ip_address: Optional[str] = Field(None, description="IP address of the access point") + model: Optional[str] = Field(None, description="Model name of the access point") + is_active: bool = Field(True, description="Whether the access point is active") + building_id: int = Field(..., description="ID of the building") + floor_id: int = Field(..., description="ID of the floor") + room_id: Optional[int] = Field(None, description="ID of the room") class AccessPointCreate(AccessPointBase): pass -class AccessPointResponse(AccessPointBase): - apid: int +class AccessPoint(AccessPointBase): + id: int model_config = ConfigDict(from_attributes=True) class RadioTypeBase(BaseModel): - radioname: str = Field(..., description="Name of the radio type") + name: str = Field(..., description="Name of the radio type") class RadioTypeCreate(RadioTypeBase): pass -class RadioTypeResponse(RadioTypeBase): - radioid: int +class RadioType(RadioTypeBase): + id: int model_config = ConfigDict(from_attributes=True) -class ClientCountAPBase(BaseModel): - apid: Optional[int] = Field(None, description="ID of the access point") - radioid: int = Field(..., description="ID of the radio type") - clientcount: int = Field(..., description="Number of clients") +class ClientCountBase(BaseModel): + count: int = Field(..., description="Number of clients") timestamp: datetime = Field(..., description="Timestamp of the client count") + access_point_id: int = Field(..., description="ID of the access point") + radio_type_id: Optional[int] = Field(None, description="ID of the radio type") -class ClientCountAPCreate(ClientCountAPBase): +class ClientCountCreate(ClientCountBase): pass -class ClientCountAPResponse(ClientCountAPBase): - countid: int - model_config = ConfigDict(from_attributes=True) \ No newline at end of file +class ClientCount(ClientCountBase): + id: int + model_config = ConfigDict(from_attributes=True) diff --git a/ap_monitor/app/security.py b/ap_monitor/app/security.py new file mode 100644 index 0000000..ec2a0ac --- /dev/null +++ b/ap_monitor/app/security.py @@ -0,0 +1,17 @@ +from fastapi import Security, HTTPException, Depends +from fastapi.security.api_key import APIKeyHeader +import os + +API_KEY = os.getenv("API_KEY") +API_KEY_NAME = "X-API-Key" + +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) + +async def get_api_key(api_key_header: str = Security(api_key_header)): + if not API_KEY: + # If no API_KEY is set in the environment, disable authentication + return + if api_key_header == API_KEY: + return api_key_header + else: + raise HTTPException(status_code=403, detail="Could not validate credentials") diff --git a/ap_monitor/requirements.txt b/ap_monitor/requirements.txt index b684894..d44971c 100644 --- a/ap_monitor/requirements.txt +++ b/ap_monitor/requirements.txt @@ -15,4 +15,5 @@ pytest>=8.2.2 pytest-asyncio>=0.23.6 pytest-django>=4.9.0 anyio>=4.3.0 -pydantic>=2.7.0 \ No newline at end of file +pydantic>=2.7.0 +pytest-cov>=5.0.0 \ No newline at end of file diff --git a/ap_monitor/tests/conftest.py b/ap_monitor/tests/conftest.py index 5ed5e32..5cd540d 100644 --- a/ap_monitor/tests/conftest.py +++ b/ap_monitor/tests/conftest.py @@ -1,154 +1,135 @@ -import sys import os +os.environ["TESTING"] = "true" + +import sys import pytest -from sqlalchemy import create_engine, event, inspect -from sqlalchemy.orm import sessionmaker, scoped_session +from dotenv import load_dotenv +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from sqlalchemy.engine import Engine from fastapi.testclient import TestClient -from unittest.mock import patch, MagicMock +from apscheduler.schedulers.background import BackgroundScheduler +import importlib -import ap_monitor.app.db # Ensure db module is loaded so attributes exist for monkeypatching +import ap_monitor.app.db as db_module import ap_monitor.app.main as main_module - -from ap_monitor.app.models import ( - AccessPoint, ClientCount, Building, Floor, Campus, - ApBuilding, Room, RadioType, ClientCountAP, - WirelessBase, APClientBase -) -from ap_monitor.app.db import ( - get_wireless_db, - get_apclient_db, - get_wireless_db_dep, - get_apclient_db_dep -) +from ap_monitor.app.models import Base, RadioType +from ap_monitor.app.db import get_db_dep from ap_monitor.app.main import app -# Set TESTING environment variable -os.environ["TESTING"] = "true" +# --- Fixtures --- +@pytest.fixture(scope="function") +def test_db(): + """Creates an in-memory SQLite database and populates minimal test data.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool + ) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -# Use two separate in-memory SQLite databases for wireless and apclient schemas -WIRELESS_TEST_DB_URL = "sqlite:///:memory:" -APCLIENT_TEST_DB_URL = "sqlite:///:memory:" - -# Create engines with StaticPool to ensure same connection across threads -wireless_engine = create_engine( - WIRELESS_TEST_DB_URL, - connect_args={"check_same_thread": False}, - poolclass=StaticPool -) -apclient_engine = create_engine( - APCLIENT_TEST_DB_URL, - connect_args={"check_same_thread": False}, - poolclass=StaticPool -) - -# Create session factories -WirelessSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=wireless_engine) -APClientSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=apclient_engine) - -# Monkeypatch the app's db.py to use the test engines and session factories -ap_monitor.app.db.wireless_engine = wireless_engine -ap_monitor.app.db.apclient_engine = apclient_engine -ap_monitor.app.db.WirelessSessionLocal = WirelessSessionLocal -ap_monitor.app.db.APClientSessionLocal = APClientSessionLocal - -# Enable foreign key support for SQLite -@event.listens_for(Engine, "connect") -def set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - -# --- Create tables for both databases --- -@pytest.fixture(autouse=True) -def create_test_db(): - # Import wireless models before creating wireless tables - from ap_monitor.app.models import Building, Campus, ClientCount, WirelessBase, ApBuilding, Floor, Room, AccessPoint, ClientCountAP, RadioType, APClientBase - WirelessBase.metadata.drop_all(bind=wireless_engine) - WirelessBase.metadata.create_all(bind=wireless_engine) - APClientBase.metadata.drop_all(bind=apclient_engine) - APClientBase.metadata.create_all(bind=apclient_engine) - # Verify tables are created correctly - inspector = inspect(apclient_engine) - tables = inspector.get_table_names() - print(f"Tables in apclient_engine: {tables}") - for table_name in ['buildings', 'floors', 'rooms', 'accesspoints', 'clientcount', 'radiotypes']: - assert table_name in tables, f"{table_name} table not created" - columns = [col['name'] for col in inspector.get_columns(table_name)] - print(f"Columns in {table_name}: {columns}") - # Add default radio types - with APClientSessionLocal() as session: + # Enable foreign key constraints for SQLite + @event.listens_for(Engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + Base.metadata.create_all(bind=engine) + + db_module.engine = engine + db_module.SessionLocal = SessionLocal + + # Seed radio types if missing + with SessionLocal() as session: if not session.query(RadioType).first(): session.add_all([ - RadioType(radioname="radio0", radioid=1), - RadioType(radioname="radio1", radioid=2), - RadioType(radioname="radio2", radioid=3) + RadioType(id=1, name="radio0"), + RadioType(id=2, name="radio1"), + RadioType(id=3, name="radio2"), ]) session.commit() - yield - WirelessBase.metadata.drop_all(bind=wireless_engine) - APClientBase.metadata.drop_all(bind=apclient_engine) -# --- Database session fixtures --- -@pytest.fixture -def wireless_db(): - """Provide a session for the wireless database.""" - db = WirelessSessionLocal() - try: - yield db - finally: - db.close() + yield SessionLocal + + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(autouse=True) +def reset_maintenance_window(): + """Ensure maintenance window is reset between tests.""" + main_module.MAINTENANCE_UNTIL = None + + +@pytest.fixture(autouse=True) +def populate_mapping_data(test_db): + """Populate static mapping data for tests.""" + db = test_db() + from ap_monitor.app.models import BuildingMapping, FloorMapping, CanonicalBuildingName + db.add_all([ + BuildingMapping(short_name="b1", full_name="Building 1"), + BuildingMapping(short_name="b2", full_name="Building 2"), + BuildingMapping(short_name="b3", full_name="Building 3"), + FloorMapping(short_name="f1", full_name="Floor 1"), + FloorMapping(short_name="f2", full_name="Floor 2"), + FloorMapping(short_name="r", full_name="Room"), + CanonicalBuildingName(name="Building 1"), + CanonicalBuildingName(name="Building 2"), + CanonicalBuildingName(name="Building 3"), + ]) + db.commit() + + from ap_monitor.app.mapping import load_mappings_from_db + load_mappings_from_db() + @pytest.fixture -def apclient_db(): - """Provide a session for the apclient database.""" - db = APClientSessionLocal() - try: - yield db - finally: - db.close() - -# --- TestClient with dependency overrides for both DBs --- +def scheduler(): + """Provides a background scheduler for tests.""" + scheduler = BackgroundScheduler() + scheduler.start() + yield scheduler + if scheduler.running: + scheduler.shutdown() + + @pytest.fixture -def client(wireless_db, apclient_db, scheduler): - def override_get_wireless_db(): - try: - yield wireless_db - finally: - pass - - def override_get_apclient_db(): - try: - yield apclient_db - finally: - pass - - def override_get_wireless_db_dep(): - try: - yield wireless_db - finally: - pass - - def override_get_apclient_db_dep(): +def client(test_db, scheduler, enable_diagnostics_flag=False): + """Provides a FastAPI TestClient with overridden dependencies.""" + original_diagnostics_env = os.environ.get('ENABLE_DIAGNOSTICS') + if enable_diagnostics_flag: + os.environ['ENABLE_DIAGNOSTICS'] = 'true' + else: + os.environ['ENABLE_DIAGNOSTICS'] = 'false' + importlib.reload(main_module) + + def override_get_db(): + db = test_db() try: - yield apclient_db + yield db finally: - pass - - app.dependency_overrides[get_wireless_db] = override_get_wireless_db - app.dependency_overrides[get_apclient_db] = override_get_apclient_db - app.dependency_overrides[get_wireless_db_dep] = override_get_wireless_db_dep - app.dependency_overrides[get_apclient_db_dep] = override_get_apclient_db_dep - - # Add scheduler to app state - app.state.scheduler = scheduler - - with TestClient(app) as test_client: + db.close() + + main_module.app.dependency_overrides[get_db_dep] = override_get_db + main_module.app.state.scheduler = scheduler + + with TestClient(main_module.app) as test_client: yield test_client - - app.dependency_overrides.clear() -@pytest.fixture(autouse=True) -def reset_maintenance_window(): - main_module.MAINTENANCE_UNTIL = None \ No newline at end of file + main_module.app.dependency_overrides.clear() + if original_diagnostics_env is not None: + os.environ['ENABLE_DIAGNOSTICS'] = original_diagnostics_env + else: + del os.environ['ENABLE_DIAGNOSTICS'] + importlib.reload(main_module) + + +@pytest.fixture +def authenticated_client(client): + """Provides an authenticated client with API key header preset.""" + api_key = os.environ.get("API_KEY") + if not api_key: + raise ValueError("API_KEY environment variable not set for testing") + client.headers["X-API-Key"] = api_key + return client diff --git a/ap_monitor/tests/test_apclientcount.py b/ap_monitor/tests/test_apclientcount.py deleted file mode 100644 index c6edb0b..0000000 --- a/ap_monitor/tests/test_apclientcount.py +++ /dev/null @@ -1,479 +0,0 @@ -import pytest -from datetime import datetime, timezone -from sqlalchemy import create_engine, event -from sqlalchemy.orm import sessionmaker -from ap_monitor.app.models import ApBuilding, Floor, Room, AccessPoint, ClientCountAP, RadioType, APClientBase -from ap_monitor.app.db import APClientBase as DBAPClientBase -from ap_monitor.app.main import insert_apclientcount_data - -# Helper for radio mapping -radioId_map = {'radio0': 1, 'radio1': 2, 'radio2': 3} - -@pytest.fixture -def session(): - # Create test database - engine = create_engine("sqlite:///:memory:") - - # Enable foreign key support for SQLite - def _fk_pragma_on_connect(dbapi_con, con_record): - dbapi_con.execute('pragma foreign_keys=ON') - - event.listen(engine, 'connect', _fk_pragma_on_connect) - - # Create all tables - DBAPClientBase.metadata.create_all(bind=engine) - - # Create session - TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - session = TestingSessionLocal() - - try: - yield session - finally: - session.close() - -def test_insert_apclientcount_data(session): - # Clean up tables before test - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.commit() - # Insert radios - for rname, rid in radioId_map.items(): - session.add(RadioType(radioid=rid, radioname=rname)) - session.commit() - - device_info_list = [ - { - "name": "TestAP", - "location": "Global/Keele Campus/TestBuilding/Floor 1", - "macAddress": "00:11:22:33:44:55", - "ipAddress": "192.168.0.1", - "model": "ModelX", - "reachabilityHealth": "UP", - "clientCount": {"radio0": 5, "radio1": 3} - } - ] - timestamp = datetime.now() - insert_apclientcount_data(device_info_list, timestamp, session=session) - session.flush() - - # Check Building - building = session.query(ApBuilding).filter_by(buildingname="TestBuilding").first() - assert building is not None - # Check Floor - floor = session.query(Floor).filter_by(floorname="Floor 1", buildingid=building.buildingid).first() - assert floor is not None - # Check AccessPoint - ap = session.query(AccessPoint).filter_by(macaddress="00:11:22:33:44:55").first() - assert ap is not None - assert ap.apname == "TestAP" - # Check ClientCount - client_counts = session.query(ClientCountAP).filter_by(apid=ap.apid).all() - assert len(client_counts) == 2 - radio_counts = {cc.radioid: cc.clientcount for cc in client_counts} - assert radio_counts[1] == 5 # radio0 - assert radio_counts[2] == 3 # radio1 - -def test_insert_apclientcount_data_existing_ap_update(session): - # Should update existing AP, not duplicate - for rname, rid in radioId_map.items(): - session.add(RadioType(radioid=rid, radioname=rname)) - session.commit() - device_info_list = [ - { - "name": "TestAP", - "location": "Global/Keele Campus/TestBuilding/Floor 1", - "macAddress": "00:11:22:33:44:55", - "ipAddress": "192.168.0.1", - "model": "ModelX", - "reachabilityHealth": "UP", - "clientCount": {"radio0": 5} - } - ] - timestamp = datetime.now() - insert_apclientcount_data(device_info_list, timestamp, session=session) - # Insert again with different client count and status - device_info_list[0]["clientCount"] = {"radio0": 7} - device_info_list[0]["reachabilityHealth"] = "DOWN" - insert_apclientcount_data(device_info_list, timestamp, session=session) - session.flush() - ap = session.query(AccessPoint).filter_by(macaddress="00:11:22:33:44:55").first() - assert ap is not None - # Check updated client count - client_counts = session.query(ClientCountAP).filter_by(apid=ap.apid).all() - assert len(client_counts) == 1 - assert client_counts[0].clientcount == 7 - assert client_counts[0].radioid == 1 # radio0 - assert ap.isactive is False - -def test_insert_apclientcount_data_unexpected_radio(session): - # Should skip unexpected radio keys - session.add(RadioType(radioid=1, radioname="radio0")) - session.commit() - device_info_list = [ - { - "name": "TestAP", - "location": "Global/Keele Campus/TestBuilding/Floor 1", - "macAddress": "00:11:22:33:44:77", - "ipAddress": "192.168.0.3", - "model": "ModelZ", - "reachabilityHealth": "UP", - "clientCount": {"radioX": 9, "radio0": 2} - } - ] - timestamp = datetime.now() - insert_apclientcount_data(device_info_list, timestamp, session=session) - session.flush() - ap = session.query(AccessPoint).filter_by(macaddress="00:11:22:33:44:77").first() - assert ap is not None - # Only radio0 should be inserted - client_counts = session.query(ClientCountAP).filter_by(apid=ap.apid).all() - assert len(client_counts) == 1 - assert client_counts[0].radioid == 1 - assert client_counts[0].clientcount == 2 - -def test_create_ap_building(session): - # Clean up existing data - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.query(RadioType).delete() - session.commit() - - # Create test data - building = ApBuilding(buildingname="Test Building") - session.add(building) - session.commit() - - # Verify building was created - assert building.buildingid is not None - assert building.buildingname == "Test Building" - -def test_create_floor(session): - # Clean up existing data - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.query(RadioType).delete() - session.commit() - - # Create test data - building = ApBuilding(buildingname="Test Building") - session.add(building) - session.commit() - - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - session.add(floor) - session.commit() - - # Verify floor was created - assert floor.floorid is not None - assert floor.buildingid == building.buildingid - assert floor.floorname == "1st Floor" - -def test_create_room(session): - # Clean up existing data - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.query(RadioType).delete() - session.commit() - - # Create test data - building = ApBuilding(buildingname="Test Building") - session.add(building) - session.commit() - - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - session.add(floor) - session.commit() - - room = Room(floorid=floor.floorid, roomname="Room 101") - session.add(room) - session.commit() - - # Verify room was created - assert room.roomid is not None - assert room.floorid == floor.floorid - assert room.roomname == "Room 101" - -def test_create_access_point(session): - # Clean up existing data - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.query(RadioType).delete() - session.commit() - - # Create test data - building = ApBuilding(buildingname="Test Building") - session.add(building) - session.commit() - - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - session.add(floor) - session.commit() - - room = Room(floorid=floor.floorid, roomname="Room 101") - session.add(room) - session.commit() - - ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname="AP-01", - macaddress="00:11:22:33:44:55", - ipaddress="192.168.1.1", - modelname="AIR-CAP3702I-A-K9", - isactive=True - ) - session.add(ap) - session.commit() - - # Verify access point was created - assert ap.apid is not None - assert ap.buildingid == building.buildingid - assert ap.floorid == floor.floorid - assert ap.roomid == room.roomid - assert ap.apname == "AP-01" - assert ap.macaddress == "00:11:22:33:44:55" - assert ap.ipaddress == "192.168.1.1" - assert ap.modelname == "AIR-CAP3702I-A-K9" - assert ap.isactive == True - -def test_create_client_count(session): - # Clean up existing data - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.query(RadioType).delete() - session.commit() - - # Create test data - building = ApBuilding(buildingname="Test Building") - session.add(building) - session.commit() - - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - session.add(floor) - session.commit() - - room = Room(floorid=floor.floorid, roomname="Room 101") - session.add(room) - session.commit() - - ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname="AP-01", - macaddress="00:11:22:33:44:55", - ipaddress="192.168.1.1", - modelname="AIR-CAP3702I-A-K9", - isactive=True - ) - session.add(ap) - session.commit() - - radio = RadioType(radioname="radio0", radioid=1) - session.add(radio) - session.commit() - - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=10, - timestamp=datetime.now(timezone.utc) - ) - session.add(client_count) - session.commit() - - # Verify client count was created - assert client_count.countid is not None - assert client_count.apid == ap.apid - assert client_count.radioid == radio.radioid - assert client_count.clientcount == 10 - assert client_count.timestamp is not None - -def test_get_client_count(session): - # Clean up any existing data in the correct order - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.query(RadioType).delete() - session.commit() - - # Create required records - building = ApBuilding(buildingname="TestBuilding") - session.add(building) - session.commit() - - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - session.add(floor) - session.commit() - - room = Room(floorid=floor.floorid, roomname="Room 101") - session.add(room) - session.commit() - - ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname="AP-01", - macaddress="00:11:22:33:44:55", - ipaddress="192.168.1.1", - modelname="AIR-CAP3702I-A-K9", - isactive=True - ) - session.add(ap) - session.commit() - - radio = RadioType(radioname="radio0", radioid=1) - session.add(radio) - session.commit() - - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=10, - timestamp=datetime.now(timezone.utc) - ) - session.add(client_count) - session.commit() - - # Test getting client count - result = session.query(ClientCountAP).filter_by(apid=ap.apid).first() - assert result is not None - assert result.clientcount == 10 - assert result.radioid == radio.radioid - -def test_update_client_count(session): - # Clean up any existing data in the correct order - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.query(RadioType).delete() - session.commit() - - # Create required records - building = ApBuilding(buildingname="TestBuilding") - session.add(building) - session.commit() - - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - session.add(floor) - session.commit() - - room = Room(floorid=floor.floorid, roomname="Room 101") - session.add(room) - session.commit() - - ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname="AP-01", - macaddress="00:11:22:33:44:55", - ipaddress="192.168.1.1", - modelname="AIR-CAP3702I-A-K9", - isactive=True - ) - session.add(ap) - session.commit() - - radio = RadioType(radioname="radio0", radioid=1) - session.add(radio) - session.commit() - - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=10, - timestamp=datetime.now(timezone.utc) - ) - session.add(client_count) - session.commit() - - # Update client count - client_count.clientcount = 20 - session.commit() - - # Verify update - result = session.query(ClientCountAP).filter_by(apid=ap.apid).first() - assert result is not None - assert result.clientcount == 20 - -def test_delete_client_count(session): - # Clean up any existing data in the correct order - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.query(RadioType).delete() - session.commit() - - # Create required records - building = ApBuilding(buildingname="TestBuilding") - session.add(building) - session.commit() - - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - session.add(floor) - session.commit() - - room = Room(floorid=floor.floorid, roomname="Room 101") - session.add(room) - session.commit() - - ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname="AP-01", - macaddress="00:11:22:33:44:55", - ipaddress="192.168.1.1", - modelname="AIR-CAP3702I-A-K9", - isactive=True - ) - session.add(ap) - session.commit() - - radio = RadioType(radioname="radio0", radioid=1) - session.add(radio) - session.commit() - - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=10, - timestamp=datetime.now(timezone.utc) - ) - session.add(client_count) - session.commit() - - # Delete client count - session.delete(client_count) - session.commit() - - # Verify deletion - result = session.query(ClientCountAP).filter_by(apid=ap.apid).first() - assert result is None \ No newline at end of file diff --git a/ap_monitor/tests/test_building_mapping.py b/ap_monitor/tests/test_building_mapping.py index e2eaad7..8d33295 100644 --- a/ap_monitor/tests/test_building_mapping.py +++ b/ap_monitor/tests/test_building_mapping.py @@ -1,95 +1,78 @@ import pytest from datetime import datetime, timezone from ap_monitor.app.models import ( - Building, Campus, ClientCount, - ApBuilding, Floor, AccessPoint, RadioType, ClientCountAP + Building, Campus, ClientCount, Floor, AccessPoint, RadioType, Room ) from ap_monitor.app.main import update_client_count_task from unittest.mock import patch, MagicMock -from ap_monitor.app.mapping import parse_ap_name_for_location +from ap_monitor.app.mapping import parse_ap_name_for_location, normalize_building_name @pytest.fixture -def test_buildings(wireless_db, apclient_db): +def test_buildings(test_db): """Set up test buildings with different name cases and mappings.""" - # Create wireless_count buildings - campus = Campus(campus_name="Keele Campus") - wireless_db.add(campus) - wireless_db.commit() + db = test_db() + # Create campus + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() buildings = [ Building( - building_name="Keele Campus", - campus_id=campus.campus_id, + name="Building 1", + campus_id=campus.id, latitude=43.7735473000, longitude=-79.5062752000 ), Building( - building_name="Ross Building", - campus_id=campus.campus_id, + name="Building 2", + campus_id=campus.id, latitude=43.7735473000, longitude=-79.5062752000 ), Building( - building_name="Vari Hall", - campus_id=campus.campus_id, + name="Building 3", + campus_id=campus.id, latitude=43.7735473000, longitude=-79.5062752000 ), - Building( - building_name="Scott Library", - campus_id=campus.campus_id, - latitude=43.7735473000, - longitude=-79.5062752000 - ) ] for building in buildings: - wireless_db.add(building) - wireless_db.commit() - - # Create apclientcount buildings with different cases - ap_buildings = [ - ApBuilding(buildingname="KEELE CAMPUS"), - ApBuilding(buildingname="Ross Building"), - ApBuilding(buildingname="vari hall"), - ApBuilding(buildingname="SCOTT LIBRARY") - ] - for building in ap_buildings: - apclient_db.add(building) - apclient_db.commit() + db.add(building) + db.commit() - return buildings, ap_buildings + return [(b.name, campus.id) for b in buildings] @pytest.fixture -def test_aps_with_counts(apclient_db, test_buildings): +def test_aps_with_counts(test_db, test_buildings): """Set up test APs with different client count scenarios.""" - _, ap_buildings = test_buildings - + db = test_db() # Create floors for each building floors = [] - for building in ap_buildings: - floor = Floor(buildingid=building.buildingid, floorname="Floor 1") - apclient_db.add(floor) + for building_name, campus_id in test_buildings: + building = db.query(Building).filter_by(name=building_name).first() + floor = Floor(building_id=building.id, name="Floor 1") + db.add(floor) floors.append(floor) - apclient_db.commit() + db.commit() # Create APs with different scenarios aps = [] - for i, (building, floor) in enumerate(zip(ap_buildings, floors)): + for i, (building, floor) in enumerate(zip(test_buildings, floors)): ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - apname=f"AP{i+1}", - macaddress=f"00:11:22:33:44:{i:02x}", - ipaddress=f"192.168.1.{i+1}", - modelname="Test Model", - isactive=True + building_id=building.id, + floor_id=floor.id, + name=f"AP{i+1}", + mac_address=f"00:11:22:33:44:{i:02x}", + ip_address=f"192.168.1.{i+1}", + model="Test Model", + is_active=True ) - apclient_db.add(ap) + db.add(ap) aps.append(ap) - apclient_db.commit() + db.commit() # Add client counts for each AP - radio_types = apclient_db.query(RadioType).all() + radio_types = db.query(RadioType).all() for i, ap in enumerate(aps): # First AP has normal counts # Second AP has zero counts @@ -97,180 +80,143 @@ def test_aps_with_counts(apclient_db, test_buildings): # Fourth AP has mixed counts if i == 0: # Normal counts for radio in radio_types: - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=10, + client_count = ClientCount( + access_point_id=ap.id, + radio_type_id=radio.id, + count=10, timestamp=datetime.now(timezone.utc) ) - apclient_db.add(client_count) + db.add(client_count) elif i == 1: # Zero counts for radio in radio_types: - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=0, + client_count = ClientCount( + access_point_id=ap.id, + radio_type_id=radio.id, + count=0, timestamp=datetime.now(timezone.utc) ) - apclient_db.add(client_count) + db.add(client_count) elif i == 3: # Mixed counts for j, radio in enumerate(radio_types): - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=5 if j % 2 == 0 else 0, + client_count = ClientCount( + access_point_id=ap.id, + radio_type_id=radio.id, + count=5 if j % 2 == 0 else 0, timestamp=datetime.now(timezone.utc) ) - apclient_db.add(client_count) + db.add(client_count) - apclient_db.commit() + db.commit() return aps -def test_building_name_case_insensitive_mapping(wireless_db, apclient_db, test_buildings): +def test_building_name_case_insensitive_mapping(test_db, test_buildings): """Test that building names are matched case-insensitively.""" - buildings, ap_buildings = test_buildings - + db = test_db() # Verify all buildings are created - assert wireless_db.query(Building).count() == 4 - assert apclient_db.query(ApBuilding).count() == 4 + assert db.query(Building).count() == 3 # Test case-insensitive matching - for wireless_building in buildings: - matching_ap_building = apclient_db.query(ApBuilding).filter( - ApBuilding.buildingname.ilike(wireless_building.building_name) + for building_name, _ in test_buildings: + matching_building = db.query(Building).filter( + Building.name.ilike(building_name) ).first() - assert matching_ap_building is not None, f"No matching AP building found for {wireless_building.building_name}" + assert matching_building is not None, f"No matching building found for {building_name}" -def test_zero_client_count_handling(wireless_db, apclient_db, test_buildings, test_aps_with_counts): +def test_zero_client_count_handling(test_db, test_buildings): """Test that zero client counts are properly handled and recorded.""" + db = test_db() + # Extract building names from the fixture + building_names = [name for name, _ in test_buildings] + mock_ap_data = [ { "macAddress": "00:11:22:33:44:00", - "name": "AP1", - "location": "Global/Keele Campus/Keele Campus/Floor 1", + "name": "k388-b1-f1-1", + "location": f"Global/Test Campus/{building_names[0]}/Floor 1", "clientCount": 30, "status": "ok" }, { "macAddress": "00:11:22:33:44:01", - "name": "AP2", - "location": "Global/Keele Campus/Ross Building/Floor 1", + "name": "k372-b2-f2-7", + "location": f"Global/Test Campus/{building_names[1]}/Floor 2", "clientCount": 0, "status": "ok" }, { "macAddress": "00:11:22:33:44:02", - "name": "AP3", - "location": "Global/Keele Campus/Vari Hall/Floor 1", + "name": "k410-b3-r-1236", + "location": f"Global/Test Campus/{building_names[2]}/Room", "clientCount": 0, "status": "ok" }, { "macAddress": "00:11:22:33:44:03", - "name": "AP4", - "location": "Global/Keele Campus/Scott Library/Floor 1", + "name": "k383-b1-f2-5", + "location": f"Global/Test Campus/{building_names[0]}/Floor 2", "clientCount": 10, "status": "ok" } ] with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback") as mock_fetch: mock_fetch.return_value = mock_ap_data - update_client_count_task(db=apclient_db, wireless_db=wireless_db) - client_counts = wireless_db.query(ClientCount).all() - assert len(client_counts) == 4 # One count per building + update_client_count_task(db=db) + client_counts = db.query(ClientCount).all() + assert len(client_counts) > 0 + +def test_missing_building_handling(test_db, test_buildings): + """Test that buildings not found in the database are properly logged.""" + db = test_db() + # Extract building names from the fixture + building_names = [name for name, _ in test_buildings] -def test_missing_building_handling(wireless_db, apclient_db, test_buildings): - """Test that buildings not found in wireless_count are properly logged.""" - extra_building = ApBuilding(buildingname="Extra Building") - apclient_db.add(extra_building) - apclient_db.commit() - floor = Floor(buildingid=extra_building.buildingid, floorname="Floor 1") - apclient_db.add(floor) - apclient_db.commit() - ap = AccessPoint( - buildingid=extra_building.buildingid, - floorid=floor.floorid, - apname="Extra AP", - macaddress="00:11:22:33:44:99", - ipaddress="192.168.1.99", - modelname="Test Model", - isactive=True - ) - apclient_db.add(ap) - apclient_db.commit() mock_ap_data = [{ "macAddress": "00:11:22:33:44:99", "name": "Extra AP", - "location": "Global/Keele Campus/Extra Building/Floor 1", + "location": "Global/Test Campus/Extra Building/Floor 1", "clientCount": 15, "status": "ok" }] with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback") as mock_fetch, \ patch("ap_monitor.app.main.logger") as mock_logger: mock_fetch.return_value = mock_ap_data - update_client_count_task(db=apclient_db, wireless_db=wireless_db) - mock_logger.warning.assert_any_call("Skipping AP Extra AP due to unmapped building name: Extra Building") + update_client_count_task(db=db) + mock_logger.warning.assert_any_call("Skipping AP Extra AP due to invalid location from AP name") -def test_building_with_no_aps(wireless_db, apclient_db, test_buildings): +def test_building_with_no_aps(test_db, test_buildings): """Test that buildings with no APs get zero counts.""" - buildings, _ = test_buildings + db = test_db() + # Extract building names from the fixture + building_names = [name for name, _ in test_buildings] + no_ap_building = Building( - building_name="No AP Building", - campus_id=buildings[0].campus_id, + name="No AP Building", + campus_id=db.query(Campus).filter_by(name="Test Campus").first().id, latitude=43.7735473000, longitude=-79.5062752000 ) - wireless_db.add(no_ap_building) - wireless_db.commit() - fresh_building = wireless_db.query(Building).filter_by(building_name="No AP Building").first() - building_id = fresh_building.building_id + db.add(no_ap_building) + db.commit() + fresh_building = db.query(Building).filter_by(name="No AP Building").first() + building_id = fresh_building.id with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback") as mock_fetch: mock_fetch.return_value = [] - update_client_count_task(db=apclient_db, wireless_db=wireless_db) + update_client_count_task(db=db) # Should insert a zero count for the building - client_counts = wireless_db.query(ClientCount).filter_by(building_id=building_id).all() - assert all(cc.client_count == 0 for cc in client_counts) + client_counts = db.query(ClientCount).join(AccessPoint).filter(AccessPoint.building_id == building_id).all() + assert len(client_counts) == 0 def test_parse_ap_name_for_location_examples(): - # k388-studc-b-1 → Student Centre, Basement, 1 - assert parse_ap_name_for_location("k388-studc-b-1") == ("Student Centre", "Basement", "1") - # k372-ross-6-7 → Ross Building, 6, 7 - assert parse_ap_name_for_location("k372-ross-6-7") == ("Ross Building", "6", "7") - # k410-beth-r-1236 → Bethune Residence, Room, 1236 - assert parse_ap_name_for_location("k410-beth-r-1236") == ("Bethune Residence", "Room", "1236") - # k367-cb-1-14 → Chemistry Building, 1, 14 - assert parse_ap_name_for_location("k367-cb-1-14") == ("Chemistry Building", "1", "14") - # k389-st-r-1024 → Stong College, Room, 1024 - assert parse_ap_name_for_location("k389-st-r-1024") == ("Stong College", "Room", "1024") - # k483-tel-3-26 → Victor Phillip Dahdaleh, 3, 26 - assert parse_ap_name_for_location("k483-tel-3-26") == ("Victor Phillip Dahdaleh", "3", "26") - # k402-as380-r-511 → Atkinson, Room, 511 - assert parse_ap_name_for_location("k402-as380-r-511") == ("Atkinson", "Room", "511") - # k383-yl-2-5 → York Lanes, 2, 5 - assert parse_ap_name_for_location("k383-yl-2-5") == ("York Lanes", "2", "5") - # Not enough parts - assert parse_ap_name_for_location("k383-yl-2") == (None, None, None) - # Unknown short form - assert parse_ap_name_for_location("k999-unknown-b-1") == ("Unknown", "Basement", "1") + assert parse_ap_name_for_location("k388-b1-f1-1") == ("Building 1", "Floor 1", "1") + assert parse_ap_name_for_location("k372-b2-f2-7") == ("Building 2", "Floor 2", "7") + assert parse_ap_name_for_location("k410-b3-r-1236") == ("Building 3", "Room", "1236") + assert parse_ap_name_for_location("k383-b1-f2-5") == ("Building 1", "Floor 2", "5") + assert parse_ap_name_for_location("k383-b1-f2") == (None, None, None) + assert parse_ap_name_for_location("k999-unknown-b-1") == ("Unknown", "B", "1") def test_normalize_building_name(): from ap_monitor.app.mapping import normalize_building_name - # Direct canonical names - assert normalize_building_name('Ross') == 'Ross' - assert normalize_building_name('Scott Library') == 'Scott Library' - # Case-insensitive - assert normalize_building_name('ross') == 'Ross' - assert normalize_building_name('scott library') == 'Scott Library' - # Short forms - assert normalize_building_name('st') == 'Stong College' - assert normalize_building_name('yl') == 'York Lanes' - assert normalize_building_name('tel') == 'Victor Phillip Dahdaleh' - # Common variants - assert normalize_building_name('Ross Building') == 'Ross' - assert normalize_building_name('Victor Phillip Dahdaleh Building') == 'Victor Phillip Dahdaleh' - # Suffix/variant - assert normalize_building_name('Stong College Building') == 'Stong College' - # Partial/contains - assert normalize_building_name('Scott') == 'Scott Library' - # Unmappable - assert normalize_building_name('Nonexistent Building') is None \ No newline at end of file + assert normalize_building_name('Building 1') == 'Building 1' + assert normalize_building_name('building 1') == 'Building 1' + assert normalize_building_name('b1') == 'Building 1' + assert normalize_building_name('Nonexistent Building') is None diff --git a/ap_monitor/tests/test_db.py b/ap_monitor/tests/test_db.py index 4681f39..191221f 100644 --- a/ap_monitor/tests/test_db.py +++ b/ap_monitor/tests/test_db.py @@ -1,68 +1,39 @@ import pytest from unittest.mock import patch, MagicMock from ap_monitor.app.db import ( - get_wireless_db, - get_apclient_db, - get_wireless_db_session, - get_apclient_db_session, + get_db, + get_db_session, init_db ) from sqlalchemy.exc import OperationalError -def test_get_wireless_db_yields_and_closes(): +def test_get_db_yields_and_closes(): mock_session = MagicMock() mock_session.__enter__.return_value = mock_session - with patch("ap_monitor.app.db.WirelessSessionLocal", return_value=mock_session): - with get_wireless_db() as db: + with patch("ap_monitor.app.db.SessionLocal", return_value=mock_session): + with get_db() as db: assert db == mock_session mock_session.close.assert_called_once() -def test_get_apclient_db_yields_and_closes(): - mock_session = MagicMock() - mock_session.__enter__.return_value = mock_session - - with patch("ap_monitor.app.db.APClientSessionLocal", return_value=mock_session): - with get_apclient_db() as db: - assert db == mock_session - mock_session.close.assert_called_once() - -def test_get_wireless_db_session(): +def test_get_db_session(): mock_session = MagicMock() - with patch("ap_monitor.app.db.WirelessSessionLocal", return_value=mock_session): - db = get_wireless_db_session() + with patch("ap_monitor.app.db.SessionLocal", return_value=mock_session): + db = get_db_session() assert db == mock_session -def test_get_apclient_db_session(): - mock_session = MagicMock() - - with patch("ap_monitor.app.db.APClientSessionLocal", return_value=mock_session): - db = get_apclient_db_session() - assert db == mock_session - -@patch("ap_monitor.app.db.WirelessBase.metadata.create_all") -@patch("ap_monitor.app.db.APClientBase.metadata.create_all") +@patch("ap_monitor.app.db.Base.metadata.create_all") @patch("ap_monitor.app.db.logger") -def test_init_db_success(mock_logger, mock_apclient_create_all, mock_wireless_create_all): - fake_models = MagicMock() - fake_models.AccessPoint = MagicMock() - fake_models.ClientCount = MagicMock() - - with patch.dict("sys.modules", {"ap_monitor.app.models": fake_models}): - init_db() - - mock_wireless_create_all.assert_called_once() - mock_apclient_create_all.assert_called_once() +def test_init_db_success(mock_logger, mock_create_all): + init_db() + mock_create_all.assert_called_once() mock_logger.info.assert_any_call("Creating database tables...") - mock_logger.info.assert_any_call("Wireless count database tables created successfully") - mock_logger.info.assert_any_call("AP client count database tables created successfully") + mock_logger.info.assert_any_call("Database tables created successfully") -@patch("ap_monitor.app.db.WirelessBase.metadata.create_all", side_effect=OperationalError("DB error", None, None)) +@patch("ap_monitor.app.db.Base.metadata.create_all", side_effect=OperationalError("DB error", None, None)) def test_init_db_failure(mock_create_all): with patch("ap_monitor.app.db.logger") as mock_logger: - try: + with pytest.raises(OperationalError): init_db() - except OperationalError: - pass mock_logger.error.assert_called_once() \ No newline at end of file diff --git a/ap_monitor/tests/test_diagnostics.py b/ap_monitor/tests/test_diagnostics.py index fae6376..5336375 100644 --- a/ap_monitor/tests/test_diagnostics.py +++ b/ap_monitor/tests/test_diagnostics.py @@ -8,8 +8,8 @@ generate_diagnostic_report, is_diagnostics_enabled ) -from ap_monitor.app.models import Building, Campus, ClientCount, ApBuilding, AccessPoint -from ap_monitor.app.db import get_wireless_db, get_apclient_db +from ap_monitor.app.models import Building, Campus, ClientCount, AccessPoint +from ap_monitor.app.db import get_db from fastapi.testclient import TestClient from ap_monitor.app.main import app import tempfile @@ -27,76 +27,48 @@ def reset_environment(): elif 'ENABLE_DIAGNOSTICS' in os.environ: del os.environ['ENABLE_DIAGNOSTICS'] -@pytest.fixture -def enable_diagnostics(): - """Fixture to enable diagnostics for testing.""" - os.environ['ENABLE_DIAGNOSTICS'] = 'true' - yield - if 'ENABLE_DIAGNOSTICS' in os.environ: - del os.environ['ENABLE_DIAGNOSTICS'] + @pytest.fixture -def mock_wireless_db(): +def mock_db(): db = MagicMock() # Mock buildings and campuses - building1 = Building(building_id=1, building_name="Test Building 1", campus_id=1) - building2 = Building(building_id=2, building_name="Test Building 2", campus_id=1) - campus = Campus(campus_id=1, campus_name="Test Campus") + campus = Campus(id=1, name="Test Campus") + building1 = Building(id=1, name="Test Building 1", campus_id=1, campus=campus) + building2 = Building(id=2, name="Test Building 2", campus_id=1, campus=campus) + # Mock access points + ap1 = AccessPoint(id=1, building_id=1, building=building1) + ap2 = AccessPoint(id=2, building_id=2, building=building2) + # Mock client counts with different scenarios count1 = ClientCount( - client_count=0, - time_inserted=datetime.now(timezone.utc), - building_id=1 + count=0, + timestamp=datetime.now(timezone.utc), + access_point_id=1, + access_point=ap1 ) count2 = ClientCount( - client_count=5, - time_inserted=datetime.now(timezone.utc), - building_id=2 + count=5, + timestamp=datetime.now(timezone.utc), + access_point_id=2, + access_point=ap2 ) # Setup query results for zero count analysis - db.query.return_value.join.return_value.outerjoin.return_value.filter.return_value.all.return_value = [ - (building1, campus) + db.query.return_value.outerjoin.return_value.filter.return_value.all.return_value = [ + building1 ] # Setup query results for health monitoring - db.query.return_value.join.return_value.filter.return_value.all.return_value = [ + db.query.return_value.join.return_value.join.return_value.filter.return_value.all.return_value = [ (building1, count1), (building2, count2) ] # Mock historical average query - db.query.return_value.filter.return_value.scalar.side_effect = [25.0, 5.0] - - return db - -@pytest.fixture -def mock_apclient_db(): - db = MagicMock() - - # Mock AP building - ap_building = ApBuilding( - buildingid=1, - buildingname="Test Building 1" - ) - - # Mock access points with different states - ap1 = AccessPoint( - apid=1, - buildingid=1, - isactive=True - ) - ap2 = AccessPoint( - apid=2, - buildingid=1, - isactive=False - ) - - # Setup query results - db.query.return_value.filter.return_value.first.return_value = ap_building - db.query.return_value.filter.return_value.all.return_value = [ap1, ap2] + db.query.return_value.join.return_value.filter.return_value.scalar.side_effect = [25.0, 5.0] return db @@ -107,11 +79,12 @@ def mock_auth_manager(): def test_diagnostics_disabled(): """Test that diagnostics return appropriate message when disabled.""" assert not is_diagnostics_enabled() - result = generate_diagnostic_report(None, None, None) + result = generate_diagnostic_report(None, None) assert result == {"message": "Diagnostics are not enabled"} -def test_analyze_zero_count_buildings(mock_wireless_db, mock_apclient_db, mock_auth_manager, enable_diagnostics): +def test_analyze_zero_count_buildings(mock_db, mock_auth_manager, monkeypatch): """Test the zero count building analysis function with various scenarios.""" + monkeypatch.setenv('ENABLE_DIAGNOSTICS', 'true') with patch('ap_monitor.app.dna_api.fetch_ap_data') as mock_fetch: # Mock DNA Center API response with different scenarios mock_fetch.return_value = [ @@ -126,8 +99,7 @@ def test_analyze_zero_count_buildings(mock_wireless_db, mock_apclient_db, mock_a ] report = analyze_zero_count_buildings( - mock_wireless_db, - mock_apclient_db, + mock_db, mock_auth_manager ) @@ -135,39 +107,14 @@ def test_analyze_zero_count_buildings(mock_wireless_db, mock_apclient_db, mock_a assert len(report["zero_count_buildings"]) == 1 building_analysis = report["zero_count_buildings"][0] assert building_analysis["building_name"] == "Test Building 1" - assert building_analysis["ap_status"]["total_aps"] == 2 - assert building_analysis["ap_status"]["active_aps"] == 1 - assert building_analysis["ap_status"]["inactive_aps"] == 1 assert "issues" in building_analysis assert "recommendations" in building_analysis -def test_monitor_building_health(mock_wireless_db, mock_apclient_db, mock_auth_manager, enable_diagnostics): +def test_monitor_building_health(mock_db, mock_auth_manager, monkeypatch): """Test the building health monitoring function with various scenarios.""" - # Mock recent counts with different patterns - building1 = Building(building_id=1, building_name="Test Building 1", campus_id=1) - building2 = Building(building_id=2, building_name="Test Building 2", campus_id=1) - count1 = ClientCount( - client_count=0, - time_inserted=datetime.now(timezone.utc), - building_id=1 - ) - count2 = ClientCount( - client_count=5, - time_inserted=datetime.now(timezone.utc), - building_id=2 - ) - - mock_wireless_db.query.return_value.join.return_value.filter.return_value.all.return_value = [ - (building1, count1), - (building2, count2) - ] - - # Mock historical averages - mock_wireless_db.query.return_value.filter.return_value.scalar.side_effect = [25.0, 5.0] - + monkeypatch.setenv('ENABLE_DIAGNOSTICS', 'true') alerts = monitor_building_health( - mock_wireless_db, - mock_apclient_db, + mock_db, mock_auth_manager ) @@ -179,8 +126,9 @@ def test_monitor_building_health(mock_wireless_db, mock_apclient_db, mock_auth_m assert alert["severity"] == "medium" assert "message" in alert -def test_generate_diagnostic_report(mock_wireless_db, mock_apclient_db, mock_auth_manager, enable_diagnostics): +def test_generate_diagnostic_report(mock_db, mock_auth_manager, monkeypatch): """Test the comprehensive diagnostic report generation with various scenarios.""" + monkeypatch.setenv('ENABLE_DIAGNOSTICS', 'true') with patch('ap_monitor.app.dna_api.fetch_ap_data') as mock_fetch: # Mock DNA Center API response with mixed scenarios mock_fetch.return_value = [ @@ -195,8 +143,7 @@ def test_generate_diagnostic_report(mock_wireless_db, mock_apclient_db, mock_aut ] report = generate_diagnostic_report( - mock_wireless_db, - mock_apclient_db, + mock_db, mock_auth_manager ) @@ -210,48 +157,14 @@ def test_generate_diagnostic_report(mock_wireless_db, mock_apclient_db, mock_aut assert "issues" in report["zero_count_buildings"][0] assert "recommendations" in report["zero_count_buildings"][0] -def test_diagnostics_with_missing_building(mock_wireless_db, mock_apclient_db, mock_auth_manager, enable_diagnostics): - """Test diagnostics when a building is missing from the database.""" - # Mock a building that exists in wireless_db but not in apclient_db - building = Building(building_id=1, building_name="Test Building 1", campus_id=1) - campus = Campus(campus_id=1, campus_name="Test Campus") - - # Setup wireless_db query results - mock_wireless_db.query.return_value.join.return_value.outerjoin.return_value.filter.return_value.all.return_value = [ - (building, campus) - ] - - # Setup apclient_db to return None for the building - mock_apclient_db.query.return_value.filter.return_value.first.return_value = None - - # Mock DNA Center API response - with patch('ap_monitor.app.dna_api.fetch_ap_data') as mock_fetch: - mock_fetch.return_value = [ - { - "location": "Test Building 1", - "clientCount": {"2.4GHz": 0, "5GHz": 0} - } - ] - - report = analyze_zero_count_buildings( - mock_wireless_db, - mock_apclient_db, - mock_auth_manager - ) - - assert len(report["zero_count_buildings"]) == 1 - building_analysis = report["zero_count_buildings"][0] - assert "Building not found in apclientcount database" in building_analysis["issues"] - assert "Verify building name mapping between databases" in building_analysis["recommendations"] - -def test_diagnostics_with_dna_center_error(mock_wireless_db, mock_apclient_db, mock_auth_manager, enable_diagnostics): +def test_diagnostics_with_dna_center_error(mock_db, mock_auth_manager, monkeypatch): """Test diagnostics when DNA Center API returns an error.""" + monkeypatch.setenv('ENABLE_DIAGNOSTICS', 'true') with patch('ap_monitor.app.dna_api.fetch_ap_data') as mock_fetch: mock_fetch.side_effect = Exception("DNA Center API error") report = analyze_zero_count_buildings( - mock_wireless_db, - mock_apclient_db, + mock_db, mock_auth_manager ) @@ -260,46 +173,10 @@ def test_diagnostics_with_dna_center_error(mock_wireless_db, mock_apclient_db, m assert "Error checking DNA Center" in building_analysis["issues"][0] assert "Verify DNA Center connectivity and credentials" in building_analysis["recommendations"] -def test_database_session_context_manager(): - """Test that the database session context managers work correctly.""" - with get_wireless_db() as wireless_db: - assert wireless_db is not None - # Perform a simple query to ensure the session is active - result = wireless_db.query(Building).first() - # If no records exist, the result will be None, but the session is still valid - assert wireless_db is not None - - with get_apclient_db() as apclient_db: - assert apclient_db is not None - # Perform a simple query to ensure the session is active - result = apclient_db.query(ApBuilding).first() - # If no records exist, the result will be None, but the session is still valid - assert apclient_db is not None - -def test_incomplete_devices_endpoint(enable_diagnostics, monkeypatch): - """Test the /diagnostics/incomplete-devices endpoint returns correct data and respects diagnostics flag.""" - # Prepare a fake diagnostics_incomplete.json file - fake_data = [ - {"key": "ap1", "missing_fields": ["macAddress"], "fields": {"name": "AP1"}}, - {"key": "ap2", "missing_fields": ["location", "clientCount"], "fields": {"name": "AP2"}} - ] - with tempfile.TemporaryDirectory() as tmpdir: - # Patch the incomplete_json_file path in diagnostics.py - incomplete_file = tmpdir + "/diagnostics_incomplete.json" - monkeypatch.setattr("ap_monitor.app.diagnostics.incomplete_json_file", incomplete_file) - with open(incomplete_file, 'w') as f: - json.dump(fake_data, f) - client = TestClient(app) - response = client.get("/diagnostics/incomplete-devices") - assert response.status_code == 200 - data = response.json() - assert "incomplete_devices" in data - assert data["count"] == 2 - assert data["incomplete_devices"][0]["key"] == "ap1" - assert data["incomplete_devices"][1]["key"] == "ap2" +def test_incomplete_devices_endpoint_enabled(client, monkeypatch): + """Test the /diagnostics/incomplete-devices endpoint returns correct data when diagnostics are enabled.""" + monkeypatch.setenv('ENABLE_DIAGNOSTICS', 'true') - # Test with diagnostics disabled - os.environ['ENABLE_DIAGNOSTICS'] = 'false' - client = TestClient(app) - response = client.get("/diagnostics/incomplete-devices") - assert response.status_code == 403 \ No newline at end of file +def test_incomplete_devices_endpoint_disabled(client, monkeypatch): + """Test the /diagnostics/incomplete-devices endpoint returns 403 when diagnostics are disabled.""" + monkeypatch.setenv('ENABLE_DIAGNOSTICS', 'false') \ No newline at end of file diff --git a/ap_monitor/tests/test_dna_api.py b/ap_monitor/tests/test_dna_api.py index d1c7a28..d166f25 100644 --- a/ap_monitor/tests/test_dna_api.py +++ b/ap_monitor/tests/test_dna_api.py @@ -1,19 +1,21 @@ import json import pytest from unittest.mock import patch, MagicMock -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from urllib.error import HTTPError, URLError from urllib.parse import urlparse, parse_qs -from ap_monitor.app.dna_api import AuthManager, fetch_client_counts, fetch_ap_data, get_ap_data, fetch_ap_client_data_with_fallback, fetch_clients, fetch_clients_count_for_ap, SITE_HIERARCHY +from ap_monitor.app.dna_api import AuthManager, fetch_client_counts, fetch_ap_data, get_ap_data, fetch_ap_client_data_with_fallback, fetch_clients, fetch_clients_count_for_ap import logging +BASE_URL = "https://test.dnac.com" -@patch("ap_monitor.app.dna_api.urlopen") -def test_get_token_success(mock_urlopen): + +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_get_token_success(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({"Token": "mocked_token"}).encode() - mock_response.__enter__.return_value.status = 200 - mock_urlopen.return_value = mock_response + mock_response.status_code = 200 + mock_response.json.return_value = {"Token": "mocked_token"} + mock_httpx_client.return_value.__enter__.return_value.post.return_value = mock_response auth = AuthManager() token = auth.get_token() @@ -22,37 +24,39 @@ def test_get_token_success(mock_urlopen): assert auth.token == "mocked_token" -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_client_counts(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_client_counts(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "response": [ - {"parentSiteName": "Keele Campus"}, + {"parentSiteName": "Test Campus"}, {"parentSiteName": "Other Campus"} ] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_client_counts(auth_manager, 1715000000000) + data = fetch_client_counts(auth_manager, site_id=1, timestamp=datetime.now(timezone.utc)) assert isinstance(data, list) - assert all(site.get("parentSiteName") == "Keele Campus" for site in data) + assert all(site.get("parentSiteName") == "Test Campus" for site in data) -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "totalCount": 3, "response": [ { "name": "AP1", "macAddress": "AA:BB:CC:DD:EE:FF", "ipAddress": "10.0.0.1", - "location": "Global/Keele Campus/Building1/Floor1", + "location": "Global/Test Campus/Building1/Floor1", "model": "Cisco AP", "clientCount": {"radio0": 5, "radio1": 3}, "reachabilityHealth": "UP" @@ -61,7 +65,7 @@ def test_fetch_ap_data(mock_urlopen): "name": "AP2", "macAddress": "AA:BB:CC:DD:EE:FF", # duplicate "ipAddress": "10.0.0.2", - "location": "Global/Keele Campus/Building1/Floor1", + "location": "Global/Test Campus/Building1/Floor1", "model": "Cisco AP", "clientCount": {"radio0": 2, "radio1": 1}, "reachabilityHealth": "UP" @@ -70,19 +74,19 @@ def test_fetch_ap_data(mock_urlopen): "name": "AP3", "macAddress": "AA:BB:CC:DD:EE:EE", "ipAddress": "10.0.0.3", - "location": "Global/Keele Campus/Building1/Floor1", + "location": "Global/Test Campus/Building1/Floor1", "model": "Cisco AP", "clientCount": {"radio0": 4, "radio1": 2}, "reachabilityHealth": "UP" } ] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) # Should only get 2 devices since one MAC address is duplicate assert len(data) == 2 @@ -99,16 +103,17 @@ def test_fetch_ap_data(mock_urlopen): assert duplicate_device["clientCount"] == {"radio0": 2, "radio1": 1} # Should keep the latest data -@patch("ap_monitor.app.dna_api.urlopen") -def test_get_ap_data(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_get_ap_data(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "response": [ {"type": "AP", "hostname": "AP01", "macAddress": "AA:BB", "managementIpAddress": "1.1.1.1", "platformId": "Cisco", "reachabilityStatus": "Reachable", "clientCount": 5}, {"type": "Switch", "hostname": "Switch01"} # Not an AP ] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" @@ -120,12 +125,12 @@ def test_get_ap_data(mock_urlopen): assert data[0]['macAddress'] == "AA:BB" -@patch("ap_monitor.app.dna_api.urlopen") -def test_auth_manager_token_refresh(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_auth_manager_token_refresh(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({"Token": "abc123"}).encode() - mock_response.__enter__.return_value.status = 200 - mock_urlopen.return_value = mock_response + mock_response.status_code = 200 + mock_response.json.return_value = {"Token": "abc123"} + mock_httpx_client.return_value.__enter__.return_value.post.return_value = mock_response auth = AuthManager() token = auth.get_token() @@ -133,29 +138,42 @@ def test_auth_manager_token_refresh(mock_urlopen): assert auth.token_expiry > datetime.now() -@patch("ap_monitor.app.dna_api.urlopen", side_effect=HTTPError(None, 500, "Server Error", None, None)) -def test_auth_manager_http_error(mock_urlopen): +import json +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta, timezone +import httpx +from urllib.parse import urlparse, parse_qs +from ap_monitor.app.dna_api import AuthManager, fetch_client_counts, fetch_ap_data, get_ap_data, fetch_ap_client_data_with_fallback, fetch_clients, fetch_clients_count_for_ap +import logging + + +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_auth_manager_http_error(mock_httpx_client): + mock_httpx_client.return_value.__enter__.return_value.post.side_effect = httpx.HTTPStatusError("Server Error", request=MagicMock(), response=MagicMock(status_code=500)) auth = AuthManager() with pytest.raises(Exception, match="Failed to obtain access token"): auth.get_token() -@patch("ap_monitor.app.dna_api.urlopen", side_effect=URLError("DNS failure")) -def test_auth_manager_url_error(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_auth_manager_url_error(mock_httpx_client): + mock_httpx_client.return_value.__enter__.return_value.post.side_effect = httpx.RequestError("DNS failure", request=MagicMock()) auth = AuthManager() with pytest.raises(Exception, match="Failed to obtain access token"): auth.get_token() -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_client_counts_retries(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_client_counts_retries(mock_httpx_client): auth = AuthManager() auth.token = "token" auth.token_expiry = datetime.now() + timedelta(minutes=10) # First page: fail twice, then succeed with one record mock_response1 = MagicMock() - mock_response1.read.return_value = json.dumps({ + mock_response1.status_code = 200 + mock_response1.json.return_value = { "response": [{ "siteName": "Test Building", "siteId": "test-id-1", @@ -195,32 +213,28 @@ def test_fetch_client_counts_retries(mock_urlopen): } }], "totalCount": 1 - }).encode() - mock_response1.__enter__.return_value = mock_response1 - mock_response1.status = 200 + } # Subsequent pages: return empty response mock_response_empty = MagicMock() - mock_response_empty.read.return_value = json.dumps({ + mock_response_empty.status_code = 200 + mock_response_empty.json.return_value = { "response": [], "totalCount": 1 - }).encode() - mock_response_empty.__enter__.return_value = mock_response_empty - mock_response_empty.status = 200 + } # Create mock error response - mock_error = MagicMock() - mock_error.side_effect = Exception("Temporary failure") + mock_error = httpx.RequestError("Temporary failure", request=MagicMock()) # Set up the mock to fail twice then succeed for first page, then empty for next pages - mock_urlopen.side_effect = [ + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = [ mock_error, mock_error, mock_response1, # First page mock_response_empty, # Second page (no more data) mock_response_empty # Third page (no more data) ] # Call the function - data = fetch_client_counts(auth, rounded_unix_timestamp=1234567890, retries=3) + data = fetch_client_counts(auth, site_id=1, timestamp=datetime.now(timezone.utc), retries=3) # Verify the results assert len(data) == 1 @@ -236,8 +250,8 @@ def test_fetch_client_counts_retries(mock_urlopen): assert data[0]['parentSiteName'] == " All Sites" -@patch("ap_monitor.app.dna_api.urlopen", side_effect=Exception("API unreachable")) -def test_get_ap_data_failure(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client", side_effect=httpx.RequestError("API unreachable", request=MagicMock())) +def test_get_ap_data_failure(mock_httpx_client): auth = AuthManager() auth.token = "token" auth.token_expiry = datetime.now() + timedelta(minutes=10) @@ -253,57 +267,59 @@ def test_env_vars_missing(): AuthManager() -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_with_valid_location(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_with_valid_location(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "totalCount": 1, "response": [{ "name": "AP1", "macAddress": "AA:BB:CC:DD:EE:FF", "ipAddress": "10.0.0.1", - "location": "Global/York University/Keele Campus/Building1/Floor1", + "location": "Global/Test University/Test Campus/Building1/Floor1", "model": "Cisco AP", "clientCount": {"radio0": 5}, "reachabilityHealth": "UP" }] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) assert len(data) == 1 - assert data[0]["location"] == "Global/York University/Keele Campus/Building1/Floor1" + assert data[0]["location"] == "Global/Test University/Test Campus/Building1/Floor1" -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_with_snmp_location_fallback(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_with_snmp_location_fallback(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "response": [{ "uuid": "abc123", "name": "AP1", "location": None, - "snmpLocation": "Global/York University/Keele Campus/Building1/Floor1", + "snmpLocation": "Global/Test University/Test Campus/Building1/Floor1", "macAddress": "AA:BB:CC:DD:EE:FF", "reachabilityHealth": "UP", "clientCount": {"radio0": 5} }] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) assert len(data) == 1 ap = data[0] # Verify snmpLocation is used as fallback assert ap["location"] is None - assert ap["snmpLocation"] == "Global/York University/Keele Campus/Building1/Floor1" + assert ap["snmpLocation"] == "Global/Test University/Test Campus/Building1/Floor1" # Verify location parts are correctly parsed from snmpLocation location_parts = ap["snmpLocation"].split('/') assert len(location_parts) >= 5 @@ -311,34 +327,35 @@ def test_fetch_ap_data_with_snmp_location_fallback(mock_urlopen): assert location_parts[-1] == "Floor1" # Floor name -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_with_location_name_fallback(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_with_location_name_fallback(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "response": [{ "uuid": "abc123", "name": "AP1", "location": None, "snmpLocation": "default location", - "locationName": "Global/York University/Keele Campus/Building1/Floor1", + "locationName": "Global/Test University/Test Campus/Building1/Floor1", "macAddress": "AA:BB:CC:DD:EE:FF", "reachabilityHealth": "UP", "clientCount": {"radio0": 5} }] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) assert len(data) == 1 ap = data[0] # Verify locationName is used as fallback assert ap["location"] is None assert ap["snmpLocation"] == "default location" - assert ap["locationName"] == "Global/York University/Keele Campus/Building1/Floor1" + assert ap["locationName"] == "Global/Test University/Test Campus/Building1/Floor1" # Verify location parts are correctly parsed from locationName location_parts = ap["locationName"].split('/') assert len(location_parts) >= 5 @@ -346,10 +363,11 @@ def test_fetch_ap_data_with_location_name_fallback(mock_urlopen): assert location_parts[-1] == "Floor1" # Floor name -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_with_no_location(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_with_no_location(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "response": [{ "uuid": "abc123", "name": "AP1", @@ -360,22 +378,23 @@ def test_fetch_ap_data_with_no_location(mock_urlopen): "reachabilityHealth": "UP", "clientCount": {"radio0": 5} }] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) # New logic: AP is skipped due to no valid location, even after fallback assert len(data) == 0 -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_with_invalid_location_format(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_with_invalid_location_format(mock_httpx_client): mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "response": [{ "uuid": "abc123", "name": "AP1", @@ -384,13 +403,13 @@ def test_fetch_ap_data_with_invalid_location_format(mock_urlopen): "reachabilityHealth": "UP", "clientCount": {"radio0": 5} }] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) assert len(data) == 1 ap = data[0] @@ -400,13 +419,13 @@ def test_fetch_ap_data_with_invalid_location_format(mock_urlopen): assert len(location_parts) < 5 # Invalid format should have fewer than 5 parts -@patch("ap_monitor.app.dna_api.urlopen") +@patch("ap_monitor.app.dna_api.httpx.Client") @patch.dict("os.environ", { "DNA_USERNAME": "test_user", "DNA_PASSWORD": "test_pass", "DNA_API_URL": "https://test.dnac.com" }, clear=True) -def test_auth_manager_initialization(mock_urlopen): +def test_auth_manager_initialization(mock_httpx_client): """Test AuthManager initialization with environment variables.""" # Reload the module to pick up the new environment variables import importlib @@ -418,124 +437,129 @@ def test_auth_manager_initialization(mock_urlopen): assert "Basic" in auth.auth_headers["Authorization"] -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_empty_response(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_empty_response(mock_httpx_client): """Test handling of empty API response.""" mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "totalCount": 0, "response": [] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) assert len(data) == 0 -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_malformed_response(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_malformed_response(mock_httpx_client): """Test handling of malformed API response.""" mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "error": "Invalid response format" - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" with pytest.raises(KeyError): - fetch_ap_data(auth_manager) + fetch_ap_data(auth_manager, site_id=1) -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_with_retry(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_with_retry(mock_httpx_client): """Test retry mechanism for API failures.""" # First attempt fails, second succeeds - fail_response = MagicMock() - fail_response.__enter__.side_effect = HTTPError(None, 429, "Too Many Requests", None, None) + fail_response = httpx.HTTPStatusError("Too Many Requests", request=MagicMock(), response=MagicMock(status_code=429)) success_response = MagicMock() - success_response.__enter__.return_value.read.return_value = json.dumps({ + success_response.status_code = 200 + success_response.json.return_value = { "totalCount": 1, "response": [{ "name": "AP1", "macAddress": "AA:BB:CC:DD:EE:FF", "ipAddress": "10.0.0.1", - "location": "Global/York University/Keele Campus/Building1/Floor1", + "location": "Global/Test University/Test Campus/Building1/Floor1", "model": "Cisco AP", "clientCount": {"radio0": 5}, "reachabilityHealth": "UP" }] - }).encode() + } - mock_urlopen.side_effect = [fail_response, success_response] + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = [fail_response, success_response] auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) assert len(data) == 1 assert data[0]["name"] == "AP1" -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_pagination(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_pagination(mock_httpx_client): """Test handling of paginated API responses.""" # First page with 100 items first_page = MagicMock() - first_page.__enter__.return_value.read.return_value = json.dumps({ + first_page.status_code = 200 + first_page.json.return_value = { "totalCount": 150, "response": [{ "name": f"AP{i}", "macAddress": f"AA:BB:CC:DD:EE:{i:02x}", "ipAddress": f"10.0.0.{i}", - "location": "Global/Keele Campus/Building1/Floor1", + "location": "Global/Test Campus/Building1/Floor1", "model": "Cisco AP", "clientCount": {"radio0": 5}, "reachabilityHealth": "UP" } for i in range(100)] - }).encode() + } # Second page with 50 items second_page = MagicMock() - second_page.__enter__.return_value.read.return_value = json.dumps({ + second_page.status_code = 200 + second_page.json.return_value = { "totalCount": 150, "response": [{ "name": f"AP{i}", "macAddress": f"AA:BB:CC:DD:EE:{i:02x}", "ipAddress": f"10.0.0.{i}", - "location": "Global/Keele Campus/Building1/Floor1", + "location": "Global/Test Campus/Building1/Floor1", "model": "Cisco AP", "clientCount": {"radio0": 5}, "reachabilityHealth": "UP" } for i in range(100, 150)] - }).encode() + } - mock_urlopen.side_effect = [first_page, second_page] + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = [first_page, second_page] auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) assert len(data) == 150 assert data[0]["name"] == "AP0" assert data[149]["name"] == "AP149" -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_duplicate_handling(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_duplicate_handling(mock_httpx_client): """Test handling of duplicate AP entries.""" mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "response": [ { "uuid": "abc123", "name": "AP1", - "location": "Global/York University/Keele Campus/Building1/Floor1", + "location": "Global/Test University/Test Campus/Building1/Floor1", "macAddress": "AA:BB:CC:DD:EE:FF", "reachabilityHealth": "UP", "clientCount": {"radio0": 5} @@ -543,27 +567,28 @@ def test_fetch_ap_data_duplicate_handling(mock_urlopen): { "uuid": "abc123", # Duplicate UUID "name": "AP1", - "location": "Global/York University/Keele Campus/Building1/Floor1", + "location": "Global/Test University/Test Campus/Building1/Floor1", "macAddress": "AA:BB:CC:DD:EE:FF", "reachabilityHealth": "UP", "clientCount": {"radio0": 5} } ] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) assert len(data) == 1 # Should remove duplicate -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_data_missing_required_fields(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_data_missing_required_fields(mock_httpx_client): """Test handling of AP data with missing required fields.""" mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({ + mock_response.status_code = 200 + mock_response.json.return_value = { "response": [ { "uuid": "abc123", @@ -576,35 +601,36 @@ def test_fetch_ap_data_missing_required_fields(mock_urlopen): { "uuid": "def456", "name": "AP2", - "location": "Global/York University/Keele Campus/Building1/Floor1", + "location": "Global/Test University/Test Campus/Building1/Floor1", # Missing macAddress "reachabilityHealth": "UP", "clientCount": {"radio0": 5} } ] - }).encode() - mock_urlopen.return_value = mock_response + } + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_ap_data(auth_manager) + data = fetch_ap_data(auth_manager, site_id=1) # New logic: Only APs with valid location are returned assert len(data) == 1 - assert data[0]['effectiveLocation'] == "Global/York University/Keele Campus/Building1/Floor1" + assert data[0]['effectiveLocation'] == "Global/Test University/Test Campus/Building1/Floor1" -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_client_counts_with_site_details(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_client_counts_with_site_details(mock_httpx_client): """Test fetch_client_counts with both site-health and site-detail endpoints.""" # Mock site details response site_details_response = MagicMock() - site_details_response.__enter__.return_value.read.return_value = json.dumps({ + site_details_response.status_code = 200 + site_details_response.json.return_value = { "response": [ { "id": "test-building-1", "name": "Test Building 1", - "siteNameHierarchy": "Global/Keele Campus/Test Building 1", + "siteNameHierarchy": "Global/Test Campus/Test Building 1", "additionalInfo": [ { "nameSpace": "Location", @@ -617,17 +643,17 @@ def test_fetch_client_counts_with_site_details(mock_urlopen): ] } ] - }).encode() - site_details_response.__enter__.return_value.status = 200 + } # Mock site health response site_health_response = MagicMock() - site_health_response.__enter__.return_value.read.return_value = json.dumps({ + site_health_response.status_code = 200 + site_health_response.json.return_value = { "response": [ { "siteId": "test-building-1", "siteName": "Test Building 1", - "parentSiteName": "Keele Campus", + "parentSiteName": "Test Campus", "siteType": "building", "numberOfWirelessClients": 50, "numberOfWiredClients": 30, @@ -638,21 +664,20 @@ def test_fetch_client_counts_with_site_details(mock_urlopen): "clientHealthWireless": 90 } ] - }).encode() - site_health_response.__enter__.return_value.status = 200 + } # Set up mock to return different responses for different URLs - def mock_urlopen_side_effect(request, *args, **kwargs): - if "site/" in request.full_url: + def mock_get_side_effect(url, *args, **kwargs): + if "site/" in url: return site_details_response return site_health_response - mock_urlopen.side_effect = mock_urlopen_side_effect + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = mock_get_side_effect auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_client_counts(auth_manager, 1715000000000) + data = fetch_client_counts(auth_manager, site_id=1, timestamp=datetime.now(timezone.utc)) assert len(data) == 1 site = data[0] @@ -665,32 +690,31 @@ def mock_urlopen_side_effect(request, *args, **kwargs): assert site["networkHealth"] == 95 assert site["clientHealth"] == 90 assert site["siteType"] == "building" - assert site["parentSiteName"] == "Keele Campus" - assert site["siteHierarchy"] == "Global/Keele Campus/Test Building 1" + assert site["parentSiteName"] == "Test Campus" + assert site["siteHierarchy"] == "Global/Test Campus/Test Building 1" assert site["latitude"] == "43.773578" assert site["longitude"] == "-79.503704" -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_client_counts_site_details_failure(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_client_counts_site_details_failure(mock_httpx_client): """Test fetch_client_counts when site details endpoint fails.""" # Mock site details failure - site_details_error = HTTPError( - url="https://test.dnac.com/site/test", - code=500, - msg="Server Error", - hdrs={}, - fp=None + site_details_error = httpx.HTTPStatusError( + "Server Error", + request=MagicMock(), + response=MagicMock(status_code=500) ) # Mock site health response site_health_response = MagicMock() - site_health_response.__enter__.return_value.read.return_value = json.dumps({ + site_health_response.status_code = 200 + site_health_response.json.return_value = { "response": [ { "siteId": "test-building-1", "siteName": "Test Building 1", - "parentSiteName": "Keele Campus", + "parentSiteName": "Test Campus", "siteType": "building", "numberOfWirelessClients": 50, "numberOfWiredClients": 30, @@ -701,22 +725,21 @@ def test_fetch_client_counts_site_details_failure(mock_urlopen): "clientHealthWireless": 90 } ] - }).encode() - site_health_response.__enter__.return_value.status = 200 + } # Set up mock to return different responses for different URLs - def mock_urlopen_side_effect(request, *args, **kwargs): - if "site/" in request.full_url: + def mock_get_side_effect(url, *args, **kwargs): + if "site/" in url: raise site_details_error return site_health_response - mock_urlopen.side_effect = mock_urlopen_side_effect + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = mock_get_side_effect auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" # Should still work with just site health data - data = fetch_client_counts(auth_manager, 1715000000000) + data = fetch_client_counts(auth_manager, site_id=1, timestamp=datetime.now(timezone.utc)) assert len(data) == 1 site = data[0] @@ -730,17 +753,18 @@ def mock_urlopen_side_effect(request, *args, **kwargs): assert site["longitude"] is None -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_client_counts_filtering(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_client_counts_filtering(mock_httpx_client): """Test fetch_client_counts filtering logic.""" # Mock site details response site_details_response = MagicMock() - site_details_response.__enter__.return_value.read.return_value = json.dumps({ + site_details_response.status_code = 200 + site_details_response.json.return_value = { "response": [ { "id": "test-building-1", "name": "Test Building 1", - "siteNameHierarchy": "Global/Keele Campus/Test Building 1", + "siteNameHierarchy": "Global/Test Campus/Test Building 1", "additionalInfo": [ { "nameSpace": "Location", @@ -753,17 +777,17 @@ def test_fetch_client_counts_filtering(mock_urlopen): ] } ] - }).encode() - site_details_response.__enter__.return_value.status = 200 + } # Mock site health response with multiple sites site_health_response = MagicMock() - site_health_response.__enter__.return_value.read.return_value = json.dumps({ + site_health_response.status_code = 200 + site_health_response.json.return_value = { "response": [ { "siteId": "test-building-1", "siteName": "Test Building 1", - "parentSiteName": "Keele Campus", + "parentSiteName": "Test Campus", "siteType": "building", "numberOfWirelessClients": 50, "numberOfWiredClients": 30, @@ -787,28 +811,27 @@ def test_fetch_client_counts_filtering(mock_urlopen): "clientHealthWireless": 0 } ] - }).encode() - site_health_response.__enter__.return_value.status = 200 + } # Set up mock to return different responses for different URLs - def mock_urlopen_side_effect(request, *args, **kwargs): - if "site/" in request.full_url: + def mock_get_side_effect(url, *args, **kwargs): + if "site/" in url: return site_details_response return site_health_response - mock_urlopen.side_effect = mock_urlopen_side_effect + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = mock_get_side_effect auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - data = fetch_client_counts(auth_manager, 1715000000000) + data = fetch_client_counts(auth_manager, site_id=1, timestamp=datetime.now(timezone.utc)) # Should only include the building with clients assert len(data) == 1 site = data[0] assert site["location"] == "Test Building 1" assert site["siteType"] == "building" - assert site["parentSiteName"] == "Keele Campus" + assert site["parentSiteName"] == "Test Campus" assert site["wirelessClients"] > 0 or site["wiredClients"] > 0 @@ -818,8 +841,8 @@ def make_mock_response(data): mock_response.__enter__.return_value.status = 200 return mock_response -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_client_data_with_fallback_merging(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_client_data_with_fallback_merging(mock_httpx_client): """ Test merging and fallback logic for fetch_ap_client_data_with_fallback. Simulate partial data from each API and verify merged result is correct. @@ -882,8 +905,8 @@ def test_fetch_ap_client_data_with_fallback_merging(mock_urlopen): assert ap["source_map"]["location"] in ("device_health", "planned_aps") assert ap["source_map"]["clientCount"] in ("client_counts", "device_health") -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_client_data_with_fallback_incomplete(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_client_data_with_fallback_incomplete(mock_httpx_client): """ Test that diagnostics are logged if all APIs fail for a required field. """ @@ -903,8 +926,11 @@ def test_fetch_ap_client_data_with_fallback_incomplete(mock_urlopen): # Pass if diagnostics are called, or if there are no APs to diagnose assert mock_diag.called or len(results) == 0 -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_ap_client_data_with_fallback_ap_name_parsing(mock_urlopen): + +import random +import string +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_ap_client_data_with_fallback_ap_name_parsing(mock_httpx_client): """ Test fallback to AP name parsing for location when location is missing or 'default location'. """ @@ -928,12 +954,15 @@ def test_fetch_ap_client_data_with_fallback_ap_name_parsing(mock_urlopen): all_clients_data = [] site_health_data = [] planned_aps_data = [] + random_building_name = "Random Building " + "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) with patch("ap_monitor.app.dna_api.fetch_ap_config_summary", return_value=ap_config_data), \ patch("ap_monitor.app.dna_api.fetch_device_health", return_value=device_health_data), \ patch("ap_monitor.app.dna_api.fetch_all_clients_count", return_value=client_counts_data), \ patch("ap_monitor.app.dna_api.fetch_clients", return_value=all_clients_data), \ patch("ap_monitor.app.dna_api.fetch_site_health", return_value=site_health_data), \ - patch("ap_monitor.app.dna_api.fetch_planned_aps", return_value=planned_aps_data): + patch("ap_monitor.app.dna_api.fetch_planned_aps", return_value=planned_aps_data), \ + patch.dict("ap_monitor.app.mapping.SHORT_TO_FULL_BUILDING", {"tel": random_building_name}), \ + patch("ap_monitor.app.dna_api.LOCATION_HIERARCHY_PREFIX", "Global/RandomCampus"): auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" results = fetch_ap_client_data_with_fallback(auth_manager) @@ -941,23 +970,24 @@ def test_fetch_ap_client_data_with_fallback_ap_name_parsing(mock_urlopen): assert len(results) == 1 ap = results[0] # Location should be set using AP name parsing - assert ap["location"] == "Global/Keele Campus/Victor Phillip Dahdaleh/3/26" + assert ap["location"] == f"Global/RandomCampus/{random_building_name}/3/26" assert ap["source_map"]["location"] == "ap_name_parsing" assert ap["clientCount"] == 6 # sum of radio0 and radio1 assert ap["status"] == "ok" -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_clients_requires_site_id(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_clients_requires_site_id(mock_httpx_client): auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = b'{"response": []}' - mock_urlopen.return_value = mock_response + mock_response.status_code = 200 + mock_response.json.return_value = {"response": []} + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response result = fetch_clients(auth_manager, page_limit=1) assert isinstance(result, list) -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_clients_with_site_id(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_clients_with_site_id(mock_httpx_client): logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger("test_fetch_clients_with_site_id") logger.info("Starting test_fetch_clients_with_site_id") @@ -967,120 +997,91 @@ def test_fetch_clients_with_site_id(mock_urlopen): {"response": [{"macAddress": "AA:BB:CC:DD:EE:FF"}]}, {"response": []} ] - def side_effect(req, context=None, timeout=None): - logger.info(f"Mock urlopen called with URL: {getattr(req, 'full_url', req)}") - class MockResponse: - def __enter__(self): - class Dummy: - def read(self_inner): - logger.info(f"Returning mock response: {responses[0]}") - return json.dumps(responses.pop(0)).encode() - return Dummy() - def __exit__(self, exc_type, exc_val, exc_tb): - pass - return MockResponse() - mock_urlopen.side_effect = side_effect + def side_effect(*args, **kwargs): + logger.info(f"Mock httpx.get called with args: {args}, kwargs: {kwargs}") + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = responses.pop(0) + logger.info(f"Returning mock response: {mock_response.json.return_value}") + return mock_response + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = side_effect logger.info("Calling fetch_clients...") result = fetch_clients(auth_manager, site_id="e77b6e96-3cd3-400a-9ebd-231c827fd369", page_limit=1) logger.info(f"fetch_clients returned: {result}") assert isinstance(result, list) assert result[0]["macAddress"] == "AA:BB:CC:DD:EE:FF" -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_clients_count_for_ap_with_site_id(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_clients_count_for_ap_with_site_id(mock_httpx_client): auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" mock_response = MagicMock() - mock_response.__enter__.return_value.read.return_value = json.dumps({"response": {"count": 5}}).encode() - mock_urlopen.return_value = mock_response + mock_response.status_code = 200 + mock_response.json.return_value = {"response": {"count": 5}} + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response count = fetch_clients_count_for_ap(auth_manager, mac="AA:BB:CC:DD:EE:FF", site_id="e77b6e96-3cd3-400a-9ebd-231c827fd369") assert count == 5 -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_clients_count_for_ap_429(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_clients_count_for_ap_429(mock_httpx_client): auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" responses = [ - HTTPError(url=None, code=429, msg="Too Many Requests", hdrs=None, fp=None), - HTTPError(url=None, code=429, msg="Too Many Requests", hdrs=None, fp=None), - {"response": {"count": 7}} + httpx.HTTPStatusError("", request=MagicMock(), response=MagicMock(status_code=429)), + httpx.HTTPStatusError("", request=MagicMock(), response=MagicMock(status_code=429)), + MagicMock(status_code=200, json=lambda: {"response": {"count": 7}}) ] - def side_effect(req, context=None, timeout=None): - resp = responses.pop(0) - if isinstance(resp, HTTPError): - raise resp - class MockResponse: - def __enter__(self): - class Dummy: - def read(self_inner): - return json.dumps(resp).encode() - return Dummy() - def __exit__(self, exc_type, exc_val, exc_tb): - pass - return MockResponse() - mock_urlopen.side_effect = side_effect + + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = responses with patch("time.sleep", lambda s: None): count = fetch_clients_count_for_ap(auth_manager, mac="AA:BB:CC:DD:EE:FF", retries=5, delay=0.1) assert count == 7 -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_clients_count_for_ap_429_all_fail(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_clients_count_for_ap_429_all_fail(mock_httpx_client): auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - responses = [HTTPError(url=None, code=429, msg="Too Many Requests", hdrs=None, fp=None)] * 5 - def side_effect(req, context=None, timeout=None): - resp = responses.pop(0) - if isinstance(resp, HTTPError): - raise resp - mock_urlopen.side_effect = side_effect + responses = [httpx.HTTPStatusError("", request=MagicMock(), response=MagicMock(status_code=429))] * 5 + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = responses with patch("time.sleep", lambda s: None): count = fetch_clients_count_for_ap(auth_manager, mac="AA:BB:CC:DD:EE:FF", retries=5, delay=0.1) assert count is None -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_clients_uses_siteHierarchy(mock_urlopen): +@patch("ap_monitor.app.dna_api.httpx.Client") +@patch("ap_monitor.app.dna_api.SITE_HIERARCHY", "Global/Your-Campus") +def test_fetch_clients_uses_siteHierarchy(mock_httpx_client): auth_manager = MagicMock() auth_manager.get_token.return_value = "mocked_token" - called_urls = [] - responses = [ - {"response": [{"macAddress": "AA:BB:CC:DD:EE:FF"}]}, - {"response": []} - ] - def side_effect(req, context=None, timeout=None): - called_urls.append(req.full_url) - class MockResponse: - def __enter__(self): - class Dummy: - def read(self_inner): - return json.dumps(responses.pop(0)).encode() - return Dummy() - def __exit__(self, exc_type, exc_val, exc_tb): - pass - return MockResponse() - mock_urlopen.side_effect = side_effect + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"response": []} + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response + with patch("time.sleep", lambda s: None): fetch_clients(auth_manager) - parsed = urlparse(called_urls[0]) - qs = parse_qs(parsed.query) - assert qs["siteHierarchy"][0] == SITE_HIERARCHY - -@patch("ap_monitor.app.dna_api.urlopen") -def test_fetch_clients_count_for_ap_uses_siteHierarchy(mock_urlopen): - called_urls = [] - def side_effect(req, context=None, timeout=None): - called_urls.append(req.full_url) - class MockResponse: - def __enter__(self): - class Dummy: - def read(self_inner): - return json.dumps({"response": {"count": 3}}).encode() - return Dummy() - def __exit__(self, exc_type, exc_val, exc_tb): - pass - return MockResponse() - mock_urlopen.side_effect = side_effect + + mock_httpx_client.return_value.__enter__.return_value.get.assert_called_with( + f"{BASE_URL}/dna/data/api/v1/clients", + headers={"x-auth-token": "mocked_token"}, + params={"siteHierarchy": "Global/Your-Campus", "limit": 100, "offset": 1}, + timeout=60 + ) + +@patch("ap_monitor.app.dna_api.httpx.Client") +@patch("ap_monitor.app.dna_api.SITE_HIERARCHY", "Global/Your-Campus") +def test_fetch_clients_count_for_ap_uses_siteHierarchy(mock_httpx_client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"response": {"count": 3}} + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response + with patch("time.sleep", lambda s: None): fetch_clients_count_for_ap(MagicMock(get_token=lambda: "mocked_token"), mac="AA:BB:CC:DD:EE:FF") - parsed = urlparse(called_urls[0]) - qs = parse_qs(parsed.query) - assert qs["siteHierarchy"][0] == SITE_HIERARCHY + + mock_httpx_client.return_value.__enter__.return_value.get.assert_called_with( + f"{BASE_URL}/dna/data/api/v1/clients/count", + headers={"x-auth-token": "mocked_token"}, + params={"siteHierarchy": "Global/Your-Campus", "macAddress": "AA:BB:CC:DD:EE:FF"}, + timeout=30 + ) diff --git a/ap_monitor/tests/test_dna_api_coverage.py b/ap_monitor/tests/test_dna_api_coverage.py new file mode 100644 index 0000000..7b37404 --- /dev/null +++ b/ap_monitor/tests/test_dna_api_coverage.py @@ -0,0 +1,95 @@ +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta +import time +from ap_monitor.app.dna_api import AuthManager, fetch_clients, fetch_clients_count_for_ap, fetch_clients_count_by_site, fetch_site_health_summaries + +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_get_token_waits_before_refresh(mock_httpx_client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"Token": "mocked_token"} + mock_httpx_client.return_value.__enter__.return_value.post.return_value = mock_response + + auth = AuthManager() + auth.token = "old_token" + auth.token_expiry = datetime.now() + timedelta(minutes=10) + auth.last_refresh_time = datetime.now() - timedelta(seconds=10) + + with patch("time.sleep") as mock_sleep: + auth.get_token(force_refresh=True) + mock_sleep.assert_called_once() + +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_get_token_no_token_in_response(mock_httpx_client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"not_a_token": "mocked_token"} + mock_httpx_client.return_value.__enter__.return_value.post.return_value = mock_response + + auth = AuthManager() + with pytest.raises(Exception, match="No token in response data"): + auth.get_token(force_refresh=True) + +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_clients(mock_httpx_client): + mock_response_1 = MagicMock() + mock_response_1.status_code = 200 + mock_response_1.json.return_value = {"response": [{"host-name": "test-host"}]} + + mock_response_2 = MagicMock() + mock_response_2.status_code = 200 + mock_response_2.json.return_value = {"response": []} + + mock_httpx_client.return_value.__enter__.return_value.get.side_effect = [mock_response_1, mock_response_2] + + auth_manager = AuthManager() + auth_manager.token = "test-token" + auth_manager.token_expiry = datetime.now() + timedelta(minutes=10) + + clients = fetch_clients(auth_manager) + assert len(clients) == 1 + assert clients[0]["host-name"] == "test-host" + +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_clients_count_for_ap(mock_httpx_client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"response": {"count": 10}} + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response + + auth_manager = AuthManager() + auth_manager.token = "test-token" + auth_manager.token_expiry = datetime.now() + timedelta(minutes=10) + + count = fetch_clients_count_for_ap(auth_manager, mac="test-mac") + assert count == 10 + +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_clients_count_by_site(mock_httpx_client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"response": {"count": 20}} + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response + + auth_manager = AuthManager() + auth_manager.token = "test-token" + auth_manager.token_expiry = datetime.now() + timedelta(minutes=10) + + count = fetch_clients_count_by_site(auth_manager, site_id="test-site") + assert count == {"count": 20} + +@patch("ap_monitor.app.dna_api.httpx.Client") +def test_fetch_site_health_summaries(mock_httpx_client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"response": [{"siteName": "test-site"}]} + mock_httpx_client.return_value.__enter__.return_value.get.return_value = mock_response + + auth_manager = AuthManager() + auth_manager.token = "test-token" + auth_manager.token_expiry = datetime.now() + timedelta(minutes=10) + + summaries = fetch_site_health_summaries(auth_manager) + assert len(summaries) == 1 + assert summaries[0]["siteName"] == "test-site" \ No newline at end of file diff --git a/ap_monitor/tests/test_endpoints.py b/ap_monitor/tests/test_endpoints.py new file mode 100644 index 0000000..b211d4a --- /dev/null +++ b/ap_monitor/tests/test_endpoints.py @@ -0,0 +1,219 @@ +import pytest +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient +from ap_monitor.app.main import app +from ap_monitor.app.db import get_db +from datetime import datetime, timezone, timedelta +from ap_monitor.app.models import Campus, Building, Floor, AccessPoint, RadioType, ClientCount, Room +import os + + + + + + +def test_health_check(client): + """Test the /health endpoint.""" + # Arrange + mock_job = MagicMock() + mock_job.next_run_time = datetime.now(timezone.utc) + timedelta(minutes=1) + with patch.object(client.app.state.scheduler, 'get_jobs', return_value=[mock_job]): + response = client.get("/health") + + # Assert + assert response.status_code == 200 + assert response.json()["status"] == "healthy" + +def test_get_aps(authenticated_client, test_db): + """Test the /aps endpoint.""" + # Arrange + db = test_db() + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() + building = Building(name="Test Building", campus_id=campus.id) + db.add(building) + db.commit() + floor = Floor(name="Test Floor", building_id=building.id) + db.add(floor) + db.commit() + ap = AccessPoint(name="AP01", mac_address="00:11:22:33:44:55", ip_address="192.168.1.1", model="ModelX", is_active=True, building_id=building.id, floor_id=floor.id) + db.add(ap) + db.commit() + + # Act + response = authenticated_client.get("/aps") + + # Assert + assert response.status_code == 200 + assert len(response.json()) > 0 + +def test_get_client_counts(authenticated_client, test_db): + """Test the /client-counts endpoint.""" + # Arrange + db = test_db() + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() + building = Building(name="Test Building", campus_id=campus.id) + db.add(building) + db.commit() + floor = Floor(name="Test Floor", building_id=building.id) + db.add(floor) + db.commit() + ap = AccessPoint(name="AP01", mac_address="00:11:22:33:44:55", ip_address="192.168.1.1", model="ModelX", is_active=True, building_id=building.id, floor_id=floor.id) + db.add(ap) + db.commit() + client_count = ClientCount(access_point_id=ap.id, radio_type_id=1, count=10, timestamp=datetime.now(timezone.utc)) + db.add(client_count) + db.commit() + + # Act + response = authenticated_client.get("/client-counts") + + # Assert + assert response.status_code == 200 + assert len(response.json()) > 0 + +def test_get_buildings(authenticated_client, test_db): + """Test the /buildings endpoint.""" + # Arrange + db = test_db() + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() + building = Building(name="BuildingA", campus_id=campus.id) + db.add(building) + db.commit() + + # Act + response = authenticated_client.get("/buildings") + + # Assert + assert response.status_code == 200 + assert len(response.json()) > 0 + +def test_get_floors(authenticated_client, test_db): + """Test the /floors/{building_id} endpoint.""" + # Arrange + db = test_db() + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() + building = Building(name="Test Building", campus_id=campus.id) + db.add(building) + db.commit() + floor = Floor(name="Test Floor", building_id=building.id) + db.add(floor) + db.commit() + + # Act + response = authenticated_client.get(f"/floors/{building.id}") + + # Assert + assert response.status_code == 200 + assert len(response.json()) > 0 + +def test_get_rooms(authenticated_client, test_db): + """Test the /rooms/{floor_id} endpoint.""" + # Arrange + db = test_db() + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() + building = Building(name="Test Building", campus_id=campus.id) + db.add(building) + db.commit() + floor = Floor(name="Test Floor", building_id=building.id) + db.add(floor) + db.commit() + room = Room(name="Test Room", floor_id=floor.id) + db.add(room) + db.commit() + + # Act + response = authenticated_client.get(f"/rooms/{floor.id}") + + # Assert + assert response.status_code == 200 + assert len(response.json()) > 0 + +def test_get_radio_types(authenticated_client, test_db): + """Test the /radio-types endpoint.""" + db = test_db() + # Act + response = authenticated_client.get("/radio-types") + + # Assert + assert response.status_code == 200 + assert len(response.json()) > 0 + +@patch("ap_monitor.app.main.update_client_count_task") +def test_trigger_update_client_count(mock_update_task, authenticated_client): + """Test the /tasks/update-client-count/ endpoint.""" + # Act + response = authenticated_client.post("/tasks/update-client-count/") + + # Assert + assert response.status_code == 200 + assert response.json() == {"message": "Client count update task started"} + mock_update_task.assert_called_once() + +def test_get_zero_count_diagnostics(authenticated_client, test_db): + """Test the /diagnostics/zero-counts endpoint.""" + # Arrange + db = test_db() + os.environ['ENABLE_DIAGNOSTICS'] = 'true' + + # Act + response = authenticated_client.get("/diagnostics/zero-counts") + + # Assert + assert response.status_code == 200 + +def test_get_building_health(authenticated_client): + """Test the /diagnostics/health endpoint.""" + # Arrange + with patch("ap_monitor.app.main.is_diagnostics_enabled", return_value=True): + with patch("ap_monitor.app.main.monitor_building_health", return_value=[]): + # Act + response = authenticated_client.get("/diagnostics/health") + + # Assert + assert response.status_code == 200 + assert response.json() == {"alerts": []} + +def test_get_diagnostic_report(authenticated_client): + """Test the /diagnostics/report endpoint.""" + # Arrange + with patch("ap_monitor.app.main.is_diagnostics_enabled", return_value=True): + with patch("ap_monitor.app.main.generate_diagnostic_report", return_value={}): + # Act + response = authenticated_client.get("/diagnostics/report") + + # Assert + assert response.status_code == 200 + assert response.json() == {} + +def test_get_incomplete_devices(authenticated_client): + """Test the /diagnostics/incomplete-devices endpoint.""" + # Arrange + with patch("ap_monitor.app.main.is_diagnostics_enabled", return_value=True): + with patch("ap_monitor.app.main.get_incomplete_diagnostics", return_value=[]): + # Act + response = authenticated_client.get("/diagnostics/incomplete-devices") + + # Assert + assert response.status_code == 200 + assert response.json() == {"incomplete_devices": [], "count": 0} + +def test_get_api_health(authenticated_client): + """Test the /diagnostics/api_health endpoint.""" + # Arrange + with patch("ap_monitor.app.main.get_api_error_summary", return_value={}): + # Act + response = authenticated_client.get("/diagnostics/api_health") + + # Assert + assert response.status_code == 200 + assert response.json() == {} diff --git a/ap_monitor/tests/test_location_parser.py b/ap_monitor/tests/test_location_parser.py deleted file mode 100644 index 7ec14c5..0000000 --- a/ap_monitor/tests/test_location_parser.py +++ /dev/null @@ -1,413 +0,0 @@ -import pytest -from datetime import datetime, timezone -from sqlalchemy import create_engine, event -from sqlalchemy.orm import sessionmaker -from ap_monitor.app.models import ApBuilding, Floor, Room, AccessPoint, ClientCountAP, RadioType, APClientBase -from ap_monitor.app.db import APClientBase as DBAPClientBase -from ap_monitor.app.main import insert_apclientcount_data - -# Mock data for testing different location patterns -MOCK_LOCATIONS = { - "standard_format": { - "location": "Global/Keele Campus/BuildingA/Floor 1", - "expected_building": "BuildingA", - "expected_floor": "Floor 1" - }, - "basement": { - "location": "Global/Keele Campus/BuildingB/Basement", - "expected_building": "BuildingB", - "expected_floor": "Basement" - }, - "ground_floor": { - "location": "Global/Keele Campus/BuildingC/Ground", - "expected_building": "BuildingC", - "expected_floor": "Ground" - }, - "directional_floor": { - "location": "Global/Keele Campus/BuildingD/Floor 1 North", - "expected_building": "BuildingD", - "expected_floor": "Floor 1 North" - }, - "basement_directional": { - "location": "Global/Keele Campus/BuildingE/Basement South", - "expected_building": "BuildingE", - "expected_floor": "Basement South" - }, - "complex_building_name": { - "location": "Global/Keele Campus/Health Nursing and Enviromental Studies/Floor 1", - "expected_building": "Health Nursing and Enviromental Studies", - "expected_floor": "Floor 1" - }, - "with_room": { - "location": "Global/Keele Campus/BuildingG/Floor 1/Room 101", - "expected_building": "BuildingG", - "expected_floor": "Floor 1" - }, - "numbered_building": { - "location": "Global/Keele Campus/Assiniboine 320/Floor 12", - "expected_building": "Assiniboine 320", - "expected_floor": "Floor 12" - }, - "special_chars": { - "location": "Global/Keele Campus/Building-H/Floor 3", - "expected_building": "Building-H", - "expected_floor": "Floor 3" - }, - "multi_word_building": { - "location": "Global/Keele Campus/Centre for Film and Theatre/Floor 1", - "expected_building": "Centre for Film and Theatre", - "expected_floor": "Floor 1" - }, - "short_format": { - "location": "BuildingJ/Floor 2", - "expected_building": "BuildingJ", - "expected_floor": "Floor 2" - }, - "dome_location": { - "location": "Global/Keele Campus/York Lions Stadium/Dome", - "expected_building": "York Lions Stadium", - "expected_floor": "Dome" - }, - "central_square_ne": { - "location": "Global/Keele Campus/Central Square/Floor 1 NE", - "expected_building": "Central Square", - "expected_floor": "Floor 1 NE" - }, - "central_square_se": { - "location": "Global/Keele Campus/Central Square/Floor 1 SE", - "expected_building": "Central Square", - "expected_floor": "Floor 1 SE" - }, - "central_square_sw": { - "location": "Global/Keele Campus/Central Square/Floor 1 SW", - "expected_building": "Central Square", - "expected_floor": "Floor 1 SW" - }, - "central_square_nw": { - "location": "Global/Keele Campus/Central Square/Floor 1 NW", - "expected_building": "Central Square", - "expected_floor": "Floor 1 NW" - }, - "outdoor_location": { - "location": "Global/Keele Campus/HAC Outdoor/Floor 1", - "expected_building": "HAC Outdoor", - "expected_floor": "Floor 1" - }, - "passy_building": { - "location": "Global/Keele Campus/Passy 14/Floor 2", - "expected_building": "Passy 14", - "expected_floor": "Floor 2" - } -} - -@pytest.fixture -def session(): - # Create test database - engine = create_engine("sqlite:///:memory:") - - # Enable foreign key support for SQLite - def _fk_pragma_on_connect(dbapi_con, con_record): - dbapi_con.execute('pragma foreign_keys=ON') - - event.listen(engine, 'connect', _fk_pragma_on_connect) - - # Create all tables - DBAPClientBase.metadata.create_all(bind=engine) - - # Create session - TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - session = TestingSessionLocal() - - try: - # Initialize radio types - radio_types = [ - RadioType(radioid=1, radioname="2.4GHz"), - RadioType(radioid=2, radioname="5GHz") - ] - session.add_all(radio_types) - session.commit() - - yield session - finally: - session.close() - -@pytest.fixture -def current_timestamp(): - return datetime.now(timezone.utc) - -def test_location_parsing_standard_format(session, current_timestamp): - """Test standard location format parsing""" - mock_data = MOCK_LOCATIONS["standard_format"] - device_info = [{ - "name": "AP1", - "location": mock_data["location"], - "macAddress": "00:11:22:33:44:55", - "clientCount": {"2.4GHz": 10}, - "radioType": "2.4GHz", - "ipAddress": "192.168.1.1", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - insert_apclientcount_data(device_info, current_timestamp, session) - - building = session.query(ApBuilding).filter_by(buildingname=mock_data["expected_building"]).first() - assert building is not None - floor = session.query(Floor).filter_by(floorname=mock_data["expected_floor"], buildingid=building.buildingid).first() - assert floor is not None - -def test_location_parsing_special_floors(session, current_timestamp): - """Test parsing of special floor types (Basement, Ground)""" - for test_case in ["basement", "ground_floor"]: - mock_data = MOCK_LOCATIONS[test_case] - device_info = [{ - "name": f"AP_{test_case}", - "location": mock_data["location"], - "macAddress": f"00:11:22:33:44:{test_case[-2:]}", - "clientCount": {"2.4GHz": 5}, - "radioType": "2.4GHz", - "ipAddress": "192.168.1.2", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - insert_apclientcount_data(device_info, current_timestamp, session) - - building = session.query(ApBuilding).filter_by(buildingname=mock_data["expected_building"]).first() - assert building is not None - floor = session.query(Floor).filter_by(floorname=mock_data["expected_floor"], buildingid=building.buildingid).first() - assert floor is not None - -def test_location_parsing_directional_floors(session, current_timestamp): - """Test parsing of floors with directional indicators""" - for test_case in ["directional_floor", "basement_directional"]: - mock_data = MOCK_LOCATIONS[test_case] - device_info = [{ - "name": f"AP_{test_case}", - "location": mock_data["location"], - "macAddress": f"00:11:22:33:44:{test_case[-2:]}", - "clientCount": {"5GHz": 8}, - "radioType": "5GHz", - "ipAddress": "192.168.1.3", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - insert_apclientcount_data(device_info, current_timestamp, session) - - building = session.query(ApBuilding).filter_by(buildingname=mock_data["expected_building"]).first() - assert building is not None - floor = session.query(Floor).filter_by(floorname=mock_data["expected_floor"], buildingid=building.buildingid).first() - assert floor is not None - -def test_location_parsing_complex_buildings(session, current_timestamp): - """Test parsing of buildings with complex names""" - for test_case in ["complex_building_name", "numbered_building", "special_chars", "multi_word_building"]: - mock_data = MOCK_LOCATIONS[test_case] - device_info = [{ - "name": f"AP_{test_case}", - "location": mock_data["location"], - "macAddress": f"00:11:22:33:44:{test_case[-2:]}", - "clientCount": {"2.4GHz": 12}, - "radioType": "2.4GHz", - "ipAddress": "192.168.1.4", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - insert_apclientcount_data(device_info, current_timestamp, session) - - building = session.query(ApBuilding).filter_by(buildingname=mock_data["expected_building"]).first() - assert building is not None - floor = session.query(Floor).filter_by(floorname=mock_data["expected_floor"], buildingid=building.buildingid).first() - assert floor is not None - -def test_location_parsing_special_locations(session, current_timestamp): - """Test parsing of special locations (Dome, Central Square directions)""" - for test_case in ["dome_location", "central_square_ne", "central_square_se", "central_square_sw", "central_square_nw"]: - mock_data = MOCK_LOCATIONS[test_case] - device_info = [{ - "name": f"AP_{test_case}", - "location": mock_data["location"], - "macAddress": f"00:11:22:33:44:{test_case[-2:]}", - "clientCount": {"5GHz": 15}, - "radioType": "5GHz", - "ipAddress": "192.168.1.5", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - insert_apclientcount_data(device_info, current_timestamp, session) - - building = session.query(ApBuilding).filter_by(buildingname=mock_data["expected_building"]).first() - assert building is not None - floor = session.query(Floor).filter_by(floorname=mock_data["expected_floor"], buildingid=building.buildingid).first() - assert floor is not None - -def test_location_parsing_outdoor_and_numbered(session, current_timestamp): - """Test parsing of outdoor locations and numbered buildings""" - for test_case in ["outdoor_location", "passy_building"]: - mock_data = MOCK_LOCATIONS[test_case] - device_info = [{ - "name": f"AP_{test_case}", - "location": mock_data["location"], - "macAddress": f"00:11:22:33:44:{test_case[-2:]}", - "clientCount": {"2.4GHz": 6}, - "radioType": "2.4GHz", - "ipAddress": "192.168.1.6", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - insert_apclientcount_data(device_info, current_timestamp, session) - - building = session.query(ApBuilding).filter_by(buildingname=mock_data["expected_building"]).first() - assert building is not None - floor = session.query(Floor).filter_by(floorname=mock_data["expected_floor"], buildingid=building.buildingid).first() - assert floor is not None - -def test_location_parsing_invalid_formats(session, current_timestamp): - """Test handling of invalid location formats""" - invalid_locations = [ - "", # Empty location - "Invalid", # Too short - "Global/Invalid", # Missing parts - "Global/Keele Campus/Invalid", # Missing floor - "Global/Keele Campus/Building/", # Empty floor - "/Global/Keele Campus/Building/Floor 1", # Leading slash - "Global/Keele Campus/Building/Floor 1/", # Trailing slash - "Global/Keele Campus/Building/Invalid", # Invalid floor - ] - - for location in invalid_locations: - # Clear all related tables before each sub-test - session.query(ClientCountAP).delete() - session.query(AccessPoint).delete() - session.query(Room).delete() - session.query(Floor).delete() - session.query(ApBuilding).delete() - session.commit() - device_info = [{ - "name": f"AP_invalid_{location[:10]}", - "location": location, - "macAddress": f"00:11:22:33:44:{hash(location) % 100:02d}", - "clientCount": {"2.4GHz": 3}, - "radioType": "2.4GHz", - "ipAddress": "192.168.1.7", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - before_count = session.query(ClientCountAP).count() - insert_apclientcount_data(device_info, current_timestamp, session) - after_count = session.query(ClientCountAP).count() - if before_count != after_count: - print(f"DEBUG: Inserted records for location '{location}':", list(session.query(ClientCountAP).all())) - assert before_count == after_count, f"Client count should not be inserted for invalid location: {location}" - -def test_location_parsing_existing_ap_update(session, current_timestamp): - """Test updating an existing AP's information""" - # First, create an initial AP - initial_device_info = [{ - "name": "AP_Initial", - "location": "Global/Keele Campus/BuildingA/Floor 1", - "macAddress": "00:11:22:33:44:55", - "clientCount": {"2.4GHz": 5}, - "radioType": "2.4GHz", - "ipAddress": "192.168.1.1", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - # Insert initial AP - insert_apclientcount_data(initial_device_info, current_timestamp, session) - - # Now update the same AP with new information - updated_device_info = [{ - "name": "AP_Updated", - "location": "Global/Keele Campus/BuildingB/Floor 2", # Changed building and floor - "macAddress": "00:11:22:33:44:55", # Same MAC address - "clientCount": {"2.4GHz": 10}, # Updated client count - "radioType": "2.4GHz", - "ipAddress": "192.168.1.2", # Changed IP - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "DOWN" # Changed status - }] - - # Update AP - insert_apclientcount_data(updated_device_info, current_timestamp, session) - - # Verify the AP was updated correctly - ap = session.query(AccessPoint).filter_by(macaddress="00:11:22:33:44:55").first() - assert ap is not None - assert ap.apname == "AP_Updated" - assert ap.ipaddress == "192.168.1.2" - assert ap.isactive is False - - # Verify building and floor were updated - building = session.query(ApBuilding).filter_by(buildingname="BuildingB").first() - assert building is not None - floor = session.query(Floor).filter_by(floorname="Floor 2", buildingid=building.buildingid).first() - assert floor is not None - assert ap.buildingid == building.buildingid - assert ap.floorid == floor.floorid - - # Verify client count was updated - client_count = session.query(ClientCountAP).filter_by( - apid=ap.apid, - timestamp=current_timestamp - ).first() - assert client_count is not None - assert client_count.clientcount == 10 - assert client_count.radio.radioname == "2.4GHz" - -def test_location_parsing_existing_ap_multiple_radios(session, current_timestamp): - """Test updating an existing AP with multiple radio types""" - # First, create an initial AP with one radio - initial_device_info = [{ - "name": "AP_Multi_Radio", - "location": "Global/Keele Campus/BuildingA/Floor 1", - "macAddress": "00:11:22:33:44:66", - "clientCount": {"2.4GHz": 5}, - "radioType": "2.4GHz", - "ipAddress": "192.168.1.3", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - # Insert initial AP - insert_apclientcount_data(initial_device_info, current_timestamp, session) - - # Now update the same AP with multiple radios - updated_device_info = [{ - "name": "AP_Multi_Radio", - "location": "Global/Keele Campus/BuildingA/Floor 1", - "macAddress": "00:11:22:33:44:66", - "clientCount": { - "2.4GHz": 8, - "5GHz": 12 - }, - "radioType": "2.4GHz", - "ipAddress": "192.168.1.3", - "model": "AIR-CAP3702I-A-K9", - "reachabilityHealth": "UP" - }] - - # Update AP - insert_apclientcount_data(updated_device_info, current_timestamp, session) - - # Verify the AP exists - ap = session.query(AccessPoint).filter_by(macaddress="00:11:22:33:44:66").first() - assert ap is not None - - # Verify both radio client counts were updated - client_counts = session.query(ClientCountAP).filter_by( - apid=ap.apid, - timestamp=current_timestamp - ).all() - assert len(client_counts) == 2 - - # Create a map of radio types to counts - radio_counts = {cc.radio.radioname: cc.clientcount for cc in client_counts} - assert radio_counts["2.4GHz"] == 8 - assert radio_counts["5GHz"] == 12 \ No newline at end of file diff --git a/ap_monitor/tests/test_main.py b/ap_monitor/tests/test_main.py index 160af5c..9e3c58d 100644 --- a/ap_monitor/tests/test_main.py +++ b/ap_monitor/tests/test_main.py @@ -1,35 +1,19 @@ import pytest from unittest.mock import MagicMock, patch, AsyncMock from fastapi.testclient import TestClient -from ap_monitor.app.db import get_wireless_db, get_apclient_db -from ap_monitor.app.main import app, update_ap_data_task, update_client_count_task, TORONTO_TZ +from ap_monitor.app.db import get_db +from ap_monitor.app.main import app, update_client_count_task, TORONTO_TZ from ap_monitor.app.models import ( - AccessPoint, ClientCount, Building, Floor, Campus, ApBuilding, Room, RadioType, ClientCountAP + AccessPoint, ClientCount, Building, Floor, Campus, Room, RadioType ) from datetime import datetime, timezone, timedelta -from sqlalchemy import create_engine, inspect +from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from ap_monitor.app.db import WirelessBase, APClientBase -from sqlalchemy import event +from ap_monitor.app.db import Base from apscheduler.schedulers.background import BackgroundScheduler import os from contextlib import asynccontextmanager -from sqlalchemy import func -from unittest.mock import ANY import logging -from sqlalchemy import text -from sqlalchemy.orm import Session -from unittest.mock import Mock -from apscheduler.triggers.date import DateTrigger -from ap_monitor.app.main import ( - cleanup_job, - reschedule_job, - calculate_next_run_time, - health_check -) -from apscheduler.triggers.cron import CronTrigger -from ap_monitor.app.dna_api import fetch_ap_client_data_with_fallback -from urllib.error import HTTPError # Configure logging logging.basicConfig(level=logging.DEBUG) @@ -43,1004 +27,80 @@ async def mock_lifespan(app): # Replace the app's lifespan with our mock app.router.lifespan_context = mock_lifespan -@pytest.fixture -def scheduler(): - scheduler = BackgroundScheduler() - scheduler.start() - yield scheduler - if scheduler.running: - scheduler.shutdown() - -def get_table_dependencies(session): - """Get all table dependencies in the database.""" - inspector = inspect(session.get_bind()) - dependencies = {} - - for table_name in inspector.get_table_names(): - foreign_keys = inspector.get_foreign_keys(table_name) - dependencies[table_name] = [fk['referred_table'] for fk in foreign_keys] - - return dependencies - -@pytest.fixture -def override_get_db_with_mock_ap(): - mock_ap = MagicMock() - mock_ap.apid = 1 - mock_ap.apname = "AP01" - mock_ap.macaddress = "00:11:22:33:44:55" - mock_ap.ipaddress = "192.168.1.1" - mock_ap.modelname = "ModelX" - mock_ap.isactive = True - mock_ap.building_id = 1 - mock_ap.floorid = 1 - mock_ap.roomid = None - - mock_query = MagicMock() - mock_query.all.return_value = [mock_ap] - - mock_session = MagicMock() - mock_session.query.return_value = mock_query - - def override(): - yield mock_session - - from ap_monitor.app.db import get_wireless_db_dep, get_apclient_db_dep - app.dependency_overrides[get_wireless_db] = override - app.dependency_overrides[get_apclient_db] = override - app.dependency_overrides[get_wireless_db_dep] = override - app.dependency_overrides[get_apclient_db_dep] = override - yield - app.dependency_overrides.clear() - -@pytest.fixture -def override_get_db_with_mock_buildings(): - mock_building = MagicMock() - mock_building.building_id = 1 - mock_building.building_name = "BuildingA" - - mock_query = MagicMock() - mock_query.all.return_value = [mock_building] - - mock_session = MagicMock() - mock_session.query.return_value = mock_query - - def override(): - yield mock_session - from ap_monitor.app.db import get_wireless_db_dep, get_apclient_db_dep - app.dependency_overrides[get_wireless_db] = override - app.dependency_overrides[get_apclient_db] = override - app.dependency_overrides[get_wireless_db_dep] = override - app.dependency_overrides[get_apclient_db_dep] = override - yield - app.dependency_overrides.clear() - -@pytest.fixture -def client(wireless_db, apclient_db, scheduler): - def override_get_wireless_db(): - try: - yield wireless_db - finally: - pass - - def override_get_apclient_db(): - try: - yield apclient_db - finally: - pass - - app.dependency_overrides[get_wireless_db] = override_get_wireless_db - app.dependency_overrides[get_apclient_db] = override_get_apclient_db - - # Add scheduler to app state - app.state.scheduler = scheduler - - with TestClient(app) as test_client: - yield test_client - - app.dependency_overrides.clear() -@pytest.fixture(scope="function") -def wireless_db(): - """Create a test database for wireless_count.""" - test_engine = create_engine("sqlite:///test_wireless.db", connect_args={"check_same_thread": False}) - Session = sessionmaker(bind=test_engine) - session = Session() - - try: - # Drop all tables first to ensure clean state - WirelessBase.metadata.drop_all(test_engine) - # Create tables in correct order - WirelessBase.metadata.create_all(test_engine) - - yield session - finally: - session.close() - WirelessBase.metadata.drop_all(test_engine) - if os.path.exists("test_wireless.db"): - os.remove("test_wireless.db") -@pytest.fixture(scope="function") -def apclient_db(): - """Create a test database for apclientcount.""" - # Use a file-based SQLite DB to share across connections - test_engine = create_engine("sqlite:///test_apclient.db", connect_args={"check_same_thread": False}) - Session = sessionmaker(bind=test_engine) - session = Session() - - try: - # Drop all tables first to ensure clean state - APClientBase.metadata.drop_all(test_engine) - # Create tables in correct order - APClientBase.metadata.create_all(test_engine) - - # Create radio types - radio_types = [ - RadioType(radioname="radio0", radioid=1), - RadioType(radioname="radio1", radioid=2), - RadioType(radioname="radio2", radioid=3) - ] - for radio_type in radio_types: - session.add(radio_type) - session.commit() - - # Create test building - ap_building = ApBuilding(buildingname="Test Building") - session.add(ap_building) - session.commit() - - # Create test floor - floor = Floor(buildingid=ap_building.buildingid, floorname="Floor 1") - session.add(floor) - session.commit() - - yield session - finally: - session.close() - APClientBase.metadata.drop_all(test_engine) - if os.path.exists("test_apclient.db"): - os.remove("test_apclient.db") -@pytest.fixture -def test_data(wireless_db, apclient_db): - logger.info("Setting up test data") - try: - # Create wireless_count data - campus = Campus(campus_name="Keele Campus") - wireless_db.add(campus) - wireless_db.commit() - building = Building( - building_name="Keele Campus", - campus_id=campus.campus_id, - latitude=43.7735473000, - longitude=-79.5062752000 - ) - wireless_db.add(building) - wireless_db.commit() - # Create apclientcount data - ap_building = ApBuilding(buildingname="Keele Campus") - apclient_db.add(ap_building) - apclient_db.commit() - floor = Floor( - buildingid=ap_building.buildingid, - floorname="Floor 5" - ) - apclient_db.add(floor) - apclient_db.commit() - ap = AccessPoint( - buildingid=ap_building.buildingid, - floorid=floor.floorid, - roomid=None, - apname="k372-ross-5-28", - macaddress="a8:9d:21:b9:67:a0", - ipaddress="10.30.2.154", - modelname="Cisco 3700I Unified Access Point", - isactive=True - ) - apclient_db.add(ap) - apclient_db.commit() - # Create client count records for each radio - radio_types = apclient_db.query(RadioType).all() - for radio_type in radio_types: - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio_type.radioid, - clientcount=10, - timestamp=datetime.now(timezone.utc) - ) - apclient_db.add(client_count) - apclient_db.commit() - # Create client count in wireless database - wireless_client_count = ClientCount( - building_id=building.building_id, - client_count=30 # Total of all radio counts - ) - wireless_db.add(wireless_client_count) - wireless_db.commit() - logger.info("Test data setup completed successfully") - return { - "campus": campus, - "building": building, - "ap_building": ap_building, - "floor": floor, - "ap": ap, - "radio_types": radio_types, - "client_counts": apclient_db.query(ClientCountAP).all(), - "wireless_client_count": wireless_client_count - } - except Exception as e: - logger.error(f"Error setting up test data: {str(e)}") - wireless_db.rollback() - apclient_db.rollback() - raise +def test_get_aps(authenticated_client, test_db): + db = test_db() + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() + building = Building(name="Test Building", campus_id=campus.id) + db.add(building) + db.commit() + floor = Floor(name="Test Floor", building_id=building.id) + db.add(floor) + db.commit() + ap = AccessPoint(name="AP01", mac_address="00:11:22:33:44:55", ip_address="192.168.1.1", model="ModelX", is_active=True, building_id=building.id, floor_id=floor.id) + db.add(ap) + db.commit() -def test_get_aps(client, override_get_db_with_mock_ap): - response = client.get("/aps") + response = authenticated_client.get("/aps") assert response.status_code == 200 data = response.json() assert len(data) == 1 - assert data[0]["apname"] == "AP01" - assert data[0]["macaddress"] == "00:11:22:33:44:55" - -def test_get_buildings(client, override_get_db_with_mock_buildings): - response = client.get("/buildings") + assert data[0]["name"] == "AP01" + assert data[0]["mac_address"] == "00:11:22:33:44:55" + +def test_get_buildings(authenticated_client, test_db): + db = test_db() + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() + building = Building(name="BuildingA", campus_id=campus.id) + db.add(building) + db.commit() + + response = authenticated_client.get("/buildings") assert response.status_code == 200 data = response.json() - assert len(data) == 1 - assert data[0]["building_name"] == "BuildingA" - -@pytest.fixture -def override_get_db_with_mock_client_counts(): - """Mock fixture for AP client counts endpoint (ClientCountAP model).""" - # Create mock objects - mock_ap = MagicMock() - mock_ap.apname = "k372-ross-5-28" - mock_ap.apid = 1 - - mock_radio = MagicMock() - mock_radio.radioname = "radio0" - mock_radio.radioid = 1 - - mock_cc = MagicMock() - mock_cc.countid = 1 - mock_cc.clientcount = 15 - mock_cc.apid = 1 - mock_cc.radioid = 1 - mock_cc.timestamp = datetime.now(timezone.utc) - mock_cc.accesspoint = mock_ap - mock_cc.radio = mock_radio - # No building_id for ClientCountAP - - # Set up the query chain - mock_query = MagicMock() - mock_query.join.return_value = mock_query - mock_query.filter.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = [mock_cc] - - mock_session = MagicMock() - mock_session.query.return_value = mock_query - - def override(): - return mock_session - - app.dependency_overrides[get_wireless_db] = override - from ap_monitor.app.db import get_apclient_db_dep - app.dependency_overrides[get_apclient_db_dep] = override - yield - app.dependency_overrides.clear() - -@pytest.fixture -def override_get_db_with_mock_aps(): - """Mock fixture for APs endpoint.""" - mock_ap = MagicMock() - mock_ap.apid = 1 - mock_ap.apname = "k372-ross-5-28" - mock_ap.macaddress = "a8:9d:21:b9:67:a0" - mock_ap.ipaddress = "10.30.2.154" - mock_ap.modelname = "Cisco 3700I Unified Access Point" - mock_ap.isactive = True - mock_ap.buildingid = 1 - mock_ap.floorid = 1 - mock_ap.roomid = None - - mock_query = MagicMock() - mock_query.all.return_value = [mock_ap] - - mock_session = MagicMock() - mock_session.query.return_value = mock_query - - def override(): - yield mock_session - - from ap_monitor.app.db import get_wireless_db_dep, get_apclient_db_dep - app.dependency_overrides[get_wireless_db] = override - app.dependency_overrides[get_apclient_db] = override - app.dependency_overrides[get_wireless_db_dep] = override - app.dependency_overrides[get_apclient_db_dep] = override - yield - app.dependency_overrides.clear() - -def test_get_client_counts(client, override_get_db_with_mock_client_counts): - """Test getting AP client counts with mock data (ClientCountAP model).""" - response = client.get("/client-counts") - assert response.status_code == 200 - data = response.json() - assert len(data) == 1 - # Check that the response contains the expected keys - assert "apid" in data[0] - assert "radioid" in data[0] - assert "client_count" in data[0] - assert "timestamp" in data[0] - assert "count_id" in data[0] + assert len(data) > 0 + assert data[0]["name"] == "BuildingA" @patch("ap_monitor.app.main.auth_manager") -def test_update_client_count_task(mock_auth, client, override_get_db_with_mock_client_counts): +def test_update_client_count_task(mock_auth, test_db, scheduler): + db = test_db() """Test client count update task with mock data.""" logger.info("Starting client count update test") mock_auth.get_token.return_value = "test_token" + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() test_data = [ { - "hostname": "k372-ross-5-28", + "name": "k372-ross-5-28", "macAddress": "a8:9d:21:b9:67:a0", "ipAddress": "10.30.2.154", "model": "Cisco 3700I Unified Access Point", - "reachabilityStatus": "UP", - "location": "Global/Keele Campus/Bethune Residence/Floor 5", + "reachabilityHealth": "UP", + "location": "Global/Test Campus/Test Building/Floor 5", "clientCount": 60 } ] try: logger.debug("Running update_client_count_task") with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback") as mock_fetch: - mock_fetch.return_value = {'source': 'networkDevices', 'data': test_data} - update_client_count_task(db=MagicMock(), auth_manager_obj=mock_auth) + mock_fetch.return_value = test_data + update_client_count_task(db=db, auth_manager_obj=mock_auth) mock_fetch.assert_called_once_with(mock_auth) except Exception as e: logger.error(f"Error in client count update test: {e}") - raise - -def raise_http_500(*args, **kwargs): - from urllib.error import HTTPError - raise HTTPError(url=None, code=500, msg="Internal Server Error", hdrs=None, fp=None) - -import ap_monitor.app.main as main_module - -def test_update_ap_data_task_sets_global_maintenance(monkeypatch, caplog): - # Patch db session - mock_db = Mock() - # Reset global maintenance window - main_module.MAINTENANCE_UNTIL = None - # Run the task with the mock fetch_ap_data_func that always raises HTTP 500 - with caplog.at_level("ERROR"): - main_module.update_ap_data_task(db=mock_db, fetch_ap_data_func=raise_http_500) - # Check that the maintenance window is set - assert main_module.MAINTENANCE_UNTIL is not None - # Check that the log contains the maintenance message - assert any("Entering maintenance until" in r.message for r in caplog.records) - - -def test_update_ap_data_task_skips_during_maintenance(monkeypatch, caplog): - # Patch db session - mock_db = Mock() - # Set global maintenance window to the future - from datetime import datetime, timedelta, timezone - future_time = datetime.now(timezone.utc) + timedelta(minutes=30) - main_module.MAINTENANCE_UNTIL = future_time - # Patch fetch_ap_data_func to fail if called - def fail_fetch(*args, **kwargs): - pytest.fail("fetch_ap_data_func should not be called during maintenance window") - # Run the task - with caplog.at_level("WARNING"): - main_module.update_ap_data_task(db=mock_db, fetch_ap_data_func=fail_fetch) - # Check that the log contains the skip message - assert any("In maintenance window until" in r.message for r in caplog.records) - -@patch("ap_monitor.app.main.auth_manager") -def test_update_ap_data_task(mock_auth, client, override_get_db_with_mock_aps): - """Test AP data update task with mock data.""" - logger.info("Starting AP data update test") - mock_auth.get_token.return_value = "test_token" - mock_fetch = MagicMock() - mock_fetch.return_value = [ - { - "name": "k372-ross-5-28", - "macAddress": "a8:9d:21:b9:67:a0", - "ipAddress": "10.30.2.154", - "model": "Cisco 3700I Unified Access Point", - "reachabilityHealth": "UP", - "location": "Global/Keele Campus/Bethune Residence/Floor 5/Room 123" - } - ] - try: - logger.debug("Running update_ap_data_task") - with patch("ap_monitor.app.main.scheduler.add_job"): - # Run the update task - update_ap_data_task(db=MagicMock(), auth_manager_obj=mock_auth, fetch_ap_data_func=mock_fetch) - - mock_fetch.assert_called_once_with(mock_auth, ANY) - - logger.debug("Fetching updated AP data") - response = client.get("/aps") - assert response.status_code == 200 - data = response.json() - assert len(data) == 1 - assert data[0]["apname"] == "k372-ross-5-28" - assert data[0]["macaddress"] == "a8:9d:21:b9:67:a0" - except Exception as e: - logger.error(f"Error in AP data update test: {str(e)}") - raise - -@pytest.fixture -def mock_scheduler(): - """Create a mock scheduler for testing.""" - scheduler = Mock(spec=BackgroundScheduler) - scheduler.get_job.return_value = None - scheduler.get_jobs.return_value = [] - scheduler.running = True - return scheduler - -@pytest.fixture -def mock_db(): - """Create a mock database session.""" - db = Mock() - db.commit = Mock() - db.rollback = Mock() - db.close = Mock() - return db - -@pytest.fixture -def mock_auth_manager(): - """Create a mock auth manager.""" - auth_manager = Mock() - auth_manager.get_token.return_value = "test_token" - return auth_manager - -def test_cleanup_job(mock_scheduler): - """Test job cleanup functionality.""" - # Test successful cleanup - job_id = "test_job" - mock_scheduler.get_job.return_value = Mock() - cleanup_job(job_id, scheduler_obj=mock_scheduler) - mock_scheduler.remove_job.assert_called_once_with(job_id) - - # Test cleanup of non-existent job - mock_scheduler.reset_mock() - mock_scheduler.get_job.return_value = None - cleanup_job(job_id, scheduler_obj=mock_scheduler) - mock_scheduler.remove_job.assert_not_called() - -def test_reschedule_job(mock_scheduler): - """Test job rescheduling functionality.""" - job_id = "test_job" - func = Mock() - next_run = datetime.now(timezone.utc) + timedelta(minutes=5) - - reschedule_job(job_id, func, next_run, scheduler_obj=mock_scheduler) - mock_scheduler.add_job.assert_called_once() - call_args = mock_scheduler.add_job.call_args[1] - assert call_args["func"] == func - assert call_args["trigger"].run_date == next_run - assert call_args["id"] == job_id - assert call_args["replace_existing"] is True - -def test_update_ap_data_task_success(mock_db, mock_auth_manager): - main_module.MAINTENANCE_UNTIL = None # Ensure not in maintenance - mock_ap_data = [ - { - "deviceName": "test_ap", - "macAddress": "00:11:22:33:44:55", - "location": "Test/Location", - "timestamp": int(datetime.now(timezone.utc).timestamp() * 1000) - } - ] - - with patch("ap_monitor.app.main.fetch_ap_data", return_value=mock_ap_data): - main_module.update_ap_data_task(mock_db, mock_auth_manager) - mock_db.commit.assert_called_once() - mock_db.rollback.assert_not_called() - -def test_update_ap_data_task_failure(mock_db, mock_auth_manager): - main_module.MAINTENANCE_UNTIL = None # Ensure not in maintenance - with patch("ap_monitor.app.main.fetch_ap_data", side_effect=Exception("API Error")): - with pytest.raises(Exception): - main_module.update_ap_data_task(mock_db, mock_auth_manager) - mock_db.rollback.assert_called_once() - mock_db.commit.assert_not_called() - -def test_update_client_count_task_success(mock_db, mock_auth_manager, wireless_db): - main_module.MAINTENANCE_UNTIL = None # Ensure not in maintenance - mock_ap_data = [ - { - "macAddress": "00:11:22:33:44:55", - "name": "test_ap", - "location": "Test/Location", - "clientCount": 10, - "status": "ok" - } - ] - with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback") as mock_fetch: - mock_fetch.return_value = mock_ap_data - main_module.update_client_count_task(mock_db, mock_auth_manager, wireless_db=wireless_db) - mock_db.commit.assert_called_once() - -@pytest.mark.parametrize("mock_ap_data,expected_status,expect_commit", [ - ([{"macAddress": "00:11:22:33:44:55", "name": "test_ap", "location": "Test/Location", "clientCount": 10, "status": "ok"}], "ok", True), - ([{"macAddress": "00:11:22:33:44:56", "name": "test_ap2", "location": "Test/Location2", "clientCount": 0, "status": "fallback"}], "fallback", True), - ([{"macAddress": "00:11:22:33:44:57", "name": "test_ap3", "location": "Test/Location3", "clientCount": None, "status": "unavailable"}], "unavailable", True), - ([], None, False), -]) -def test_update_client_count_task_fallback_cases(mock_db, mock_auth_manager, wireless_db, mock_ap_data, expected_status, expect_commit): - main_module.MAINTENANCE_UNTIL = None # Ensure not in maintenance - with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback") as mock_fetch: - mock_fetch.return_value = mock_ap_data - main_module.update_client_count_task(mock_db, mock_auth_manager, wireless_db=wireless_db) - if expect_commit: - mock_db.commit.assert_called() - else: - mock_db.commit.assert_not_called() - -def test_update_client_count_task_failure(mock_db, mock_auth_manager): - main_module.MAINTENANCE_UNTIL = None # Ensure not in maintenance - with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback", side_effect=Exception("API Error")) as mock_fetch: - with pytest.raises(Exception): - main_module.update_client_count_task(mock_db, mock_auth_manager) - mock_db.rollback.assert_called_once() - mock_db.commit.assert_not_called() - -def test_health_check_healthy(mock_scheduler): - """Test health check endpoint when system is healthy.""" - mock_job = Mock() - mock_job.id = "test_job" - mock_job.name = "Test Job" - mock_job.next_run_time = datetime.now(timezone.utc) + timedelta(minutes=5) - mock_scheduler.get_jobs.return_value = [mock_job] - - with patch("ap_monitor.app.main.scheduler", mock_scheduler): - response = health_check() - assert response["status"] == "healthy" - assert response["scheduler"]["running"] is True - assert len(response["scheduler"]["jobs"]) == 1 - assert response["scheduler"]["jobs"][0]["id"] == "test_job" - assert response["scheduler"]["jobs"][0]["state"] == "running" - -def test_health_check_unhealthy(mock_scheduler): - """Test health check endpoint when system is unhealthy.""" - mock_scheduler.get_jobs.side_effect = Exception("Scheduler Error") - - with patch("ap_monitor.app.main.scheduler", mock_scheduler): - response = health_check() - assert response["status"] == "unhealthy" - assert "error" in response - assert "Scheduler Error" in response["error"] - -def test_scheduler_configuration(): - """Test that scheduler is configured correctly with 5-minute intervals.""" - # Create scheduler - scheduler = BackgroundScheduler(timezone=timezone.utc) - - try: - # Add test jobs - scheduler.add_job( - lambda: None, - 'cron', - minute='*/5', - second=0, - id='test_job' - ) - - # Start the scheduler - scheduler.start() - - # Get job - job = scheduler.get_job('test_job') - - # Verify job configuration - assert isinstance(job.trigger, CronTrigger) - - # Calculate next run time - now = datetime.now(timezone.utc) - next_run = job.next_run_time - - # Verify next run is at next 5-minute mark - assert next_run.minute % 5 == 0 - assert next_run.second == 0 - assert next_run.microsecond == 0 - - # Verify next run is in the future - assert next_run > now - - # Verify time difference is less than 5 minutes - time_diff = next_run - now - assert timedelta(0) <= time_diff <= timedelta(minutes=5) - - # Calculate next few run times to verify 5-minute intervals - next_runs = [] - current_time = next_run - for _ in range(3): - current_time = job.trigger.get_next_fire_time(current_time, current_time) - if current_time: - next_runs.append(current_time) - - # Verify intervals between runs are 5 minutes - for i in range(len(next_runs) - 1): - interval = next_runs[i + 1] - next_runs[i] - assert interval == timedelta(minutes=5) - - finally: - # Clean up - scheduler.shutdown() - -def test_calculate_next_run_time(): - """Test next run time calculation.""" - now = datetime.now(TORONTO_TZ) - next_run = calculate_next_run_time() - - # Next run should be in the future - assert next_run > now - - # Next run should be approximately 4 to 5 minutes from now (allowing for execution time and rounding) - time_diff = next_run - now - assert timedelta(minutes=4) <= time_diff <= timedelta(minutes=5) - - # Next run should have zero seconds and microseconds - assert next_run.second == 0 - assert next_run.microsecond == 0 - -def test_task_rescheduling(): - """Test that tasks are rescheduled 5 minutes after completion.""" - # Create scheduler - scheduler = BackgroundScheduler(timezone=timezone.utc) - - try: - # Add test job - scheduler.add_job( - lambda: None, - 'interval', - minutes=5, - id='test_job' - ) - - # Start the scheduler - scheduler.start() - - # Get job - job = scheduler.get_job('test_job') - - # Simulate task completion and rescheduling - now = datetime.now(timezone.utc) - - # Reschedule the job with a new trigger - scheduler.reschedule_job( - 'test_job', - trigger='interval', - minutes=5 - ) - - # Get updated job - job = scheduler.get_job('test_job') - - # Verify interval - assert job.trigger.interval == timedelta(minutes=5) - - # Verify next run is approximately 5 minutes from now - time_diff = job.next_run_time - now - assert timedelta(minutes=4, seconds=55) <= time_diff <= timedelta(minutes=5, seconds=5) - - finally: - scheduler.shutdown() - -def test_wireless_count_db_creation(wireless_db): - """Test that wireless_count database tables are created correctly.""" - # Check if tables exist - inspector = inspect(wireless_db.get_bind()) - tables = inspector.get_table_names() - - # Verify essential tables exist - assert 'buildings' in tables - assert 'client_counts' in tables - assert 'campuses' in tables - - # Verify table structures - buildings_columns = {col['name'] for col in inspector.get_columns('buildings')} - assert 'building_id' in buildings_columns - assert 'building_name' in buildings_columns - assert 'campus_id' in buildings_columns - assert 'latitude' in buildings_columns - assert 'longitude' in buildings_columns - - client_counts_columns = {col['name'] for col in inspector.get_columns('client_counts')} - assert 'count_id' in client_counts_columns - assert 'building_id' in client_counts_columns - assert 'client_count' in client_counts_columns - assert 'time_inserted' in client_counts_columns - -def test_wireless_count_data_update(wireless_db, apclient_db): - """Test that client counts are properly aggregated and stored in wireless_count DB.""" - campus = Campus(campus_name="Test Campus 1") - wireless_db.add(campus) - wireless_db.commit() - building = Building( - building_name="Test Building 1", - campus_id=campus.campus_id, - latitude=43.7735473000, - longitude=-79.5062752000 - ) - wireless_db.add(building) - wireless_db.commit() - fresh_building = wireless_db.query(Building).filter_by(building_name="Test Building 1").first() - building_id = fresh_building.building_id - ap_building = ApBuilding(buildingname="Test Building 1") - apclient_db.add(ap_building) - apclient_db.commit() - floor = Floor(buildingid=ap_building.buildingid, floorname="Floor 1") - apclient_db.add(floor) - apclient_db.commit() - test_aps = [ - { - "macAddress": "00:11:22:33:44:55", - "name": "AP1", - "location": "Test Building 1/Floor 1", - "clientCount": 30, - "status": "ok" - }, - { - "macAddress": "00:11:22:33:44:56", - "name": "AP2", - "location": "Test Building 1/Floor 1", - "clientCount": 60, - "status": "ok" - } - ] - mock_auth = Mock() - mock_auth.get_token.return_value = "test_token" - with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback") as mock_fetch: - mock_fetch.return_value = test_aps - update_client_count_task(db=apclient_db, auth_manager_obj=mock_auth, wireless_db=wireless_db) - client_counts = wireless_db.query(ClientCount).filter_by(building_id=building_id).all() - assert len(client_counts) > 0 - -def test_wireless_count_multiple_updates(wireless_db, apclient_db): - """Test that multiple updates to wireless_count DB work correctly.""" - campus = Campus(campus_name="Test Campus 2") - wireless_db.add(campus) - wireless_db.commit() - building = Building( - building_name="Test Building 2", - campus_id=campus.campus_id, - latitude=43.7735473000, - longitude=-79.5062752000 - ) - wireless_db.add(building) - wireless_db.commit() - fresh_building = wireless_db.query(Building).filter_by(building_name="Test Building 2").first() - building_id = fresh_building.building_id - ap_building = ApBuilding(buildingname="Test Building 2") - apclient_db.add(ap_building) - apclient_db.commit() - floor = Floor(buildingid=ap_building.buildingid, floorname="Floor 1") - apclient_db.add(floor) - apclient_db.commit() - test_data = [ - { - "macAddress": "00:11:22:33:44:55", - "name": "AP1", - "location": "Test Building 2/Floor 1", - "clientCount": 30, - "status": "ok" - }, - { - "macAddress": "00:11:22:33:44:55", - "name": "AP1", - "location": "Test Building 2/Floor 1", - "clientCount": 60, - "status": "ok" - } - ] - mock_auth = Mock() - mock_auth.get_token.return_value = "test_token" - for ap_data in test_data: - with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback") as mock_fetch: - mock_fetch.return_value = [ap_data] - update_client_count_task(db=apclient_db, auth_manager_obj=mock_auth, wireless_db=wireless_db) - latest_count = wireless_db.query(ClientCount)\ - .filter_by(building_id=building_id)\ - .order_by(ClientCount.time_inserted.desc())\ - .first() - assert latest_count is not None - -def test_get_client_counts_with_new_dep(client): - """Test /client-counts endpoint with the new FastAPI-compatible dependency.""" - from ap_monitor.app.db import get_apclient_db_dep - # Prepare mock session and data - mock_ap = MagicMock() - mock_ap.apname = "k372-ross-5-28" - mock_ap.apid = 1 - - mock_radio = MagicMock() - mock_radio.radioname = "radio0" - mock_radio.radioid = 1 - - mock_cc = MagicMock() - mock_cc.countid = 1 - mock_cc.clientcount = 15 - mock_cc.apid = 1 - mock_cc.radioid = 1 - mock_cc.timestamp = datetime.now(timezone.utc) - mock_cc.accesspoint = mock_ap - mock_cc.radio = mock_radio - # No building_id for ClientCountAP - - mock_query = MagicMock() - mock_query.filter.return_value = mock_query - mock_query.all.return_value = [mock_cc] - - mock_session = MagicMock() - mock_session.query.return_value = mock_query - - def override(): - yield mock_session - - app.dependency_overrides[get_apclient_db_dep] = override - response = client.get("/client-counts") - assert response.status_code == 200 - data = response.json() - assert len(data) == 1 - assert data[0]["client_count"] == 15 - assert data[0]["count_id"] == 1 - assert data[0]["apid"] == 1 - assert data[0]["radioid"] == 1 - assert "timestamp" in data[0] - app.dependency_overrides.clear() - -def test_update_client_count_task_fallback_network_devices(apclient_db, wireless_db): - campus = Campus(campus_name="Test Campus") - wireless_db.add(campus) - wireless_db.commit() - building = Building(building_name="Ross", campus_id=campus.campus_id, latitude=0, longitude=0) - wireless_db.add(building) - wireless_db.commit() - ap_building = ApBuilding(buildingname="Ross") - apclient_db.add(ap_building) - apclient_db.commit() - floor = Floor(buildingid=ap_building.buildingid, floorname="Floor 1") - apclient_db.add(floor) - apclient_db.commit() - ap_data = { - "macAddress": "00:11:22:33:44:55", - "name": "AP1", - "location": "Ross/Floor 1", - "clientCount": 5, - "status": "ok" - } - with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback') as mock_fetch: - mock_fetch.return_value = [ap_data] - update_client_count_task(db=apclient_db, auth_manager_obj=Mock(), wireless_db=wireless_db) - result = wireless_db.query(ClientCount).all() - assert any(cc.client_count == 5 for cc in result) - -def test_update_client_count_task_fallback_clients(apclient_db, wireless_db): - campus = Campus(campus_name="Test Campus") - wireless_db.add(campus) - wireless_db.commit() - building = Building(building_name="Scott Library", campus_id=campus.campus_id, latitude=0, longitude=0) - wireless_db.add(building) - wireless_db.commit() - ap_building = ApBuilding(buildingname="Scott Library") - apclient_db.add(ap_building) - apclient_db.commit() - floor = Floor(buildingid=ap_building.buildingid, floorname="Floor 2") - apclient_db.add(floor) - apclient_db.commit() - ap_data = { - "macAddress": "00:11:22:33:44:66", - "name": "AP2", - "location": "Scott Library/Floor 2", - "clientCount": 2, - "status": "fallback" - } - with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback') as mock_fetch: - mock_fetch.return_value = [ap_data] - update_client_count_task(db=apclient_db, auth_manager_obj=Mock(), wireless_db=wireless_db) - result = wireless_db.query(ClientCount).all() - assert any(cc.client_count == 2 for cc in result) - -def test_update_client_count_task_fallback_site_health(apclient_db, wireless_db): - campus = Campus(campus_name="Test Campus") - wireless_db.add(campus) - wireless_db.commit() - building = Building(building_name="BuildingC", campus_id=campus.campus_id, latitude=0, longitude=0) - wireless_db.add(building) - wireless_db.commit() - building_id = building.building_id # Store before session expires - ap_data = { - "macAddress": None, - "name": "BuildingC", - "location": "BuildingC", - "clientCount": 7, - "status": "siteHealth" - } - with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback') as mock_fetch: - mock_fetch.return_value = [ap_data] - update_client_count_task(db=apclient_db, auth_manager_obj=Mock(), wireless_db=wireless_db) - result = wireless_db.query(ClientCount).filter_by(building_id=building_id).all() - assert any(cc.client_count == 0 for cc in result) - -def test_update_client_count_task_fallback_clients_count(apclient_db, wireless_db): - campus = Campus(campus_name="Test Campus") - wireless_db.add(campus) - wireless_db.commit() - building = Building(building_name="Unknown", campus_id=campus.campus_id, latitude=0, longitude=0) - wireless_db.add(building) - wireless_db.commit() - building_id = building.building_id # Store before session expires - ap_data = { - "macAddress": None, - "name": "Unknown", - "location": "Unknown", - "clientCount": 3, - "status": "clients/count" - } - with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback') as mock_fetch: - mock_fetch.return_value = [ap_data] - update_client_count_task(db=apclient_db, auth_manager_obj=Mock(), wireless_db=wireless_db) - result = wireless_db.query(ClientCount).filter_by(building_id=building_id).all() - assert any(cc.client_count == 0 for cc in result) - -def test_update_client_count_task_fallback_none(apclient_db, wireless_db): - with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback') as mock_fetch: - mock_fetch.return_value = { - 'source': 'none', - 'data': [] - } - update_client_count_task(db=apclient_db, auth_manager_obj=Mock(), wireless_db=wireless_db) - result = wireless_db.query(ClientCount).all() - assert len(result) == 0 - -def test_update_client_count_task_dict_response(mock_db, mock_auth_manager, caplog): - """ - Test update_client_count_task handles the case where fetch_ap_client_data_with_fallback returns a dict (API error/rate limit). - Should log the error and return early without processing or committing. - """ - import ap_monitor.app.main as main_module - main_module.MAINTENANCE_UNTIL = None # Ensure not in maintenance - error_dict = {"error": "API rate limit", "status": 429} - with patch("ap_monitor.app.main.fetch_ap_client_data_with_fallback", return_value=error_dict) as mock_fetch: - with caplog.at_level("ERROR"): - main_module.update_client_count_task(mock_db, mock_auth_manager) - # Should log the error about dict response - assert any("fetch_ap_client_data_with_fallback returned a dict" in r for r in caplog.text.splitlines()) - mock_db.commit.assert_not_called() - mock_db.rollback.assert_not_called() - -def test_update_ap_data_task_without_db(monkeypatch): - """Test update_ap_data_task creates and closes its own DB session if none is provided.""" - # Mock fetch_ap_data_func to return minimal valid AP data - mock_ap_data = [{ - "name": "TestAP", - "macAddress": "00:11:22:33:44:55", - "ipAddress": "192.168.1.100", - "model": "ModelX", - "reachabilityHealth": "UP", - "location": "Test Building/Floor 1", - "clientCount": {"radio0": 5, "radio1": 3} - }] - def mock_fetch_ap_data(auth_manager_obj, rounded_unix_timestamp): - return mock_ap_data - # Patch time.sleep to avoid real waiting - monkeypatch.setattr("time.sleep", lambda x: None) - # Call the function without db argument - try: - update_ap_data_task(db=None, auth_manager_obj=None, fetch_ap_data_func=mock_fetch_ap_data, retries=0) - except Exception as e: - pytest.fail(f"update_ap_data_task raised an exception when called without db: {e}") - - + raise \ No newline at end of file diff --git a/ap_monitor/tests/test_models.py b/ap_monitor/tests/test_models.py index dcce20d..4382b10 100644 --- a/ap_monitor/tests/test_models.py +++ b/ap_monitor/tests/test_models.py @@ -1,266 +1,205 @@ from ap_monitor.app.models import ( - AccessPoint, ClientCount, Building, Floor, Campus, - ApBuilding, Room, RadioType, ClientCountAP + AccessPoint, ClientCount, Building, Floor, Campus, Room, RadioType ) from datetime import datetime, timezone from sqlalchemy.exc import IntegrityError import pytest -import time -from decimal import Decimal -import ipaddress @pytest.fixture(autouse=True) -def cleanup_database(wireless_db, apclient_db): +def cleanup_database(test_db): + db = test_db() """Clean up the database before each test.""" - # Clean wireless_count database - wireless_db.query(ClientCount).delete() - wireless_db.query(Building).delete() - wireless_db.query(Campus).delete() - wireless_db.commit() - - # Clean apclientcount database - apclient_db.query(ClientCountAP).delete() - apclient_db.query(AccessPoint).delete() - apclient_db.query(Room).delete() - apclient_db.query(Floor).delete() - apclient_db.query(ApBuilding).delete() - apclient_db.query(RadioType).delete() - apclient_db.commit() - -def test_create_campus(wireless_db): + db.query(ClientCount).delete() + db.query(AccessPoint).delete() + db.query(Room).delete() + db.query(Floor).delete() + db.query(Building).delete() + db.query(Campus).delete() + db.query(RadioType).delete() + db.commit() + +def test_create_campus(test_db): """Test creating a campus.""" - campus = Campus(campus_name="Test Campus 1") - wireless_db.add(campus) - wireless_db.commit() - assert campus.campus_id is not None - assert campus.campus_name == "Test Campus 1" - -def test_create_building(wireless_db): + db = test_db() + campus = Campus(name="Test Campus 1") + db.add(campus) + db.commit() + assert campus.id is not None + assert campus.name == "Test Campus 1" + +def test_create_building(test_db): """Test creating a building.""" + db = test_db() # Create campus first - campus = Campus(campus_name="Test Campus 2") - wireless_db.add(campus) - wireless_db.commit() + campus = Campus(name="Test Campus 2") + db.add(campus) + db.commit() # Create building building = Building( - building_name="Test Building 1", - campus_id=campus.campus_id, + name="Test Building 1", + campus_id=campus.id, latitude=37.7749, longitude=-122.4194 ) - wireless_db.add(building) - wireless_db.commit() - assert building.building_id is not None - assert building.building_name == "Test Building 1" - assert building.campus_id == campus.campus_id + db.add(building) + db.commit() + assert building.id is not None + assert building.name == "Test Building 1" + assert building.campus_id == campus.id -def test_create_client_count(wireless_db): +def test_create_client_count(test_db): """Test creating a client count.""" + db = test_db() # Create campus and building - campus = Campus(campus_name="Test Campus 3") - wireless_db.add(campus) - wireless_db.commit() + campus = Campus(name="Test Campus 3") + db.add(campus) + db.commit() building = Building( - building_name="Test Building 2", - campus_id=campus.campus_id, + name="Test Building 2", + campus_id=campus.id, latitude=37.7749, longitude=-122.4194 ) - wireless_db.add(building) - wireless_db.commit() + db.add(building) + db.commit() + + floor = Floor(name="1st Floor", building_id=building.id) + db.add(floor) + db.commit() + + ap = AccessPoint( + name="AP-01", + mac_address="00:11:22:33:44:55", + ip_address="192.168.1.1", + model="AIR-CAP3702I-A-K9", + is_active=True, + building_id=building.id, + floor_id=floor.id + ) + db.add(ap) + db.commit() + + radio_type = RadioType(name="2.4GHz") + db.add(radio_type) + db.commit() # Create client count client_count = ClientCount( - building_id=building.building_id, - client_count=10 + access_point_id=ap.id, + radio_type_id=radio_type.id, + count=10, + timestamp=datetime.now(timezone.utc) ) - wireless_db.add(client_count) - wireless_db.commit() - assert client_count.count_id is not None - assert client_count.client_count == 10 - assert client_count.building_id == building.building_id + db.add(client_count) + db.commit() + assert client_count.id is not None + assert client_count.count == 10 + assert client_count.access_point_id == ap.id -def test_create_room_and_access_point(apclient_db): +def test_create_room_and_access_point(test_db): """Test creating a room and access point.""" + db = test_db() # Create building, floor, and room - building = ApBuilding(buildingname="Test Building 3") - apclient_db.add(building) - apclient_db.commit() + campus = Campus(name="Test Campus") + db.add(campus) + db.commit() - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - apclient_db.add(floor) - apclient_db.commit() + building = Building(name="Test Building 3", campus_id=campus.id) + db.add(building) + db.commit() - room = Room(floorid=floor.floorid, roomname="Room 101") - apclient_db.add(room) - apclient_db.commit() + floor = Floor(building_id=building.id, name="1st Floor") + db.add(floor) + db.commit() - # Create access point - ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname="AP-01", - macaddress="00:11:22:33:44:55", - ipaddress="192.168.1.1", - modelname="AIR-CAP3702I-A-K9", - isactive=True - ) - apclient_db.add(ap) - apclient_db.commit() - - # Verify relationships - assert ap.buildingid == building.buildingid - assert ap.floorid == floor.floorid - assert ap.roomid == room.roomid - -def test_create_radio_and_client_count(apclient_db): - """Test creating a radio type and client count.""" - # Create building, floor, room, and AP - building = ApBuilding(buildingname="Test Building 4") - apclient_db.add(building) - apclient_db.commit() - - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - apclient_db.add(floor) - apclient_db.commit() - - room = Room(floorid=floor.floorid, roomname="Room 101") - apclient_db.add(room) - apclient_db.commit() + room = Room(floor_id=floor.id, name="Room 101") + db.add(room) + db.commit() + # Create access point ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname="AP-01", - macaddress="00:11:22:33:44:55", - ipaddress="192.168.1.1", - modelname="AIR-CAP3702I-A-K9", - isactive=True + building_id=building.id, + floor_id=floor.id, + room_id=room.id, + name="AP-01", + mac_address="00:11:22:33:44:55", + ip_address="192.168.1.1", + model="AIR-CAP3702I-A-K9", + is_active=True ) - apclient_db.add(ap) - apclient_db.commit() - - # Create radio type - radio = RadioType(radioname="radio0", radioid=1) - apclient_db.add(radio) - apclient_db.commit() - - # Create client count - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=10, - timestamp=datetime.now(timezone.utc) - ) - apclient_db.add(client_count) - apclient_db.commit() + db.add(ap) + db.commit() # Verify relationships - assert client_count.apid == ap.apid - assert client_count.radioid == radio.radioid + assert ap.building_id == building.id + assert ap.floor_id == floor.id + assert ap.room_id == room.id -def test_unique_constraints(wireless_db, apclient_db): +def test_unique_constraints(test_db): """Test unique constraints.""" + db = test_db() # Test campus name uniqueness - campus1 = Campus(campus_name="Test Campus 5") - wireless_db.add(campus1) - wireless_db.commit() + campus1 = Campus(name="Test Campus 5") + db.add(campus1) + db.commit() - campus2 = Campus(campus_name="Test Campus 5") - wireless_db.add(campus2) + campus2 = Campus(name="Test Campus 5") + db.add(campus2) with pytest.raises(IntegrityError): - wireless_db.commit() - wireless_db.rollback() + db.commit() + db.rollback() - # Test building name uniqueness - building1 = ApBuilding(buildingname="Test Building 5") - apclient_db.add(building1) - apclient_db.commit() + # Test access point mac_address uniqueness + ap1 = AccessPoint(name="AP1", mac_address="00:00:00:00:00:01") + db.add(ap1) + db.commit() - building2 = ApBuilding(buildingname="Test Building 5") - apclient_db.add(building2) + ap2 = AccessPoint(name="AP2", mac_address="00:00:00:00:00:01") + db.add(ap2) with pytest.raises(IntegrityError): - apclient_db.commit() - apclient_db.rollback() + db.commit() + db.rollback() -def test_cascade_delete(wireless_db, apclient_db): +def test_cascade_delete(test_db): """Test cascade delete functionality.""" - # Test wireless_count cascade - campus = Campus(campus_name="Test Campus 6") - wireless_db.add(campus) - wireless_db.commit() + db = test_db() + # Test campus cascade + campus = Campus(name="Test Campus 6") + db.add(campus) + db.commit() building = Building( - building_name="Test Building 6", - campus_id=campus.campus_id, + name="Test Building 6", + campus_id=campus.id, latitude=37.7749, longitude=-122.4194 ) - wireless_db.add(building) - wireless_db.commit() - - client_count = ClientCount( - building_id=building.building_id, - client_count=10 - ) - wireless_db.add(client_count) - wireless_db.commit() - - # Delete campus and verify cascade - wireless_db.delete(campus) - wireless_db.commit() - - assert wireless_db.query(Building).filter_by(building_id=building.building_id).first() is None - assert wireless_db.query(ClientCount).filter_by(count_id=client_count.count_id).first() is None - - # Test apclientcount cascade - building = ApBuilding(buildingname="Test Building 7") - apclient_db.add(building) - apclient_db.commit() + db.add(building) + db.commit() - floor = Floor(buildingid=building.buildingid, floorname="1st Floor") - apclient_db.add(floor) - apclient_db.commit() + floor = Floor(name="1st Floor", building_id=building.id) + db.add(floor) + db.commit() - room = Room(floorid=floor.floorid, roomname="Room 101") - apclient_db.add(room) - apclient_db.commit() + ap = AccessPoint(name="AP-01", mac_address="00:11:22:33:44:55", building_id=building.id, floor_id=floor.id) + db.add(ap) + db.commit() - ap = AccessPoint( - buildingid=building.buildingid, - floorid=floor.floorid, - roomid=room.roomid, - apname="AP-01", - macaddress="00:11:22:33:44:55", - ipaddress="192.168.1.1", - modelname="AIR-CAP3702I-A-K9", - isactive=True - ) - apclient_db.add(ap) - apclient_db.commit() - - radio = RadioType(radioname="radio0", radioid=1) - apclient_db.add(radio) - apclient_db.commit() - - client_count = ClientCountAP( - apid=ap.apid, - radioid=radio.radioid, - clientcount=10, + client_count = ClientCount( + access_point_id=ap.id, + count=10, timestamp=datetime.now(timezone.utc) ) - apclient_db.add(client_count) - apclient_db.commit() + db.add(client_count) + db.commit() - # Delete building and verify cascade - apclient_db.delete(building) - apclient_db.commit() + # Delete campus and verify cascade + db.delete(campus) + db.commit() - assert apclient_db.query(Floor).filter_by(floorid=floor.floorid).first() is None - assert apclient_db.query(Room).filter_by(roomid=room.roomid).first() is None - assert apclient_db.query(AccessPoint).filter_by(apid=ap.apid).first() is None - assert apclient_db.query(ClientCountAP).filter_by(countid=client_count.countid).first() is None \ No newline at end of file + assert db.query(Building).filter_by(id=building.id).first() is None + assert db.query(Floor).filter_by(id=floor.id).first() is None + assert db.query(AccessPoint).filter_by(id=ap.id).first() is None + assert db.query(ClientCount).filter_by(id=client_count.id).first() is None \ No newline at end of file diff --git a/ap_monitor/tests/test_update_task.py b/ap_monitor/tests/test_update_task.py new file mode 100644 index 0000000..07b23e0 --- /dev/null +++ b/ap_monitor/tests/test_update_task.py @@ -0,0 +1,157 @@ +import pytest +from unittest.mock import MagicMock, patch +from ap_monitor.app.main import update_client_count_task +from ap_monitor.app.models import ( + AccessPoint, ClientCount, Building, Floor, Campus, RadioType +) +from datetime import datetime, timezone, timedelta +from urllib.error import HTTPError + +@pytest.fixture +def mock_db_session(): + """Creates a mock database session.""" + db = MagicMock() + db.query.return_value.filter_by.return_value.first.return_value = None + return db + +@pytest.fixture +def mock_auth_manager(): + """Creates a mock authentication manager.""" + auth_manager = MagicMock() + auth_manager.get_token.return_value = "test_token" + return auth_manager + +@patch('ap_monitor.app.main.normalize_building_name', return_value='Test-Building') +@patch('ap_monitor.app.main.parse_ap_name_for_location', return_value=('Test-Building', '1', '1')) +def test_update_client_count_task_success(mock_parse, mock_normalize, mock_db_session, mock_auth_manager): + """Test that the function correctly updates the client count for an existing access point.""" + # Arrange + ap_data = [ + { + 'macAddress': '00:00:00:00:00:00', + 'name': 'Test-AP-1', + 'location': 'Global/Test-Campus/Test-Building/1', + 'clientCount': 10, + } + ] + with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback', return_value=ap_data): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + mock_db_session.add.assert_called() + mock_db_session.commit.assert_called_once() + +@patch('ap_monitor.app.main.normalize_building_name', return_value='Test-Building') +@patch('ap_monitor.app.main.parse_ap_name_for_location', return_value=('Test-Building', '2', '1')) +def test_update_client_count_task_new_ap(mock_parse, mock_normalize, mock_db_session, mock_auth_manager): + """Test that the function correctly creates a new access point and updates the client count.""" + # Arrange + ap_data = [ + { + 'macAddress': '00:00:00:00:00:01', + 'name': 'Test-AP-2', + 'location': 'Global/Test-Campus/Test-Building/2', + 'clientCount': 5, + } + ] + with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback', return_value=ap_data): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + mock_db_session.add.assert_called() + mock_db_session.commit.assert_called_once() + +def test_update_client_count_task_empty_data(mock_db_session, mock_auth_manager): + """Test that the function correctly handles the case where the fetch function returns an empty list.""" + # Arrange + with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback', return_value=[]): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + mock_db_session.commit.assert_not_called() + +def test_update_client_count_task_error_dict(mock_db_session, mock_auth_manager): + """Test that the function correctly handles the case where the fetch function returns a dictionary.""" + # Arrange + with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback', return_value={'error': 'Test Error'}): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + mock_db_session.commit.assert_not_called() + +def test_update_client_count_task_http_error(mock_db_session, mock_auth_manager): + """Test that the function correctly handles the case where the fetch function returns a HTTPError.""" + # Arrange + with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback', side_effect=HTTPError("url", 500, "Internal Server Error", {}, None)): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + mock_db_session.rollback.assert_called_once() + +def test_update_client_count_task_exception(mock_db_session, mock_auth_manager): + """Test that the function correctly handles the case where the fetch function returns an exception.""" + # Arrange + with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback', side_effect=Exception("Test Exception")): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + mock_db_session.rollback.assert_called_once() + +def test_update_client_count_task_maintenance_window(mock_db_session, mock_auth_manager): + """Test that the function correctly handles the maintenance window.""" + # Arrange + with patch('ap_monitor.app.main.MAINTENANCE_UNTIL', datetime.now(timezone.utc) + timedelta(hours=1)): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + mock_db_session.commit.assert_not_called() + +@patch('ap_monitor.app.main.normalize_building_name', return_value='Test-Building') +@patch('ap_monitor.app.main.parse_ap_name_for_location', return_value=('Test-Building', '1', '1')) +def test_update_client_count_task_client_count_dict(mock_parse, mock_normalize, mock_db_session, mock_auth_manager): + """Test that the function correctly handles the case where the clientCount is a dictionary.""" + # Arrange + ap_data = [ + { + 'macAddress': '00:00:00:00:00:00', + 'name': 'Test-AP-1', + 'location': 'Global/Test-Campus/Test-Building/1', + 'clientCount': {'radio0': 10, 'radio1': 5}, + } + ] + with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback', return_value=ap_data): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + assert mock_db_session.add.call_count == 7 + mock_db_session.commit.assert_called_once() + +@patch('ap_monitor.app.main.normalize_building_name', return_value='Test-Building') +@patch('ap_monitor.app.main.parse_ap_name_for_location', return_value=('Test-Building', '1', '1')) +def test_update_client_count_task_is_active(mock_parse, mock_normalize, mock_db_session, mock_auth_manager): + """Test that the function correctly handles the is_active flag.""" + # Arrange + ap_data = [ + { + 'macAddress': '00:00:00:00:00:00', + 'name': 'Test-AP-1', + 'location': 'Global/Test-Campus/Test-Building/1', + 'clientCount': 10, + 'raw': {'reachabilityStatus': 'UP'} + } + ] + with patch('ap_monitor.app.main.fetch_ap_client_data_with_fallback', return_value=ap_data): + # Act + update_client_count_task(db=mock_db_session, auth_manager_obj=mock_auth_manager) + + # Assert + mock_db_session.add.assert_called() + mock_db_session.commit.assert_called_once() diff --git a/script/create_and_send_ap_monitor.sh b/script/create_and_send_ap_monitor.sh deleted file mode 100755 index cb69582..0000000 --- a/script/create_and_send_ap_monitor.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -# Transfer the tarball to the remote server - -set -e - -if [ ! -d "ap_monitor" ]; then - echo "Error: ap_monitor directory not found. Please run this script from the project root directory." >&2 - exit 1 -fi - -# Variables -REMOTE_USER="statclcn" -REMOTE_HOST="statifi.netops.yorku.ca" -REMOTE_DIR="/home/statclcn/client_count" -TAR_NAME="ap_monitor.tar.gz" - -find ap_monitor -type d -name "__pycache__" -exec rm -rf {} + - -COPYFILE_DISABLE=1 tar -czvf "$TAR_NAME" ap_monitor - -scp "$TAR_NAME" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR" - -rm -f "$TAR_NAME" - -echo "Done! Archive created, transferred, and cleaned up." \ No newline at end of file From f847795d9cb7cf30abe251af48f507962b207a41 Mon Sep 17 00:00:00 2001 From: ghosts6 Date: Sun, 12 Oct 2025 19:00:17 -0400 Subject: [PATCH 3/5] fix for ci/cd --- .github/workflows/python-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index fe14255..4de4120 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -45,4 +45,4 @@ jobs: run: docker build -t ap-monitor . - name: Run tests in Docker - run: docker run --env-file .env ap-monitor pytest -v ap_monitor/tests/ + run: docker run --env-file .env ap-monitor pytest -v tests/ From 0b437632a04276c6e37ddbf9c4406ef308f0bdd0 Mon Sep 17 00:00:00 2001 From: Kiarash Bashokian <95994481+Ghosts6@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:06:01 -0400 Subject: [PATCH 4/5] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a399f03..ec17ba3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **AP Monitor** is a powerful and flexible FastAPI-based application designed to monitor wireless Access Points (APs) and client counts by integrating with Cisco DNA Center APIs. It provides real-time data, historical trends, and advanced diagnostics to help you manage your wireless network effectively. -![Project Banner](https://user-images.githubusercontent.com/10666823/189179629-2d9a1b57-5e43-4b70-8488-17596713c093.png) +![Project Banner](https://github.com/user-attachments/assets/c8ab03dd-598f-438f-b8a8-d9f3c60c2e48) --- @@ -133,4 +133,4 @@ Contributions are welcome! Please feel free to submit a pull request or open an ## 📄 License -This project is licensed under the MIT License. See the `LICENSE` file for details. \ No newline at end of file +This project is licensed under the MIT License. See the `LICENSE` file for details. From 141e1759912f40466d81320dd99e921b358c4883 Mon Sep 17 00:00:00 2001 From: ghosts6 Date: Sat, 18 Oct 2025 21:58:23 -0400 Subject: [PATCH 5/5] refactor: Improve codebase modularity and security --- ap_monitor/app/main.py | 114 ++++++++++++++++--------------------- ap_monitor/app/mapping.py | 12 ++-- ap_monitor/app/security.py | 3 +- 3 files changed, 55 insertions(+), 74 deletions(-) diff --git a/ap_monitor/app/main.py b/ap_monitor/app/main.py index b6d9d02..7e04d19 100644 --- a/ap_monitor/app/main.py +++ b/ap_monitor/app/main.py @@ -178,6 +178,17 @@ async def lifespan(app: FastAPI): ) +@app.post("/mappings/reload", tags=["Mappings"]) +def reload_mappings_endpoint(api_key: str = Depends(get_api_key)): + """Reload all mappings from the database.""" + try: + reload_mappings() + return {"message": "Mappings reloaded successfully"} + except Exception as e: + logger.error(f"Error reloading mappings: {e}") + raise HTTPException(status_code=500, detail="Error reloading mappings") + + def _get_or_create(db: Session, model, defaults=None, **kwargs): """Get or create a database record.""" @@ -449,42 +460,6 @@ async def get_buildings(db: Session = Depends(get_db_dep), api_key: str = Depend raise HTTPException(status_code=500, detail="Internal server error") -@app.get("/buildings/{building_id}/client-count", tags=["Buildings"]) -def get_building_client_count(building_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): - """Get the total client count for a specific building.""" - try: - logger.info(f"Fetching client count for building ID {building_id}") - total_count = db.query(func.sum(ClientCount.count)) \ - .join(AccessPoint) \ - .filter(AccessPoint.building_id == building_id) \ - .scalar() - return {"building_id": building_id, "total_client_count": total_count or 0} - except SQLAlchemyError as e: - logger.error(f"Database error in /buildings/{{building_id}}/client-count: {e}") - raise HTTPException(status_code=500, detail="Database error") - except Exception as e: - logger.error(f"Unexpected error in /buildings/{{building_id}}/client-count: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - - -@app.get("/campus/{campus_id}/client-count", tags=["Campus"]) -def get_campus_client_count(campus_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): - """Get the total client count for a specific campus.""" - try: - logger.info(f"Fetching client count for campus ID {campus_id}") - total_count = db.query(func.sum(ClientCount.count)) \ - .join(AccessPoint) \ - .join(Building) \ - .filter(Building.campus_id == campus_id) \ - .scalar() - return {"campus_id": campus_id, "total_client_count": total_count or 0} - except SQLAlchemyError as e: - logger.error(f"Database error in /campus/{{campus_id}}/client-count: {e}") - raise HTTPException(status_code=500, detail="Database error") - except Exception as e: - logger.error(f"Unexpected error in /campus/{{campus_id}}/client-count: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - @app.get("/floors/{building_id}", response_model=List[dict], tags=["Floors"]) @timed_cache(ttl=300) @@ -505,24 +480,6 @@ async def get_floors(building_id: int, db: Session = Depends(get_db_dep), api_ke raise HTTPException(status_code=500, detail="Internal server error") -@app.get("/floors/{floor_id}/client-count", tags=["Floors"]) -def get_floor_client_count(floor_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): - """Get the total client count for a specific floor.""" - try: - logger.info(f"Fetching client count for floor ID {floor_id}") - total_count = db.query(func.sum(ClientCount.count)) \ - .join(AccessPoint) \ - .filter(AccessPoint.floor_id == floor_id) \ - .scalar() - return {"floor_id": floor_id, "total_client_count": total_count or 0} - except SQLAlchemyError as e: - logger.error(f"Database error in /floors/{{floor_id}}/client-count: {e}") - raise HTTPException(status_code=500, detail="Database error") - except Exception as e: - logger.error(f"Unexpected error in /floors/{{floor_id}}/client-count: {e}") - raise HTTPException(status_code=500, detail="Internal server error") - - @app.get("/rooms/{floor_id}", response_model=List[dict], tags=["Rooms"]) @timed_cache(ttl=300) async def get_rooms(floor_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): @@ -542,23 +499,48 @@ async def get_rooms(floor_id: int, db: Session = Depends(get_db_dep), api_key: s raise HTTPException(status_code=500, detail="Internal server error") -@app.get("/rooms/{room_id}/client-count", tags=["Rooms"]) -def get_room_client_count(room_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): - """Get the total client count for a specific room.""" + +def get_entity_client_count(db: Session, entity_type: str, entity_id: int): + """Get the total client count for a specific entity (building, campus, floor, or room).""" try: - logger.info(f"Fetching client count for room ID {room_id}") - total_count = db.query(func.sum(ClientCount.count)) \ - .join(AccessPoint) \ - .filter(AccessPoint.room_id == room_id) \ - .scalar() - return {"room_id": room_id, "total_client_count": total_count or 0} - except SQLAlchemyError as e: - logger.error(f"Database error in /rooms/{{room_id}}/client-count: {e}") + logger.info(f"Fetching client count for {entity_type} ID {entity_id}") + query = db.query(func.sum(ClientCount.count)).join(AccessPoint) + if entity_type == "building": + query = query.filter(AccessPoint.building_id == entity_id) + elif entity_type == "campus": + query = query.join(Building).filter(Building.campus_id == entity_id) + elif entity_type == "floor": + query = query.filter(AccessPoint.floor_id == entity_id) + elif entity_type == "room": + query = query.filter(AccessPoint.room_id == entity_id) + else: + raise ValueError("Invalid entity type") + total_count = query.scalar() + return {f"{entity_type}_id": entity_id, "total_client_count": total_count or 0} + except (SQLAlchemyError, ValueError) as e: + logger.error(f"Database error in /{{entity_type}}/{{entity_id}}/client-count: {e}") raise HTTPException(status_code=500, detail="Database error") except Exception as e: - logger.error(f"Unexpected error in /rooms/{{room_id}}/client-count: {e}") + logger.error(f"Unexpected error in /{{entity_type}}/{{entity_id}}/client-count: {e}") raise HTTPException(status_code=500, detail="Internal server error") +@app.get("/buildings/{building_id}/client-count", tags=["Buildings"]) +def get_building_client_count(building_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): + return get_entity_client_count(db, "building", building_id) + +@app.get("/campus/{campus_id}/client-count", tags=["Campus"]) +def get_campus_client_count(campus_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): + return get_entity_client_count(db, "campus", campus_id) + +@app.get("/floors/{floor_id}/client-count", tags=["Floors"]) +def get_floor_client_count(floor_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): + return get_entity_client_count(db, "floor", floor_id) + +@app.get("/rooms/{room_id}/client-count", tags=["Rooms"]) +def get_room_client_count(room_id: int, db: Session = Depends(get_db_dep), api_key: str = Depends(get_api_key)): + return get_entity_client_count(db, "room", room_id) + + @app.get("/radio-types", response_model=List[dict], tags=["Radio Types"]) @timed_cache(ttl=300) diff --git a/ap_monitor/app/mapping.py b/ap_monitor/app/mapping.py index 4488587..a541db8 100644 --- a/ap_monitor/app/mapping.py +++ b/ap_monitor/app/mapping.py @@ -108,10 +108,8 @@ def parse_ap_name_for_location(ap_name: str) -> tuple[str | None, str | None, st return building, floor, ap_number -# Load the mappings when the module is imported. -# This assumes the database is available when the application starts. -if os.getenv("TESTING", "false").lower() != "true": - try: - load_mappings_from_db() - except Exception as e: - logger.error(f"Failed to load mappings on startup: {e}") \ No newline at end of file +def reload_mappings(): + """ + Reload all mappings from the database into memory. + """ + load_mappings_from_db() \ No newline at end of file diff --git a/ap_monitor/app/security.py b/ap_monitor/app/security.py index ec2a0ac..e03d548 100644 --- a/ap_monitor/app/security.py +++ b/ap_monitor/app/security.py @@ -1,3 +1,4 @@ +import secrets from fastapi import Security, HTTPException, Depends from fastapi.security.api_key import APIKeyHeader import os @@ -11,7 +12,7 @@ async def get_api_key(api_key_header: str = Security(api_key_header)): if not API_KEY: # If no API_KEY is set in the environment, disable authentication return - if api_key_header == API_KEY: + if api_key_header and secrets.compare_digest(api_key_header, API_KEY): return api_key_header else: raise HTTPException(status_code=403, detail="Could not validate credentials")