diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5351971 --- /dev/null +++ b/.env.example @@ -0,0 +1,81 @@ +# Django Configuration +DEBUG=True +SECRET_KEY=your-secret-key-here-change-in-production +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +# Database Configuration +DB_ENGINE=django.db.backends.postgresql +DB_NAME=opencare_africa +DB_USER=opencare_user +DB_PASSWORD=your-db-password +DB_HOST=localhost +DB_PORT=5432 + +# Redis Configuration +REDIS_URL=redis://localhost:6379/0 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 + +# Email Configuration +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER=your-email@gmail.com +EMAIL_HOST_PASSWORD=your-app-password + +# AWS S3 Configuration (for file storage) +AWS_ACCESS_KEY_ID=your-aws-access-key +AWS_SECRET_ACCESS_KEY=your-aws-secret-key +AWS_STORAGE_BUCKET_NAME=opencare-africa-storage +AWS_S3_REGION_NAME=us-east-1 +AWS_S3_CUSTOM_DOMAIN=your-custom-domain.com + +# JWT Configuration +JWT_SECRET_KEY=your-jwt-secret-key +JWT_ACCESS_TOKEN_LIFETIME=5 +JWT_REFRESH_TOKEN_LIFETIME=1 + +# Health Data Standards +FHIR_SERVER_URL=https://hapi.fhir.org/baseR4 +OPENMRS_URL=http://localhost:8080/openmrs + +# Analytics & Monitoring +SENTRY_DSN=your-sentry-dsn +METABASE_URL=http://localhost:3000 +SUPERSET_URL=http://localhost:8088 + +# Mobile App Configuration +MOBILE_APP_API_URL=http://localhost:8000/api/v1 +MOBILE_APP_VERSION=1.0.0 + +# Security +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 +CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:8080 + +# Development Settings +USE_DEBUG_TOOLBAR=True +USE_SILK_PROFILER=False +ENABLE_LOGGING=True + +# Internationalization +LANGUAGE_CODE=en-us +TIME_ZONE=UTC +USE_I18N=True +USE_L10N=True +USE_TZ=True + +# Static and Media Files +STATIC_URL=/static/ +MEDIA_URL=/media/ +STATIC_ROOT=staticfiles/ +MEDIA_ROOT=media/ + +# Celery Configuration +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 +CELERY_ACCEPT_CONTENT=json +CELERY_TASK_SERIALIZER=json +CELERY_RESULT_SERIALIZER=json +CELERY_TIMEZONE=UTC diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b62721 --- /dev/null +++ b/.gitignore @@ -0,0 +1,209 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pycache__/ +*.py[cod] +*$py.class + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyderworkspace + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Django static files +staticfiles/ +static/ + +# Django media files +media/ + +# Django migrations +*/migrations/*.py +!*/migrations/__init__.py + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Node.js (for mobile app) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# React Native +.expo/ +dist/ +web-build/ + +# Android +android/app/build/ +android/build/ +android/gradle/ +android/local.properties +android/.gradle/ + +# iOS +ios/build/ +ios/Pods/ +ios/Podfile.lock + +# Docker +.dockerignore + +# Backup files +*.bak +*.tmp +*.temp + +# Local development +local_settings.py +.env.local +.env.development +.env.production + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log + +# Coverage reports +htmlcov/ +.coverage +.coverage.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5d77c8c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,306 @@ +# Contributing to OpenCare-Africa + +Thank you for your interest in contributing to OpenCare-Africa! We welcome contributions from developers, healthcare professionals, and anyone passionate about improving healthcare systems in Africa. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [How Can I Contribute?](#how-can-i-contribute) +- [Getting Started](#getting-started) +- [Development Workflow](#development-workflow) +- [Code Standards](#code-standards) +- [Testing](#testing) +- [Documentation](#documentation) +- [Security](#security) +- [Questions or Need Help?](#questions-or-need-help) + +## Code of Conduct + +This project and everyone participating in it is governed by our Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to [conduct@opencare-africa.org](mailto:conduct@opencare-africa.org). + +## How Can I Contribute? + +### Reporting Bugs + +- Use the GitHub issue tracker +- Include detailed steps to reproduce the bug +- Provide environment information (OS, Python version, Django version) +- Include error logs and screenshots if applicable + +### Suggesting Enhancements + +- Use the GitHub issue tracker with the "enhancement" label +- Describe the problem and proposed solution +- Consider the impact on existing functionality +- Think about backward compatibility + +### Code Contributions + +- Bug fixes +- New features +- Performance improvements +- Documentation updates +- Test coverage improvements + +## Getting Started + +### Prerequisites + +- Python 3.8+ +- Git +- PostgreSQL (for production-like development) +- Redis (for caching and Celery) + +### Setup Development Environment + +1. **Fork and Clone the Repository** + ```bash + git clone https://github.com/your-username/OpenCare-Africa.git + cd OpenCare-Africa + ``` + +2. **Create Virtual Environment** + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install Dependencies** + ```bash + pip install -r requirements-dev.txt + ``` + +4. **Environment Configuration** + ```bash + cp .env.example .env + # Edit .env with your local settings + ``` + +5. **Database Setup** + ```bash + python manage.py migrate + python manage.py createsuperuser + ``` + +6. **Run Development Server** + ```bash + python manage.py runserver + ``` + +## Development Workflow + +### 1. Create a Feature Branch + +```bash +git checkout -b feature/amazing-feature +# or +git checkout -b fix/bug-description +``` + +### 2. Make Your Changes + +- Write clean, readable code +- Follow the coding standards below +- Add tests for new functionality +- Update documentation as needed + +### 3. Commit Your Changes + +Use conventional commit format: + +```bash +git commit -m "feat: add patient search functionality" +git commit -m "fix: resolve authentication issue in API" +git commit -m "docs: update API documentation" +git commit -m "test: add unit tests for patient model" +``` + +**Commit Types:** +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Code style changes (formatting, etc.) +- `refactor`: Code refactoring +- `test`: Adding or updating tests +- `chore`: Maintenance tasks + +### 4. Push and Create Pull Request + +```bash +git push origin feature/amazing-feature +``` + +Then create a Pull Request on GitHub with: +- Clear description of changes +- Link to related issues +- Screenshots for UI changes +- Test results + +## Code Standards + +### Python/Django Standards + +- Follow PEP 8 style guide +- Use meaningful variable and function names +- Write docstrings for all functions and classes +- Keep functions small and focused +- Use type hints where appropriate + +### Django Best Practices + +- Use Django ORM efficiently +- Implement proper model relationships +- Use Django forms and serializers +- Follow Django security best practices +- Implement proper error handling + +### Code Quality Tools + +We use several tools to maintain code quality: + +```bash +# Format code with Black +black . + +# Sort imports with isort +isort . + +# Check code style with flake8 +flake8 . + +# Run security checks with bandit +bandit -r . + +# Run tests with coverage +coverage run --source='.' manage.py test +coverage report +``` + +## Testing + +### Running Tests + +```bash +# Run all tests +python manage.py test + +# Run specific app tests +python manage.py test apps.patients + +# Run with coverage +coverage run --source='.' manage.py test +coverage report +coverage html # Generate HTML report +``` + +### Writing Tests + +- Write tests for all new functionality +- Aim for at least 80% code coverage +- Use descriptive test names +- Test both success and failure cases +- Use factories for test data (factory-boy) + +### Test Structure + +```python +class PatientModelTest(TestCase): + def setUp(self): + """Set up test data.""" + self.patient = PatientFactory() + + def test_patient_creation(self): + """Test that patient can be created.""" + self.assertIsNotNone(self.patient) + self.assertEqual(self.patient.first_name, "John") + + def test_patient_full_name(self): + """Test patient full name method.""" + expected = f"{self.patient.first_name} {self.patient.last_name}" + self.assertEqual(self.patient.get_full_name(), expected) +``` + +## Documentation + +### Code Documentation + +- Write clear docstrings for all functions and classes +- Use Google or NumPy docstring format +- Include examples for complex functions +- Document parameters, return values, and exceptions + +### API Documentation + +- Update API documentation when endpoints change +- Include request/response examples +- Document authentication requirements +- Provide clear error messages + +### User Documentation + +- Update README.md for major changes +- Maintain setup and deployment guides +- Document configuration options +- Provide troubleshooting guides + +## Security + +### Security Guidelines + +- Never commit sensitive information (API keys, passwords) +- Use environment variables for configuration +- Implement proper authentication and authorization +- Validate all user inputs +- Use HTTPS in production +- Follow OWASP security guidelines + +### Reporting Security Issues + +If you discover a security vulnerability, please: + +1. **DO NOT** create a public GitHub issue +2. Email security@opencare-africa.org +3. Include detailed information about the vulnerability +4. Allow time for the security team to respond + +## Questions or Need Help? + +### Getting Help + +- Check existing documentation +- Search existing issues and discussions +- Join our community discussions +- Contact the development team + +### Communication Channels + +- **GitHub Issues**: For bugs and feature requests +- **GitHub Discussions**: For questions and general discussion +- **Email**: support@opencare-africa.org +- **Community Forum**: [forum.opencare-africa.org](https://forum.opencare-africa.org) + +### Mentorship + +New contributors are welcome! We offer: + +- Code review and feedback +- Pair programming sessions +- Mentorship from experienced contributors +- Regular office hours for questions + +## Recognition + +Contributors will be recognized in: + +- Project README +- Release notes +- Contributor hall of fame +- Annual contributor acknowledgments + +## Thank You! + +Your contributions help make healthcare better for millions of people across Africa. Every line of code, bug report, or documentation improvement makes a difference. + +--- + +**Together, we can build a healthier future for Africa!** 🩺🌍 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4da6d56 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Use Python 3.11 slim image +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DJANGO_SETTINGS_MODULE=config.settings.development + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + postgresql-client \ + build-essential \ + libpq-dev \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . /app/ + +# Create necessary directories +RUN mkdir -p /app/staticfiles /app/media /app/logs + +# Collect static files +RUN python manage.py collectstatic --noinput + +# Create non-root user +RUN adduser --disabled-password --gecos '' appuser +RUN chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health/ || exit 1 + +# Run the application +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8a0262d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 OpenCare-Africa + +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 NOT 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. diff --git a/README.md b/README.md index d724b15..ccc78b0 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,653 @@ -# BOSC Health Informatics Project +# OpenCare-Africa + +A comprehensive health informatics platform backend built with Django, designed specifically for healthcare management in Africa. + +## πŸ₯ Project Overview + +OpenCare-Africa is a robust, scalable backend system for managing healthcare operations, patient records, health worker management, and health facility operations. The system is built with modern Django practices and includes comprehensive API endpoints for integration with frontend applications. + +## ✨ Features + +### Core Functionality +- **User Management**: Comprehensive user roles and permissions for healthcare workers +- **Patient Management**: Complete patient lifecycle management with medical history +- **Health Facility Management**: Facility operations, services, and resource management +- **Health Records**: Comprehensive medical records with FHIR compliance +- **Analytics & Reporting**: Health metrics, disease outbreak tracking, and performance analytics +- **API-First Design**: RESTful API with OpenAPI/Swagger documentation + +### Technical Features +- **Django 4.2+**: Modern Django with best practices +- **PostgreSQL**: Robust database with healthcare-optimized schemas +- **Redis**: Caching and session management +- **Celery**: Background task processing +- **Docker**: Containerized deployment +- **JWT Authentication**: Secure API authentication +- **Health Checks**: System monitoring and diagnostics + +## πŸ—οΈ Architecture + +``` +OpenCare-Africa/ +β”œβ”€β”€ apps/ # Django applications +β”‚ β”œβ”€β”€ core/ # Core models and utilities +β”‚ β”œβ”€β”€ patients/ # Patient management +β”‚ β”œβ”€β”€ health_workers/ # Healthcare personnel management +β”‚ β”œβ”€β”€ facilities/ # Health facility operations +β”‚ β”œβ”€β”€ records/ # Medical records management +β”‚ β”œβ”€β”€ analytics/ # Health analytics and reporting +β”‚ └── api/ # API endpoints and viewsets +β”œβ”€β”€ config/ # Project configuration +β”‚ β”œβ”€β”€ settings/ # Environment-specific settings +β”‚ β”œβ”€β”€ urls.py # Main URL configuration +β”‚ └── celery.py # Celery configuration +β”œβ”€β”€ templates/ # HTML templates +β”œβ”€β”€ static/ # Static files +β”œβ”€β”€ media/ # User-uploaded files +β”œβ”€β”€ docs/ # Documentation +└── scripts/ # Database and deployment scripts +``` + +## πŸš€ Quick Start -## Overview - -Welcome to the Health Informatics project by the Bugema Open Source Community (BOSC). We develop open-source, data-driven health solutions to strengthen health systems, support mobile health (mHealth), ensure interoperability, and enable predictive analytics for communities in Uganda and beyond. - -## Project Goals - -- Track patient data securely. -- Support mobile health apps for community health workers and patients. -- Ensure interoperability with health standards. -- Provide health dashboards and AI-driven insights (e.g., malaria risk). +### Prerequisites +- **Docker & Docker Compose** (recommended for all environments) +- Python 3.11+ (only needed for local development without Docker) +- PostgreSQL 15+ (included in Docker setup) +- Redis 7+ (included in Docker setup) -## Tech Stack +### 🐳 Docker Setup (Recommended) -| **Purpose** | **Framework/Tool** | **Why We Use It** | -|------------------------|---------------------------------------|----------------------------------------------------------------------------------| -| Health Data Standards | DHIS2, FHIR | Widely used for health records and reporting. | -| Backend/API | Django REST Framework, Node.js | Flexible REST APIs for health systems. | -| Data Visualization | Metabase, Superset, Power BI Embedded | Analytics and reporting for health data. | -| Mobile App | React Native | mHealth apps for community health workers and patients. | -| Interoperability | OpenHIM | Integrates data across health systems. | -| AI/ML Tools | TensorFlow, scikit-learn, Streamlit | Predictive modeling for health trends. | +This is the **recommended** way to run OpenCare-Africa as it ensures consistency across all environments. -**Cross-Cutting Tools**: -- **GitHub Actions**: CI/CD automation. -- **Docker**: Consistent environments. -- **Firebase/Supabase**: Authentication and database. -- **i18n Libraries**: Local language support. +1. **Clone the repository** + ```bash + git clone https://github.com/bos-com/OpenCare-Africa.git + cd OpenCare-Africa + ``` -## Setup Instructions +2. **Set up environment variables** + ```bash + # Copy environment template + cp env.example .env + + # The .env file is already configured for Docker + # No changes needed unless you want to customize settings + ``` -### Prerequisites -- **Git**: For version control. -- **Python 3.8+**: For Django/Streamlit. -- **Node.js**: For Node.js backend -- **Docker**: For containerized setups. -- **MongoDB**: For data storage. -- **API Keys**: For OpenHIM. +3. **Build and start all services** + ```bash + # Build Docker images + docker-compose build + + # Start all services (database, Redis, web app, Celery) + docker-compose up -d + ``` -### Installation +4. **Run database migrations** + ```bash + docker-compose exec web python manage.py migrate + ``` -1. **Clone the Repository**: +5. **Create superuser (optional)** ```bash - git clone https://github.com/BOSC-Bugema/health-informatics.git - cd health-informatics + docker-compose exec web python manage.py createsuperuser ``` -2. **Set Up Environment**: - - Copy `.env.example` to `.env`: - ```bash - cp .env.example .env - ``` - - Update with API keys (e.g., Firebase) and database credentials. - -3. **Choose Your Setup**: - - **Django Backend**: - ```bash - pip install -r requirements.txt - python manage.py migrate - python manage.py runserver - ``` - - **Node.js Backend**: - ```bash - npm install - npm run dev - ``` - - **Streamlit for AI/ML**: - ```bash - pip install -r requirements.txt - streamlit run app.py - ``` - -4. **Docker Setup (Alternative and recommendendaed)**: +6. **Verify the installation** ```bash - docker-compose up --build + # Check service status + docker-compose ps + + # Test the health endpoint + curl http://localhost:8000/health/ + + # Test the web interface + open http://localhost:8000/ ``` -5. **Configure DHIS2**: - - Follow `docs/dhis2_setup.md` for health data standards. +7. **Access the application** + - **Web Interface**: http://localhost:8000 + - **Admin Panel**: http://localhost:8000/admin + - **API Documentation**: http://localhost:8000/api/docs/ + - **Health Check**: http://localhost:8000/health/ + +## πŸ“˜ Viewing API Docs + +- Start the stack via Docker or local development, then browse to http://localhost:8000/api/docs/ for interactive OpenAPI documentation. +- Review sanitized response expectations and logging rules in [`docs/error-handling.md`](docs/error-handling.md) before exposing new endpoints. +- Extend automated tests to cover both happy-path and error scenarios when updating API behavior; see the error-handling guide for recommendations. + +### 🐳 Docker Services Overview + +The Docker setup includes the following services: -### Running the Project +| Service | Port | Purpose | +|---------|------|---------| +| **web** | 8000 | Django web application | +| **db** | 5432 | PostgreSQL database | +| **redis** | 6379 | Redis cache and Celery broker | +| **celery** | - | Background task processor | +| **celery-beat** | - | Scheduled task scheduler | +| **nginx** | 80 | Reverse proxy (production) | +| **metabase** | 3000 | Analytics dashboard | +| **superset** | 8088 | Business intelligence platform | -- **Backend**: `python manage.py runserver` (Django) or `npm run dev` (Node.js). -- **Mobile App**: `flutter run` or `npx react-native run-android`. -- **Dashboards**: Run Metabase/Superset via Docker (see `docs/dashboard_setup.md`). -- **AI/ML**: Access Streamlit at `http://localhost:8008`. +### πŸš€ Quick Docker Commands -### Testing +```bash +# Start all services +docker-compose up -d -- Run tests: `pytest` (Python) or `npm test` (Node.js). -- Check GitHub Actions for CI/CD pipelines. +# View service status +docker-compose ps -## GitHub Configuration +# View logs +docker-compose logs -f web -We follow BOSC’s standards for secure and collaborative development: +# Stop all services +docker-compose down -- **Security**: - - Code Scanning: Enabled via GitHub Advanced Security. - - Secret Scanning: Detects credentials. - - Dependency Review: Blocks vulnerable dependencies. - - Branch Protection: Requires reviews and CI checks. - - Audit Logs: Tracks activity. +# Restart a specific service +docker-compose restart web -- **Workflows**: - - Template Repositories: Standardizes setups. - - GitHub Actions: Automates CI/CD. - - GitHub Packages: Stores private packages. - - Collaboration: Uses commit conventions and PR templates. +# Execute Django commands +docker-compose exec web python manage.py migrate +docker-compose exec web python manage.py createsuperuser +docker-compose exec web python manage.py shell +``` -- **Onboarding**: +### πŸ› οΈ Local Development Setup (Alternative) - - See `docs/onboarding.md` for guides. - - Join workshops for hands-on learning. - - Use training repos for practice. +If you prefer to run the application locally without Docker: -- **Monitoring**: - - Track usage and audit logs. - - Contact support via `SUPPORT.md`. +1. **Clone the repository** + ```bash + git clone https://github.com/bos-com/OpenCare-Africa.git + cd OpenCare-Africa + ``` -## Contribution Guidelines +2. **Set up Python virtual environment** + ```bash + # Create virtual environment + python3 -m venv venv + + # Activate virtual environment + source venv/bin/activate # On Windows: venv\Scripts\activate + + # Upgrade pip + pip install --upgrade pip + ``` -- Use commit conventions (e.g., `feat: add patient tracking`). -- Follow PR templates and require one approval. -- Optimize for low-bandwidth and offline-first designs. -- Support local languages with i18n. +3. **Set up environment variables** + ```bash + # Copy environment template + cp env.example .env + + # Update .env for local development + # Change DB_HOST=localhost and REDIS_HOST=localhost + ``` -## License +4. **Install dependencies** + ```bash + # Install production dependencies + pip install -r requirements.txt + + # Install development dependencies (optional) + pip install -r requirements-dev.txt + ``` -This project is licensed under the [MIT License](LICENSE). +5. **Set up the database** + ```bash + # For development, SQLite is used by default + # No additional database setup required + + # Run migrations + python manage.py migrate + ``` + +6. **Create superuser (optional)** + ```bash + python manage.py createsuperuser + ``` -## Contact +7. **Run the development server** + ```bash + python manage.py runserver + ``` -Join us at [https://github.com/BOSC-Bugema](https://github.com/BOSC-Bugema) or email [kmuwanga@bugemauniv.ac.ug](mailto:kmuwanga@bugemauniv.ac.ug). +8. **Verify the installation** + ```bash + # Test the API health endpoint + curl http://localhost:8000/health/ + + # Test the web interface + open http://localhost:8000/ + ``` -Let’s empower health systems together! 🩺 #OpenSource #HealthInformatics #BOSC +## πŸ“Š API Documentation + +The API is fully documented using OpenAPI/Swagger: + +- **Swagger UI**: `/api/docs/` +- **ReDoc**: `/api/redoc/` +- **OpenAPI Schema**: `/api/schema/` + +### Key API Endpoints + +- **Authentication**: `/api/v1/auth/` +- **Patients**: `/api/v1/patients/` +- **Health Workers**: `/api/v1/health-workers/` +- **Facilities**: `/api/v1/facilities/` +- **Health Records**: `/api/v1/records/` +- **Analytics**: `/api/v1/analytics/` +- **Appointments**: `/api/v1/appointments/` + +## πŸ—„οΈ Database Schema + +### Core Models +- **User**: Extended user model with healthcare worker profiles +- **Location**: Hierarchical geographic location management +- **HealthFacility**: Health facility information and services +- **AuditTrail**: Comprehensive audit logging + +### Patient Models +- **Patient**: Complete patient information and demographics +- **PatientVisit**: Patient visit tracking and scheduling +- **PatientMedicalHistory**: Medical history and conditions + +### Healthcare Models +- **HealthWorkerProfile**: Extended healthcare worker profiles +- **ProfessionalQualification**: Qualifications and certifications +- **WorkSchedule**: Work schedules and availability +- **PerformanceEvaluation**: Performance tracking and reviews + +### Facility Models +- **FacilityService**: Services offered by health facilities +- **FacilityStaff**: Staff management and assignments +- **FacilityEquipment**: Medical equipment tracking +- **FacilityInventory**: Medical supplies and inventory + +### Records Models +- **HealthRecord**: Comprehensive medical records +- **VitalSigns**: Patient vital signs and measurements +- **Medication**: Prescription and medication management +- **LaboratoryTest**: Lab test results and interpretation +- **ImagingStudy**: Medical imaging results + +### Analytics Models +- **HealthMetrics**: Health KPIs and metrics +- **DiseaseOutbreak**: Disease outbreak tracking +- **HealthReport**: Automated health reports +- **PatientAnalytics**: Patient-specific analytics + +## πŸ”§ Configuration + +### Environment Variables + +```bash +# Django Configuration +SECRET_KEY=your-secret-key-here +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database Configuration +DB_ENGINE=django.db.backends.postgresql +DB_NAME=opencare_africa +DB_USER=opencare_user +DB_PASSWORD=opencare_password +DB_HOST=localhost +DB_PORT=5432 + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 + +# JWT Configuration +JWT_ACCESS_TOKEN_LIFETIME=5 +JWT_REFRESH_TOKEN_LIFETIME=1 +``` + +### Settings Files + +- `config/settings/base.py`: Base configuration +- `config/settings/development.py`: Development environment +- `config/settings/production.py`: Production environment +- `config/settings/test.py`: Testing environment + +## πŸ§ͺ Testing + +```bash +# Run all tests +python manage.py test + +# Run tests with coverage +coverage run --source='.' manage.py test +coverage report +coverage html + +# Run specific app tests +python manage.py test apps.patients +python manage.py test apps.core +``` + +## πŸ“ˆ Performance & Monitoring + +### Health Checks +- Database connectivity +- Redis connectivity +- Storage availability +- System resources + +### Monitoring +- Django Debug Toolbar (development) +- Sentry integration (production) +- Custom health metrics +- Performance analytics + +### Caching +- Redis-based caching +- Database query optimization +- Static file caching +- API response caching + +## πŸš€ Deployment + +### Production Checklist +- [ ] Set `DEBUG=False` +- [ ] Configure production database +- [ ] Set up SSL/TLS certificates +- [ ] Configure static file serving +- [ ] Set up monitoring and logging +- [ ] Configure backup strategies +- [ ] Set up CI/CD pipelines + +### Docker Production +```bash +# Build production image +docker build -t opencare-africa:latest . + +# Run with production settings +docker run -e DJANGO_SETTINGS_MODULE=config.settings.production opencare-africa:latest +``` + +## πŸ”’ Security Features + +- JWT-based authentication +- Role-based access control +- Comprehensive audit logging +- Input validation and sanitization +- CORS configuration +- Rate limiting (configurable) +- Secure password policies + +## πŸ“š Documentation + +- **API Documentation**: Built-in Swagger/OpenAPI docs +- **Code Documentation**: Comprehensive docstrings +- **Admin Interface**: Django admin for data management +- **User Guides**: Available in `/docs/` directory +- **Patient Records**: See [`docs/patient-records.md`](docs/patient-records.md) for CRUD usage and security notes +- **Audit Logging**: See [`docs/audit-logs.md`](docs/audit-logs.md) for PHI access tracking requirements +- **Appointments**: See [`docs/appointments.md`](docs/appointments.md) for scheduling API usage and safeguards + +## 🀝 Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Ensure all tests pass +6. Submit a pull request + +### Development Guidelines +- Follow PEP 8 style guidelines +- Write comprehensive docstrings +- Include tests for new features +- Update documentation as needed +- Use meaningful commit messages + +## πŸ“„ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## πŸ”§ Troubleshooting + +### Docker-Specific Issues + +#### 1. Docker Build Failures +**Issue**: `docker-compose build` fails with errors +**Solution**: +```bash +# Clean up Docker cache and rebuild +docker-compose down +docker system prune -f +docker-compose build --no-cache + +# Check Docker logs for specific errors +docker-compose logs web +``` + +#### 2. Port Conflicts +**Issue**: `Port is already allocated` or `Address already in use` +**Solution**: +```bash +# Check what's using the port +sudo netstat -tulpn | grep :8000 + +# Stop conflicting services or change ports in docker-compose.yml +docker-compose down +# Edit docker-compose.yml to use different ports +docker-compose up -d +``` + +#### 3. Database Connection Issues +**Issue**: `django.db.utils.OperationalError: could not connect to server` +**Solution**: +```bash +# Ensure database service is running +docker-compose ps + +# Check database logs +docker-compose logs db + +# Restart database service +docker-compose restart db + +# Wait for database to be ready, then run migrations +sleep 10 +docker-compose exec web python manage.py migrate +``` + +#### 4. Redis Connection Issues +**Issue**: `redis.exceptions.ConnectionError` in Docker +**Solution**: +```bash +# Check Redis service status +docker-compose ps redis + +# Restart Redis service +docker-compose restart redis + +# Check Redis logs +docker-compose logs redis +``` + +#### 5. Permission Issues +**Issue**: `Permission denied` errors in Docker containers +**Solution**: +```bash +# Fix file permissions +sudo chown -R $USER:$USER . + +# Rebuild containers +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +#### 6. Memory Issues +**Issue**: Docker containers running out of memory +**Solution**: +```bash +# Check Docker resource usage +docker stats + +# Increase Docker memory limit in Docker Desktop settings +# Or add memory limits to docker-compose.yml +``` + +#### 7. Volume Mount Issues +**Issue**: Changes not reflecting in containers +**Solution**: +```bash +# Restart services to pick up volume changes +docker-compose restart web + +# Or rebuild if needed +docker-compose down +docker-compose up -d --build +``` + +### Docker Commands Reference + +```bash +# Start all services +docker-compose up -d + +# Stop all services +docker-compose down + +# View logs +docker-compose logs web +docker-compose logs db +docker-compose logs redis + +# Execute commands in containers +docker-compose exec web python manage.py migrate +docker-compose exec web python manage.py createsuperuser +docker-compose exec web python manage.py shell + +# Check service status +docker-compose ps + +# Rebuild specific service +docker-compose build web +docker-compose up -d web + +# Clean up everything +docker-compose down -v +docker system prune -f +``` + +### Common Issues + +#### 1. Python Version Compatibility +**Issue**: `ModuleNotFoundError` or package installation failures +**Solution**: Ensure you're using Python 3.11+ and have created a virtual environment +```bash +python3 --version # Should be 3.11+ +python3 -m venv venv +source venv/bin/activate +``` + +#### 2. Database Migration Errors +**Issue**: `django.db.utils.OperationalError` or migration failures +**Solution**: Ensure the database is properly configured and migrations are up to date +```bash +python manage.py showmigrations +python manage.py migrate +``` + +#### 3. Missing Dependencies +**Issue**: `ModuleNotFoundError` for specific packages +**Solution**: Reinstall dependencies and ensure all requirements are met +```bash +pip install --upgrade pip +pip install -r requirements.txt +``` + +#### 4. Port Already in Use +**Issue**: `Address already in use` error when starting the server +**Solution**: Use a different port or kill the existing process +```bash +python manage.py runserver 8001 +# OR +pkill -f "python manage.py runserver" +``` + +#### 5. Static Files Warning +**Issue**: `staticfiles.W004` warning about missing static directory +**Solution**: Create the static directory (this is normal for development) +```bash +mkdir -p static +``` + +#### 6. Redis Connection Errors +**Issue**: `redis.exceptions.ConnectionError: Error 111 connecting to localhost:6379` +**Solution**: Redis is not required for basic development. The health check has been configured to skip Redis checks in development mode. If you need Redis for production features, install and start it: +```bash +# Ubuntu/Debian +sudo apt install redis-server +sudo systemctl start redis-server + +# macOS (with Homebrew) +brew install redis +brew services start redis +``` + +#### 7. Environment Variables +**Issue**: Configuration errors or missing environment variables +**Solution**: Ensure `.env` file exists and contains required variables +```bash +cp env.example .env +# Edit .env if needed for your environment +``` + +### Development Tips + +1. **Use the development settings**: The project uses `config.settings.development` by default +2. **Check logs**: Look at the console output for detailed error messages +3. **Database**: SQLite is used by default for development (no setup required) +4. **API Testing**: Use the built-in API documentation at `/api/docs/` +5. **Admin Interface**: Access at `/admin/` after creating a superuser + +### Getting Help + +- **Documentation**: Check the `/docs/` directory +- **Issues**: Report bugs via GitHub Issues +- **Discussions**: Use GitHub Discussions for questions +- **Email**: Contact the development team + +## πŸ—ΊοΈ Roadmap + +### Phase 1 (Current) +- βœ… Core backend infrastructure +- βœ… Patient management system +- βœ… Health worker management +- βœ… Facility management +- βœ… Basic API endpoints + +### Phase 2 (Next) +- πŸ”„ Advanced analytics dashboard +- πŸ”„ Mobile API optimization +- πŸ”„ Integration with external systems +- πŸ”„ Advanced reporting features + +### Phase 3 (Future) +- πŸ“‹ AI-powered health insights +- πŸ“‹ Telemedicine integration +- πŸ“‹ Advanced data visualization +- πŸ“‹ Multi-language support + +## πŸ™ Acknowledgments + +- Django community for the excellent framework +- Healthcare professionals for domain expertise +- Open source contributors for various packages +- African healthcare workers for inspiration + +--- + +**OpenCare-Africa** - Empowering healthcare in Africa through technology. diff --git a/Sample.py b/Sample.py index 9c0b377..8b13789 100644 --- a/Sample.py +++ b/Sample.py @@ -1 +1 @@ -print ("Welcome to the BU Health Informatics") + diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/analytics/__init__.py b/apps/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/analytics/models.py b/apps/analytics/models.py new file mode 100644 index 0000000..c956b73 --- /dev/null +++ b/apps/analytics/models.py @@ -0,0 +1,187 @@ +""" +Analytics models for OpenCare-Africa health system. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.core.models import User, HealthFacility, Location +from apps.patients.models import Patient + + +class HealthMetrics(models.Model): + """ + Model for storing health metrics and KPIs. + """ + METRIC_TYPE_CHOICES = [ + ('patient_count', _('Patient Count')), + ('visit_count', _('Visit Count')), + ('disease_prevalence', _('Disease Prevalence')), + ('mortality_rate', _('Mortality Rate')), + ('birth_rate', _('Birth Rate')), + ('vaccination_rate', _('Vaccination Rate')), + ('treatment_success', _('Treatment Success Rate')), + ('wait_time', _('Average Wait Time')), + ] + + metric_type = models.CharField(max_length=50, choices=METRIC_TYPE_CHOICES) + value = models.DecimalField(max_digits=10, decimal_places=2) + unit = models.CharField(max_length=20, blank=True) + location = models.ForeignKey(Location, on_delete=models.CASCADE, null=True, blank=True) + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE, null=True, blank=True) + date = models.DateField() + period = models.CharField(max_length=20, choices=[ + ('daily', _('Daily')), + ('weekly', _('Weekly')), + ('monthly', _('Monthly')), + ('quarterly', _('Quarterly')), + ('yearly', _('Yearly')), + ]) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Health Metric') + verbose_name_plural = _('Health Metrics') + unique_together = ['metric_type', 'location', 'facility', 'date', 'period'] + ordering = ['-date', 'metric_type'] + + def __str__(self): + location_str = f" at {self.location}" if self.location else "" + facility_str = f" at {self.facility}" if self.facility else "" + return f"{self.get_metric_type_display()}: {self.value} {self.unit}{location_str}{facility_str}" + + +class DiseaseOutbreak(models.Model): + """ + Model for tracking disease outbreaks and epidemics. + """ + SEVERITY_CHOICES = [ + ('low', _('Low')), + ('medium', _('Medium')), + ('high', _('High')), + ('critical', _('Critical')), + ] + + STATUS_CHOICES = [ + ('active', _('Active')), + ('contained', _('Contained')), + ('resolved', _('Resolved')), + ] + + disease_name = models.CharField(max_length=200) + location = models.ForeignKey(Location, on_delete=models.CASCADE) + start_date = models.DateField() + end_date = models.DateField(null=True, blank=True) + severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') + + # Case counts + total_cases = models.PositiveIntegerField(default=0) + active_cases = models.PositiveIntegerField(default=0) + recovered_cases = models.PositiveIntegerField(default=0) + fatal_cases = models.PositiveIntegerField(default=0) + + # Response information + response_measures = models.JSONField(default=list) + affected_facilities = models.ManyToManyField(HealthFacility) + notes = models.TextField(blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Disease Outbreak') + verbose_name_plural = _('Disease Outbreaks') + ordering = ['-start_date'] + + def __str__(self): + return f"{self.disease_name} outbreak in {self.location} ({self.get_status_display()})" + + +class HealthReport(models.Model): + """ + Model for generating and storing health reports. + """ + REPORT_TYPE_CHOICES = [ + ('daily', _('Daily Report')), + ('weekly', _('Weekly Report')), + ('monthly', _('Monthly Report')), + ('quarterly', _('Quarterly Report')), + ('annual', _('Annual Report')), + ('incident', _('Incident Report')), + ('outbreak', _('Outbreak Report')), + ] + + report_type = models.CharField(max_length=20, choices=REPORT_TYPE_CHOICES) + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + generated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE, null=True, blank=True) + location = models.ForeignKey(Location, on_delete=models.CASCADE, null=True, blank=True) + + # Report period + start_date = models.DateField() + end_date = models.DateField() + + # Report content + content = models.JSONField(default=dict) + summary = models.TextField(blank=True) + recommendations = models.TextField(blank=True) + + # File attachments + report_file = models.FileField(upload_to='reports/', null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Health Report') + verbose_name_plural = _('Health Reports') + ordering = ['-created_at'] + + def __str__(self): + return f"{self.title} ({self.get_report_type_display()})" + + +class PatientAnalytics(models.Model): + """ + Model for patient-specific analytics and insights. + """ + patient = models.ForeignKey(Patient, on_delete=models.CASCADE) + analysis_date = models.DateField() + + # Health indicators + risk_score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) + health_trend = models.CharField(max_length=20, choices=[ + ('improving', _('Improving')), + ('stable', _('Stable')), + ('declining', _('Declining')), + ('critical', _('Critical')), + ], blank=True) + + # Visit patterns + visit_frequency = models.PositiveIntegerField(default=0) + last_visit_date = models.DateField(null=True, blank=True) + next_scheduled_visit = models.DateField(null=True, blank=True) + + # Treatment effectiveness + treatment_compliance = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) + medication_adherence = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) + + # Predictive analytics + predicted_health_issues = models.JSONField(default=list) + recommended_actions = models.JSONField(default=list) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Patient Analytics') + verbose_name_plural = _('Patient Analytics') + unique_together = ['patient', 'analysis_date'] + ordering = ['-analysis_date'] + + def __str__(self): + return f"Analytics for {self.patient} on {self.analysis_date}" diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps.py b/apps/api/apps.py new file mode 100644 index 0000000..c6d9c5f --- /dev/null +++ b/apps/api/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.api' + verbose_name = 'API' diff --git a/apps/api/exceptions.py b/apps/api/exceptions.py new file mode 100644 index 0000000..3aa08e0 --- /dev/null +++ b/apps/api/exceptions.py @@ -0,0 +1,94 @@ +""" +Custom exception handling for the OpenCare API. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import exception_handler as drf_exception_handler + +logger = logging.getLogger("opencare.exceptions") + +GENERIC_MESSAGE = _("An unexpected error occurred. Please try again later.") +BASIC_MESSAGE_MAP = { + status.HTTP_400_BAD_REQUEST: _("Request validation failed."), + status.HTTP_401_UNAUTHORIZED: _("Authentication credentials were not provided or are invalid."), + status.HTTP_403_FORBIDDEN: _("You do not have permission to perform this action."), + status.HTTP_404_NOT_FOUND: _("The requested resource could not be found."), +} + + +def sanitized_exception_handler(exc: Exception, context: dict[str, Any]) -> Response: + """ + Wrap DRF's exception handler to standardize and sanitize error responses. + + * Returns a structured payload with `code` and `message`. + * Logs full details server-side for operational visibility. + * Ensures generic messaging for all 5xx responses to avoid leaking secrets. + """ + + response = drf_exception_handler(exc, context) + request = context.get("request") + metadata = { + "path": getattr(request, "path", None), + "method": getattr(request, "method", None), + "user_id": getattr(getattr(request, "user", None), "pk", None), + "view": context.get("view").__class__.__name__ if context.get("view") else None, + } + + if response is None: + logger.exception("Unhandled exception", extra={"request": metadata}) + return Response( + {"code": "server_error", "message": GENERIC_MESSAGE}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + status_code = response.status_code + raw_data = response.data + if isinstance(raw_data, dict): + code = raw_data.get("code") or getattr(exc, "default_code", None) or "error" + else: + code = getattr(exc, "default_code", None) or "error" + + if status_code >= 500: + logger.exception("Internal server error", extra={"request": metadata}) + return Response( + {"code": "server_error", "message": GENERIC_MESSAGE}, + status=status_code, + ) + + if 400 <= status_code < 500: + logger.warning("API request failed", extra={"request": metadata, "code": code}) + message = BASIC_MESSAGE_MAP.get(status_code, response.status_text) + normalized_errors = _normalize_errors(raw_data) + payload: dict[str, Any] = {"code": code, "message": message} + if normalized_errors: + payload["errors"] = normalized_errors + response.data = payload + return response + + # Fallback for any other status codes (should be rare) + logger.error("Unexpected exception handler pathway", extra={"request": metadata, "code": code}) + return Response( + {"code": code, "message": GENERIC_MESSAGE}, + status=status_code, + ) + + +def _normalize_errors(data: Any) -> Any: + """ + Recursively convert DRF ErrorDetail objects into primitive types for JSON. + """ + + if data is None: + return None + if isinstance(data, (list, tuple)): + return [_normalize_errors(item) for item in data] + if isinstance(data, dict): + return {key: _normalize_errors(value) for key, value in data.items()} + return str(data) diff --git a/apps/api/mixins.py b/apps/api/mixins.py new file mode 100644 index 0000000..fc1efc6 --- /dev/null +++ b/apps/api/mixins.py @@ -0,0 +1,143 @@ +# pylint: disable=too-many-branches +""" +Shared mixins for API viewsets. +""" + +from __future__ import annotations + +from typing import Iterable, Optional + +from rest_framework.response import Response + +from apps.core.audit import log_audit_event + + +class AuditLogMixin: + """ + Automatically capture audit trail entries for sensitive viewsets. + """ + + audit_list_object_id = "list" + + def get_audit_model_name(self, instance=None) -> str: + if instance is not None: + return instance._meta.label # type: ignore[attr-defined] + queryset = getattr(self, "queryset", None) + model = getattr(queryset, "model", None) + if model is not None: + return model._meta.label # type: ignore[attr-defined] + serializer_class = self.get_serializer_class() + meta_model = getattr(getattr(serializer_class, "Meta", None), "model", None) + if meta_model is None: + raise ValueError("Unable to determine model name for audit logging") + return meta_model._meta.label # type: ignore[attr-defined] + + def get_audit_object_id(self, instance=None) -> str: + if instance is None: + return self.audit_list_object_id + identifier = getattr(instance, "pk", None) + return str(identifier) if identifier is not None else "" + + def _build_change_payload( + self, + *, + fields: Optional[Iterable[str]] = None, + summary: Optional[str] = None, + count: Optional[int] = None, + filters: Optional[Iterable[str]] = None, + metadata: Optional[str] = None, + ): + payload = {} + if fields: + payload["fields"] = {str(name) for name in fields} + if summary: + payload["summary"] = summary + if count is not None: + payload["count"] = int(count) + if filters: + payload["filters"] = {str(name) for name in filters} + if metadata: + payload["metadata"] = metadata + return payload + + # CRUD hooks --------------------------------------------------------- + def perform_create(self, serializer): + instance = serializer.save() + payload = self._build_change_payload(fields=serializer.validated_data.keys()) + log_audit_event( + user=self.request.user, + action="create", + model_name=self.get_audit_model_name(instance), + object_id=self.get_audit_object_id(instance), + request=self.request, + changes=payload, + ) + return instance + + def perform_update(self, serializer): + instance = serializer.save() + payload = self._build_change_payload(fields=serializer.validated_data.keys()) + log_audit_event( + user=self.request.user, + action="update", + model_name=self.get_audit_model_name(instance), + object_id=self.get_audit_object_id(instance), + request=self.request, + changes=payload, + ) + return instance + + def perform_destroy(self, instance): + object_id = self.get_audit_object_id(instance) + model_name = self.get_audit_model_name(instance) + log_audit_event( + user=self.request.user, + action="delete", + model_name=model_name, + object_id=object_id, + request=self.request, + changes=self._build_change_payload(summary="record deleted"), + ) + instance.delete() + + # Read operations ---------------------------------------------------- + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + response: Response = super().retrieve(request, *args, **kwargs) + if response.status_code < 400: + log_audit_event( + user=request.user, + action="view", + model_name=self.get_audit_model_name(instance), + object_id=self.get_audit_object_id(instance), + request=request, + changes=self._build_change_payload(summary="record retrieved"), + ) + return response + + def list(self, request, *args, **kwargs): + response: Response = super().list(request, *args, **kwargs) + if response.status_code < 400: + count = None + data = response.data + if isinstance(data, dict): + if "results" in data and isinstance(data["results"], list): + count = len(data["results"]) + elif isinstance(data.get("count"), int): + count = data["count"] + elif isinstance(data, list): + count = len(data) + + log_audit_event( + user=request.user, + action="view", + model_name=self.get_audit_model_name(), + object_id=self.get_audit_object_id(None), + request=request, + changes=self._build_change_payload( + summary="list retrieved", + count=count, + filters=request.query_params.keys(), + ), + ) + return response diff --git a/apps/api/permissions.py b/apps/api/permissions.py new file mode 100644 index 0000000..982fa81 --- /dev/null +++ b/apps/api/permissions.py @@ -0,0 +1,90 @@ +""" +Custom permission classes for API access control. +""" + +from __future__ import annotations + +from typing import Iterable, Set + +from django.utils.translation import gettext_lazy as _ +from rest_framework.permissions import BasePermission + + +def _normalize_roles(roles: Iterable[str]) -> frozenset[str]: + """Normalize role values to strings.""" + normalized: Set[str] = set() + for role in roles: + if hasattr(role, "value"): + normalized.add(str(role.value)) + else: + normalized.add(str(role)) + return frozenset(normalized) + + +class IsClinicalStaff(BasePermission): + """ + Allow requests from authenticated clinical staff or superusers. + + Clinical staff is defined as any user whose `user_type` is in + `CLINICAL_ROLES`. Read and write operations are blocked for other roles, + ensuring sensitive health records are only modified by qualified users. + """ + + CLINICAL_ROLES: Iterable[str] = ( + "doctor", + "nurse", + "midwife", + "pharmacist", + "lab_technician", + ) + + def has_permission(self, request, view) -> bool: + user = request.user + if not user or not user.is_authenticated: + return False + if user.is_superuser: + return True + return getattr(user, "user_type", None) in self.CLINICAL_ROLES + + def has_object_permission(self, request, view, obj) -> bool: # noqa: D401 + """ + Mirror `has_permission` so object-level checks follow the same rule. + """ + return self.has_permission(request, view) + + +class RoleRequired(BasePermission): + """DRF permission enforcing role membership based on ``view.required_roles``.""" + + message = _("You do not have permission to perform this action.") + + def has_permission(self, request, view) -> bool: # type: ignore[override] + required = getattr(view, "required_roles", None) + if required is None and hasattr(view, "cls"): + required = getattr(view.cls, "required_roles", None) + + if not required: + return True + + user = getattr(request, "user", None) + if user is None or not getattr(user, "is_authenticated", False): + return False + + if getattr(user, "is_admin_role", False) or getattr(user, "is_superuser", False): + return True + + return getattr(user, "role", None) in required + + +def require_roles(*roles: str): + """Decorator to attach required roles metadata to API views.""" + + normalized = _normalize_roles(roles) + + def decorator(view): + setattr(view, "required_roles", normalized) + if hasattr(view, "cls"): + setattr(view.cls, "required_roles", normalized) + return view + + return decorator diff --git a/apps/api/tests/__init__.py b/apps/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/test_audit_logging.py b/apps/api/tests/test_audit_logging.py new file mode 100644 index 0000000..78f979d --- /dev/null +++ b/apps/api/tests/test_audit_logging.py @@ -0,0 +1,128 @@ +""" +Tests for health data audit logging. +""" + +from datetime import date + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from apps.core.models import AuditTrail, HealthFacility, Location +from apps.patients.models import Patient +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class AuditLoggingTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + username="doctor1", + password="password123", + user_type="doctor", + first_name="Doc", + last_name="Tor", + ) + self.client.force_authenticate(self.user) + + self.location = Location.objects.create( + name="Central District", + location_type="district", + ) + self.facility = HealthFacility.objects.create( + name="Central Clinic", + facility_type="hospital", + location=self.location, + address="123 Main Street", + phone_number="+256700000010", + email="clinic@example.com", + website="", + is_24_hours=True, + contact_person_name="Chief Admin", + contact_person_phone="+256700000011", + services_offered=[], + ) + self.patient = Patient.objects.create( + patient_id="PAT-000001", + first_name="Jane", + last_name="Doe", + date_of_birth=date(1990, 1, 1), + gender="F", + phone_number="+256700000012", + email="jane@example.com", + address="45 Health Avenue", + location=self.location, + emergency_contact_name="John Doe", + emergency_contact_phone="+256700000013", + emergency_contact_relationship="Spouse", + registered_facility=self.facility, + ) + + def test_patient_detail_access_creates_audit_log(self): + AuditTrail.objects.all().delete() + url = reverse("api:patient-detail", args=[self.patient.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + log_entry = AuditTrail.objects.filter( + action="view", + model_name="patients.Patient", + object_id=str(self.patient.pk), + ).first() + self.assertIsNotNone(log_entry) + self.assertEqual(log_entry.user, self.user) + + def test_patient_list_access_logs_view(self): + AuditTrail.objects.all().delete() + url = reverse("api:patient-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + log_entry = AuditTrail.objects.filter( + action="view", + model_name="patients.Patient", + object_id="list", + ).first() + self.assertIsNotNone(log_entry) + self.assertEqual(log_entry.user, self.user) + self.assertIn("summary", log_entry.changes) + + def test_patient_creation_logs_action(self): + AuditTrail.objects.all().delete() + url = reverse("api:patient-list") + payload = { + "first_name": "Alice", + "last_name": "Smith", + "date_of_birth": "1985-05-05", + "gender": "F", + "phone_number": "+256700000099", + "email": "alice@example.com", + "address": "78 Care Road", + "location": self.location.pk, + "emergency_contact_name": "Bob Smith", + "emergency_contact_phone": "+256700000098", + "emergency_contact_relationship": "Sibling", + "registered_facility": self.facility.pk, + } + response = self.client.post(url, data=payload, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + log_entry = AuditTrail.objects.filter( + action="create", model_name="patients.Patient" + ).order_by("-timestamp").first() + self.assertIsNotNone(log_entry) + self.assertEqual(log_entry.user, self.user) + self.assertIn("fields", log_entry.changes) + self.assertNotIn("first_name", str(log_entry.changes)) + + def test_audit_log_endpoint_requires_admin(self): + url = reverse("api:audit-logs-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + admin_user = User.objects.create_superuser( + username="admin1", + email="admin@example.com", + password="password123", + ) + self.client.force_authenticate(admin_user) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/apps/api/tests/test_exception_handling.py b/apps/api/tests/test_exception_handling.py new file mode 100644 index 0000000..de0b7c5 --- /dev/null +++ b/apps/api/tests/test_exception_handling.py @@ -0,0 +1,65 @@ +"""Tests for sanitized API exception handling.""" + +from __future__ import annotations + +from django.test import TestCase, override_settings +from django.urls import path +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.exceptions import ValidationError +from rest_framework.test import APIClient + + +@api_view(["GET"]) +def explode(request): + """Endpoint that raises an unhandled exception.""" + + raise ValueError("super secret stack trace") + + +@api_view(["GET"]) +def invalid(request): + """Endpoint that raises a validation error.""" + + raise ValidationError({"field": ["This value is invalid."]}) + + +urlpatterns = [ + path("boom/", explode, name="boom"), + path("invalid/", invalid, name="invalid"), +] + + +class ExceptionHandlingTests(TestCase): + """Validate the custom exception handler behaviour.""" + + def setUp(self): + self.client = APIClient() + user_model = get_user_model() + self.user = user_model.objects.create_user( + username="handler-tester", + password="testpass123", + email="tester@example.com", + ) + self.client.force_authenticate(self.user) + + @override_settings(ROOT_URLCONF=__name__) + def test_unhandled_exception_returns_generic_message(self): + """Unhandled exceptions should produce sanitized 500 responses.""" + + response = self.client.get("/boom/") + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.data["code"], "server_error") + self.assertNotIn("secret", response.data["message"].lower()) + + @override_settings(ROOT_URLCONF=__name__) + def test_validation_error_returns_structured_payload(self): + """Validation errors should include a generic message plus field errors.""" + + response = self.client.get("/invalid/") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["code"], "invalid") + self.assertEqual(response.data["message"], "Request validation failed.") + self.assertIn("field", response.data["errors"]) + self.assertEqual(response.data["errors"]["field"][0], "This value is invalid.") diff --git a/apps/api/tests/test_health_records_api.py b/apps/api/tests/test_health_records_api.py new file mode 100644 index 0000000..8e3b584 --- /dev/null +++ b/apps/api/tests/test_health_records_api.py @@ -0,0 +1,199 @@ +""" +API tests validating patient health record CRUD flows. +""" + +from datetime import timedelta + +from django.urls import reverse +from django.utils import timezone +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APITestCase + +from apps.core.models import HealthFacility, Location +from apps.patients.models import Patient +from apps.records.models import HealthRecord + + +User = get_user_model() + + +class HealthRecordAPITests(APITestCase): + """ + Exercise the records endpoint to ensure role enforcement and functionality. + """ + + def setUp(self): + """ + Create reusable fixtures for each test case. + """ + + self.clinical_user = User.objects.create_user( + username="doctor1", + password="testpass123", + user_type="doctor", + first_name="Dana", + last_name="Doctor", + ) + self.non_clinical_user = User.objects.create_user( + username="community1", + password="testpass123", + user_type="community_worker", + first_name="Case", + last_name="Worker", + ) + self.location = Location.objects.create( + name="Central Region", + location_type="region", + ) + self.facility = HealthFacility.objects.create( + name="Central Hospital", + facility_type="hospital", + location=self.location, + address="123 Main Street", + phone_number="+256700000000", + email="central@example.com", + website="", + is_24_hours=True, + contact_person_name="Head Nurse", + contact_person_phone="+256700000001", + services_offered=[], + ) + self.patient = Patient.objects.create( + patient_id="PAT-2001", + first_name="Jane", + last_name="Doe", + date_of_birth=timezone.now().date() - timedelta(days=30 * 365), + gender="F", + phone_number="+256700000002", + email="jane@example.com", + address="456 Care Road", + location=self.location, + emergency_contact_name="John Doe", + emergency_contact_phone="+256700000003", + emergency_contact_relationship="Sibling", + registered_facility=self.facility, + ) + self.list_url = reverse("api:records-list") + self.client.force_authenticate(self.clinical_user) + + def _payload(self, **overrides): + """ + Build a base payload for record creation, overriding as needed. + """ + + base = { + "patient": self.patient.pk, + "facility": self.facility.pk, + "record_type": "medical", + "record_date": timezone.now().isoformat(), + "attending_provider": self.clinical_user.pk, + "chief_complaint": "Mild headache", + "assessment": "Observation required", + "diagnosis": [], + "treatment_plan": "Monitor symptoms", + "follow_up_plan": "Return in one week", + "notes": "Initial consultation", + "is_confidential": False, + "is_active": True, + } + base.update(overrides) + return base + + def test_clinical_staff_can_create_record(self): + """ + Clinical roles should be able to create patient records. + """ + + response = self.client.post(self.list_url, data=self._payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(HealthRecord.objects.count(), 1) + + def test_non_clinical_user_forbidden(self): + """ + Non-clinical roles must be denied read access. + """ + + self.client.force_authenticate(self.non_clinical_user) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_record(self): + """ + Ensure updates persist and return the latest content. + """ + + record = HealthRecord.objects.create( + patient=self.patient, + facility=self.facility, + record_type="medical", + record_date=timezone.now(), + attending_provider=self.clinical_user, + ) + url = reverse("api:records-detail", args=[record.pk]) + response = self.client.patch( + url, + data={"assessment": "Condition improving"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + record.refresh_from_db() + self.assertEqual(record.assessment, "Condition improving") + + def test_delete_record(self): + """ + Deleting a record should remove it from the database. + """ + + record = HealthRecord.objects.create( + patient=self.patient, + facility=self.facility, + record_type="medical", + record_date=timezone.now(), + attending_provider=self.clinical_user, + ) + url = reverse("api:records-detail", args=[record.pk]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(HealthRecord.objects.filter(pk=record.pk).exists()) + + def test_filter_by_record_type(self): + """ + Verify that filter parameters narrow the result set. + """ + + HealthRecord.objects.create( + patient=self.patient, + facility=self.facility, + record_type="medical", + record_date=timezone.now(), + attending_provider=self.clinical_user, + ) + HealthRecord.objects.create( + patient=self.patient, + facility=self.facility, + record_type="imaging", + record_date=timezone.now(), + attending_provider=self.clinical_user, + ) + response = self.client.get(self.list_url, {"record_type": "imaging"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["record_type"], "imaging") + + def test_by_patient_action(self): + """ + The by-patient helper should return records scoped by external ID. + """ + + HealthRecord.objects.create( + patient=self.patient, + facility=self.facility, + record_type="medical", + record_date=timezone.now(), + attending_provider=self.clinical_user, + ) + url = reverse("api:records-by-patient") + response = self.client.get(url, {"patient_id": self.patient.patient_id}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) diff --git a/apps/api/tests/test_rbac.py b/apps/api/tests/test_rbac.py new file mode 100644 index 0000000..a737c2f --- /dev/null +++ b/apps/api/tests/test_rbac.py @@ -0,0 +1,131 @@ +""" +RBAC enforcement tests for API endpoints. +""" + +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient, APIRequestFactory + +from apps.api.permissions import RoleRequired + +User = get_user_model() + + +class RBACPermissionTests(TestCase): + """Validate role-based access control wiring.""" + + def setUp(self) -> None: + self.factory = APIRequestFactory() + + self.admin_user = User.objects.create_user( + username="rbac-admin", + password="testpass123", + email="admin@example.com", + role=User.Role.ADMIN, + is_staff=True, + is_superuser=True, + ) + + self.provider_user = User.objects.create_user( + username="rbac-provider", + password="testpass123", + email="provider@example.com", + role=User.Role.PROVIDER, + ) + + self.patient_user = User.objects.create_user( + username="rbac-patient", + password="testpass123", + email="patient@example.com", + role=User.Role.PATIENT, + ) + + def _client_for(self, user: User) -> APIClient: + client = APIClient() + client.force_authenticate(user) + return client + + def test_patient_blocked_from_clinical_endpoints(self): + """Patients should receive 403 when calling staff-only endpoints.""" + client = self._client_for(self.patient_user) + url = reverse("api:patients-list") + response = client.get(url) + self.assertEqual(response.status_code, 403) + + def test_provider_blocked_from_admin_only_metrics(self): + """Providers must not access admin-only statistics endpoints.""" + client = self._client_for(self.provider_user) + url = reverse("api:api_stats") + response = client.get(url) + self.assertEqual(response.status_code, 403) + + def test_admin_can_access_admin_only_endpoints(self): + """Admins should be able to call restricted endpoints.""" + client = self._client_for(self.admin_user) + stats_url = reverse("api:api_stats") + export_url = reverse("api:export_data") + + stats_response = client.get(stats_url) + self.assertEqual(stats_response.status_code, 200) + + export_response = client.post(export_url, {"format": "csv", "type": "patients"}, format="json") + self.assertEqual(export_response.status_code, 200) + self.assertEqual(export_response.data["format"], "csv") + + def test_role_required_allows_provider_role(self): + """RoleRequired should allow users whose role matches the requirement.""" + permission = RoleRequired() + + class DummyView: + required_roles = frozenset({User.Role.PROVIDER}) + + request = self.factory.get("/dummy") + request.user = self.provider_user + + self.assertTrue(permission.has_permission(request, DummyView())) + + request.user = self.patient_user + self.assertFalse(permission.has_permission(request, DummyView())) + + request.user = self.admin_user + self.assertTrue(permission.has_permission(request, DummyView())) + + def test_provider_can_access_provider_endpoints(self): + """Providers should access endpoints allowed for providers.""" + client = self._client_for(self.provider_user) + url = reverse("api:patients-list") + response = client.get(url) + self.assertEqual(response.status_code, 200) + + def test_admin_can_access_all_endpoints(self): + """Admins should have access to all endpoints.""" + client = self._client_for(self.admin_user) + + # Test provider endpoints + patients_url = reverse("api:patients-list") + response = client.get(patients_url) + self.assertEqual(response.status_code, 200) + + # Test admin-only endpoints + health_workers_url = reverse("api:health-workers-list") + response = client.get(health_workers_url) + self.assertEqual(response.status_code, 200) + + def test_unauthenticated_blocked(self): + """Unauthenticated users should be blocked from protected endpoints.""" + client = APIClient() + url = reverse("api:patients-list") + response = client.get(url) + self.assertEqual(response.status_code, 401) + + def test_health_check_public(self): + """Health check endpoint should be publicly accessible.""" + client = APIClient() + url = reverse("api:health_check") + response = client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "healthy") + diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 0000000..f04e1e3 --- /dev/null +++ b/apps/api/urls.py @@ -0,0 +1,36 @@ +""" +URL configuration for API app. +""" + +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from . import views + +app_name = 'api' + +# Create a router and register our viewsets with it +router = DefaultRouter() +router.register(r'patients', views.PatientViewSet, basename='patients') +router.register(r'health-workers', views.HealthWorkerViewSet, basename='health-workers') +router.register(r'facilities', views.FacilityViewSet, basename='facilities') +router.register(r'visits', views.PatientVisitViewSet, basename='visits') +router.register(r'records', views.HealthRecordViewSet, basename='records') +router.register(r'audit-logs', views.AuditTrailViewSet, basename='audit-logs') +router.register(r'appointments', views.AppointmentViewSet, basename='appointment') + +urlpatterns = [ + # API v1 endpoints + path('', include(router.urls)), + + # Authentication endpoints + path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + + # Health check endpoint + path('health/', views.health_check, name='health_check'), + + # Custom endpoints + path('stats/', views.api_stats, name='api_stats'), + path('export/', views.export_data, name='export_data'), +] \ No newline at end of file diff --git a/apps/api/views.py b/apps/api/views.py new file mode 100644 index 0000000..0e279b2 --- /dev/null +++ b/apps/api/views.py @@ -0,0 +1,325 @@ +""" +API views for OpenCare-Africa health system. +""" + +from django.contrib.auth import get_user_model +from django.http import JsonResponse +from django.db.models import Q +from django.views.decorators.http import require_http_methods +from rest_framework import viewsets, status +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated +from rest_framework.response import Response + +from apps.api.mixins import AuditLogMixin +from apps.appointments.views import AppointmentViewSet # re-export for router +from apps.core.models import AuditTrail, HealthFacility +from apps.core.serializers import ( + AuditTrailSerializer, + HealthFacilitySerializer, + UserSerializer, +) +from apps.patients.models import Patient, PatientVisit +from apps.patients.serializers import ( + PatientCreateSerializer, + PatientDetailSerializer, + PatientSerializer, + PatientVisitCreateSerializer, + PatientVisitDetailSerializer, + PatientVisitSerializer, + PatientSearchSerializer, +) +from apps.records.models import HealthRecord +from apps.records.serializers import ( + HealthRecordCreateSerializer, + HealthRecordDetailSerializer, + HealthRecordSerializer, +) +from apps.core.audit import log_audit_event +from apps.api.permissions import IsClinicalStaff, RoleRequired, require_roles +from apps.records.filters import HealthRecordFilter + +User = get_user_model() + + +class PatientViewSet(AuditLogMixin, viewsets.ModelViewSet): + """ + ViewSet for patient management with audit logging. + """ + + permission_classes = [IsAuthenticated, RoleRequired] + required_roles = frozenset({User.Role.ADMIN, User.Role.PROVIDER}) + queryset = ( + Patient.objects.select_related("location", "registered_facility") + .prefetch_related("patientvisit_set") + .all() + ) + serializer_class = PatientSerializer + filterset_fields = ["registered_facility", "gender", "is_active"] + search_fields = ["patient_id", "first_name", "last_name", "phone_number"] + ordering_fields = ["registration_date", "last_name"] + + serializer_action_map = { + "list": PatientSerializer, + "retrieve": PatientDetailSerializer, + "create": PatientCreateSerializer, + "update": PatientCreateSerializer, + "partial_update": PatientCreateSerializer, + } + + def get_serializer_class(self): + return self.serializer_action_map.get(self.action, self.serializer_class) + + @action(detail=False, methods=["get"], url_path="search") + def search(self, request): + """ + Search patients by name, ID, or other criteria. + """ + serializer = PatientSearchSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + query = serializer.validated_data["query"] + search_type = serializer.validated_data["search_type"] + + queryset = self.get_queryset() + if search_type == "name": + queryset = queryset.filter( + Q(first_name__icontains=query) | Q(last_name__icontains=query) + ) + elif search_type == "patient_id": + queryset = queryset.filter(patient_id__icontains=query) + elif search_type == "phone": + queryset = queryset.filter(phone_number__icontains=query) + elif search_type == "location": + queryset = queryset.filter(location__name__icontains=query) + elif search_type == "facility": + queryset = queryset.filter(registered_facility__name__icontains=query) + + results = PatientSerializer(queryset[: serializer.validated_data["limit"]], many=True).data + + log_audit_event( + user=request.user, + action="view", + model_name=self.get_audit_model_name(), + object_id=self.get_audit_object_id(None), + request=request, + changes={ + "summary": "patient search executed", + "filters": [search_type], + }, + ) + + return Response({"results": results, "count": len(results)}) + + +class HealthWorkerViewSet(viewsets.ReadOnlyModelViewSet): + """ + ViewSet for health worker management. + """ + + permission_classes = [IsAuthenticated, RoleRequired] + required_roles = frozenset({User.Role.ADMIN}) + queryset = User.objects.filter( + user_type__in=["doctor", "nurse", "midwife", "community_worker"] + ) + serializer_class = UserSerializer + search_fields = ["first_name", "last_name", "specialization"] + ordering_fields = ["last_name", "date_joined"] + + +class FacilityViewSet(viewsets.ReadOnlyModelViewSet): + """ + ViewSet for health facility management. + """ + + permission_classes = [IsAuthenticated, RoleRequired] + required_roles = frozenset({User.Role.ADMIN, User.Role.PROVIDER}) + queryset = HealthFacility.objects.select_related("location").all() + serializer_class = HealthFacilitySerializer + filterset_fields = ["facility_type", "location"] + search_fields = ["name", "address"] + ordering_fields = ["name"] + + +class PatientVisitViewSet(AuditLogMixin, viewsets.ModelViewSet): + """ + ViewSet for patient visit management with audit logging. + """ + + permission_classes = [IsAuthenticated, RoleRequired] + required_roles = frozenset({User.Role.ADMIN, User.Role.PROVIDER}) + queryset = ( + PatientVisit.objects.select_related("patient", "facility", "attending_provider") + .all() + ) + serializer_class = PatientVisitSerializer + filterset_fields = ["visit_type", "status", "facility", "patient"] + search_fields = ["patient__patient_id", "patient__first_name", "patient__last_name"] + ordering_fields = ["scheduled_date", "created_at"] + + serializer_action_map = { + "list": PatientVisitSerializer, + "retrieve": PatientVisitDetailSerializer, + "create": PatientVisitCreateSerializer, + "update": PatientVisitCreateSerializer, + "partial_update": PatientVisitCreateSerializer, + } + + def get_serializer_class(self): + return self.serializer_action_map.get(self.action, self.serializer_class) + + @action(detail=False, methods=["get"], url_path="today") + def today(self, request): + """ + Return visits scheduled for today. + """ + from django.utils import timezone + + today = timezone.now().date() + visits = self.get_queryset().filter(scheduled_date__date=today) + results = PatientVisitSerializer(visits, many=True).data + log_audit_event( + user=request.user, + action="view", + model_name=self.get_audit_model_name(), + object_id=self.get_audit_object_id(None), + request=request, + changes={"summary": "today visit report generated"}, + ) + return Response({"results": results, "count": len(results)}) + + +class HealthRecordViewSet(AuditLogMixin, viewsets.ModelViewSet): + """ + Provide CRUD operations for patient health records with audit logging. + + Access is limited to authenticated clinical staff or superusers. Collections + support filtering by patient, provider, facility, record type, and date + ranges to keep browsing focused on relevant medical history. + """ + + permission_classes = [IsClinicalStaff] + queryset = ( + HealthRecord.objects.select_related("patient", "facility", "attending_provider") + .prefetch_related("medications", "laboratory_tests") + .all() + ) + serializer_class = HealthRecordSerializer + filterset_class = HealthRecordFilter + search_fields = [ + "patient__patient_id", + "patient__first_name", + "patient__last_name", + "facility__name", + "attending_provider__first_name", + "attending_provider__last_name", + ] + ordering_fields = ["record_date", "created_at"] + ordering = ["-record_date"] + + serializer_action_map = { + "list": HealthRecordSerializer, + "retrieve": HealthRecordDetailSerializer, + "create": HealthRecordCreateSerializer, + "update": HealthRecordCreateSerializer, + "partial_update": HealthRecordCreateSerializer, + } + + def get_serializer_class(self): + """ + Return the serializer configured for the current action. + """ + return self.serializer_action_map.get(self.action, self.serializer_class) + + @action(detail=False, methods=["get"], url_path="by-patient") + def by_patient(self, request): + """ + List health records for a patient identifier. + + This helper allows clinicians to quickly pull a patient's timeline by + passing their external `patient_id` as a query parameter. + """ + patient_identifier = request.query_params.get("patient_id") + if not patient_identifier: + return Response({"results": []}) + + queryset = self.filter_queryset( + self.get_queryset().filter(patient__patient_id=patient_identifier) + ) + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page or queryset, many=True) + + log_audit_event( + user=request.user, + action="view", + model_name=self.get_audit_model_name(), + object_id=self.get_audit_object_id(None), + request=request, + changes={"summary": "records fetched by patient identifier"}, + ) + + if page is not None: + return self.get_paginated_response(serializer.data) + return Response({"results": serializer.data}) + + +class AuditTrailViewSet(viewsets.ReadOnlyModelViewSet): + """ + Read-only access to audit trail entries for administrative users. + """ + + permission_classes = [IsAuthenticated, RoleRequired] + required_roles = frozenset({User.Role.ADMIN}) + queryset = AuditTrail.objects.select_related("user").all().order_by("-timestamp") + serializer_class = AuditTrailSerializer + filterset_fields = ["action", "model_name", "user"] + search_fields = ["object_id", "changes"] + ordering_fields = ["timestamp", "model_name"] + ordering = ["-timestamp"] + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def health_check(request): + """Health check endpoint for API monitoring.""" + return Response({ + "status": "healthy", + "service": "OpenCare-Africa API", + "version": "1.0.0", + "timestamp": "2024-01-01T00:00:00Z", + "endpoints": { + "patients": "/api/v1/patients/", + "health_workers": "/api/v1/health-workers/", + "facilities": "/api/v1/facilities/", + "visits": "/api/v1/visits/", + "records": "/api/v1/records/", + }, + }) + + +@require_roles(User.Role.ADMIN) +@api_view(["GET"]) +@permission_classes([IsAuthenticated, RoleRequired]) +def api_stats(request): + """Get API usage statistics.""" + return Response({ + "total_requests": 0, + "active_users": 0, + "popular_endpoints": [], + "response_times": {"average": 0, "p95": 0, "p99": 0}, + }) + + +@require_roles(User.Role.ADMIN) +@api_view(["POST"]) +@permission_classes([IsAuthenticated, RoleRequired]) +def export_data(request): + """Export data in various formats.""" + format_type = request.data.get("format", "json") + data_type = request.data.get("type", "patients") + + return Response({ + "message": "Data export endpoint", + "format": format_type, + "type": data_type, + "download_url": None, + }) diff --git a/apps/appointments/__init__.py b/apps/appointments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/appointments/admin.py b/apps/appointments/admin.py new file mode 100644 index 0000000..41ba462 --- /dev/null +++ b/apps/appointments/admin.py @@ -0,0 +1,44 @@ +""" +Admin configuration for appointments. +""" + +from django.contrib import admin + +from .models import Appointment + + +@admin.register(Appointment) +class AppointmentAdmin(admin.ModelAdmin): + """ + Read-only admin for viewing appointments. + """ + + list_display = [ + "patient", + "provider", + "facility", + "start_time", + "end_time", + "status", + ] + list_filter = ["status", "facility", "provider"] + search_fields = [ + "patient__first_name", + "patient__last_name", + "provider__first_name", + "provider__last_name", + "facility__name", + ] + ordering = ["-start_time"] + readonly_fields = [ + "created_at", + "updated_at", + "notifications_sent", + "created_by", + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/apps/appointments/apps.py b/apps/appointments/apps.py new file mode 100644 index 0000000..9e54014 --- /dev/null +++ b/apps/appointments/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AppointmentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.appointments" + verbose_name = "Appointments" diff --git a/apps/appointments/migrations/__init__.py b/apps/appointments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/appointments/models.py b/apps/appointments/models.py new file mode 100644 index 0000000..9e9b3f2 --- /dev/null +++ b/apps/appointments/models.py @@ -0,0 +1,115 @@ +""" +Appointment scheduling models. +""" + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Appointment(models.Model): + """ + Represents a scheduled interaction between a patient and provider. + """ + + class Status(models.TextChoices): + SCHEDULED = "scheduled", _("Scheduled") + COMPLETED = "completed", _("Completed") + CANCELLED = "cancelled", _("Cancelled") + NO_SHOW = "no_show", _("No Show") + + patient = models.ForeignKey( + "patients.Patient", + on_delete=models.CASCADE, + related_name="appointments", + ) + provider = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="appointments", + ) + facility = models.ForeignKey( + "core.HealthFacility", + on_delete=models.CASCADE, + related_name="appointments", + ) + appointment_type = models.CharField(max_length=50, blank=True) + reason = models.TextField(blank=True) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.SCHEDULED, + ) + start_time = models.DateTimeField() + end_time = models.DateTimeField() + notifications_sent = models.JSONField(default=list, blank=True) + + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="appointments_created", + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("Appointment") + verbose_name_plural = _("Appointments") + ordering = ["start_time"] + indexes = [ + models.Index(fields=["provider", "start_time"]), + models.Index(fields=["patient", "start_time"]), + models.Index(fields=["facility", "start_time"]), + ] + + def __str__(self): + return f"{self.patient} with {self.provider} on {self.start_time:%Y-%m-%d %H:%M}" + + def check_conflicts(self, exclude_pk=None): + """ + Check for scheduling conflicts with existing appointments. + + Returns a dict with conflict information if conflicts exist. + """ + from django.db.models import Q + + active_statuses = [self.Status.SCHEDULED, self.Status.NO_SHOW] + query = Q( + status__in=active_statuses, + start_time__lt=self.end_time, + end_time__gt=self.start_time + ) + + if exclude_pk: + query &= ~Q(pk=exclude_pk) + + conflicts = Appointment.objects.filter(query) + + provider_conflicts = conflicts.filter(provider=self.provider) + patient_conflicts = conflicts.filter(patient=self.patient) + facility_conflicts = conflicts.filter(facility=self.facility) + + result = {} + if provider_conflicts.exists(): + result['provider'] = list(provider_conflicts.values('id', 'start_time', 'end_time', 'patient__first_name', 'patient__last_name')) + if patient_conflicts.exists(): + result['patient'] = list(patient_conflicts.values('id', 'start_time', 'end_time', 'provider__first_name', 'provider__last_name')) + if facility_conflicts.exists(): + result['facility'] = list(facility_conflicts.values('id', 'start_time', 'end_time', 'patient__first_name', 'provider__first_name')) + + return result if result else None + + @property + def duration_minutes(self): + """Calculate appointment duration in minutes.""" + if self.start_time and self.end_time: + return int((self.end_time - self.start_time).total_seconds() / 60) + return 0 + + @property + def is_upcoming(self): + """Check if appointment is in the future.""" + from django.utils import timezone + return self.start_time > timezone.now() and self.status == self.Status.SCHEDULED diff --git a/apps/appointments/notifications.py b/apps/appointments/notifications.py new file mode 100644 index 0000000..2cd2728 --- /dev/null +++ b/apps/appointments/notifications.py @@ -0,0 +1,247 @@ +""" +Notification hooks for appointment events with email/SMS support. +""" + +import logging +from typing import Literal, Optional +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils import timezone + +from .models import Appointment + +logger = logging.getLogger(__name__) + +AppointmentEvent = Literal["created", "updated", "cancelled", "reminder"] + + +def send_email_notification( + appointment: Appointment, + event: AppointmentEvent, + recipient_email: str, + recipient_name: str +) -> bool: + """ + Send email notification for appointment events. + + Returns True if email was sent successfully, False otherwise. + """ + try: + subject = _get_email_subject(appointment, event) + message = _get_email_message(appointment, event, recipient_name) + from_email = getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@opencare-africa.com") + + send_mail( + subject=subject, + message=message, + from_email=from_email, + recipient_list=[recipient_email], + fail_silently=False, + ) + logger.info(f"Email sent to {recipient_email} for appointment {appointment.id} event {event}") + return True + except Exception as e: + logger.error(f"Failed to send email to {recipient_email}: {str(e)}") + return False + + +def send_sms_notification( + appointment: Appointment, + event: AppointmentEvent, + recipient_phone: str, + recipient_name: str +) -> bool: + """ + Send SMS notification for appointment events. + + This is a hook for SMS providers. In production, integrate with: + - Twilio + - AWS SNS + - Africa's Talking + - Other SMS gateway services + + Returns True if SMS was sent successfully, False otherwise. + """ + try: + message = _get_sms_message(appointment, event, recipient_name) + + # TODO: Integrate with actual SMS provider + # Example with Twilio: + # from twilio.rest import Client + # client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) + # client.messages.create( + # body=message, + # from_=settings.TWILIO_PHONE_NUMBER, + # to=recipient_phone + # ) + + # For now, log the SMS that would be sent + logger.info(f"SMS would be sent to {recipient_phone}: {message}") + logger.info(f"SMS notification for appointment {appointment.id} event {event}") + return True + except Exception as e: + logger.error(f"Failed to send SMS to {recipient_phone}: {str(e)}") + return False + + +def _get_email_subject(appointment: Appointment, event: AppointmentEvent) -> str: + """Generate email subject based on event type.""" + subjects = { + "created": f"Appointment Scheduled - {appointment.start_time.strftime('%B %d, %Y at %I:%M %p')}", + "updated": f"Appointment Updated - {appointment.start_time.strftime('%B %d, %Y at %I:%M %p')}", + "cancelled": f"Appointment Cancelled - {appointment.start_time.strftime('%B %d, %Y at %I:%M %p')}", + "reminder": f"Appointment Reminder - {appointment.start_time.strftime('%B %d, %Y at %I:%M %p')}", + } + return subjects.get(event, "Appointment Notification") + + +def _get_email_message(appointment: Appointment, event: AppointmentEvent, recipient_name: str) -> str: + """Generate email message body.""" + provider_name = appointment.provider.get_full_name() + patient_name = appointment.patient.get_full_name() + facility_name = appointment.facility.name + + messages = { + "created": f""" +Hello {recipient_name}, + +Your appointment has been scheduled: + +Patient: {patient_name} +Provider: {provider_name} +Facility: {facility_name} +Date & Time: {appointment.start_time.strftime('%B %d, %Y at %I:%M %p')} +Duration: {appointment.duration_minutes} minutes +Type: {appointment.appointment_type or 'General Consultation'} +Reason: {appointment.reason or 'Not specified'} + +Please arrive 10 minutes before your scheduled time. + +Thank you, +OpenCare-Africa Team + """, + "updated": f""" +Hello {recipient_name}, + +Your appointment has been updated: + +Patient: {patient_name} +Provider: {provider_name} +Facility: {facility_name} +New Date & Time: {appointment.start_time.strftime('%B %d, %Y at %I:%M %p')} +Duration: {appointment.duration_minutes} minutes + +Please note the new time and arrive 10 minutes early. + +Thank you, +OpenCare-Africa Team + """, + "cancelled": f""" +Hello {recipient_name}, + +Your appointment has been cancelled: + +Patient: {patient_name} +Provider: {provider_name} +Facility: {facility_name} +Original Date & Time: {appointment.start_time.strftime('%B %d, %Y at %I:%M %p')} + +If you need to reschedule, please contact the facility or book a new appointment. + +Thank you, +OpenCare-Africa Team + """, + "reminder": f""" +Hello {recipient_name}, + +This is a reminder about your upcoming appointment: + +Patient: {patient_name} +Provider: {provider_name} +Facility: {facility_name} +Date & Time: {appointment.start_time.strftime('%B %d, %Y at %I:%M %p')} +Duration: {appointment.duration_minutes} minutes + +Please arrive 10 minutes before your scheduled time. + +Thank you, +OpenCare-Africa Team + """, + } + return messages.get(event, "Appointment notification").strip() + + +def _get_sms_message(appointment: Appointment, event: AppointmentEvent, recipient_name: str) -> str: + """Generate SMS message body (shorter than email).""" + provider_name = appointment.provider.get_full_name() + date_str = appointment.start_time.strftime('%b %d, %Y at %I:%M %p') + + messages = { + "created": f"Appointment scheduled: {date_str} with {provider_name} at {appointment.facility.name}. Arrive 10 mins early.", + "updated": f"Appointment updated: {date_str} with {provider_name} at {appointment.facility.name}.", + "cancelled": f"Appointment cancelled: {date_str} with {provider_name}. Contact facility to reschedule.", + "reminder": f"Reminder: Appointment tomorrow {date_str} with {provider_name} at {appointment.facility.name}.", + } + return messages.get(event, "Appointment notification") + + +def send_notification(appointment: Appointment, event: AppointmentEvent) -> None: + """ + Dispatch notifications for appointment changes via email and SMS. + + Sends notifications to both patient and provider when appropriate. + """ + logger.info( + "Appointment %s: patient=%s provider=%s start=%s status=%s", + event, + appointment.patient_id, + appointment.provider_id, + appointment.start_time.isoformat(), + appointment.status, + ) + + notifications_sent = [] + + # Send to patient + if appointment.patient.email: + email_sent = send_email_notification( + appointment, event, + appointment.patient.email, + appointment.patient.get_full_name() + ) + if email_sent: + notifications_sent.append({"type": "email", "recipient": "patient", "method": "email", "sent_at": timezone.now().isoformat()}) + + if appointment.patient.phone_number: + sms_sent = send_sms_notification( + appointment, event, + appointment.patient.phone_number, + appointment.patient.get_full_name() + ) + if sms_sent: + notifications_sent.append({"type": "sms", "recipient": "patient", "method": "sms", "sent_at": timezone.now().isoformat()}) + + # Send to provider + if appointment.provider.email: + email_sent = send_email_notification( + appointment, event, + appointment.provider.email, + appointment.provider.get_full_name() + ) + if email_sent: + notifications_sent.append({"type": "email", "recipient": "provider", "method": "email", "sent_at": timezone.now().isoformat()}) + + if appointment.provider.phone_number: + sms_sent = send_sms_notification( + appointment, event, + appointment.provider.phone_number, + appointment.provider.get_full_name() + ) + if sms_sent: + notifications_sent.append({"type": "sms", "recipient": "provider", "method": "sms", "sent_at": timezone.now().isoformat()}) + + # Update appointment with notification history + existing_notifications = list(appointment.notifications_sent or []) + existing_notifications.extend(notifications_sent) + Appointment.objects.filter(pk=appointment.pk).update(notifications_sent=existing_notifications) diff --git a/apps/appointments/serializers.py b/apps/appointments/serializers.py new file mode 100644 index 0000000..8844469 --- /dev/null +++ b/apps/appointments/serializers.py @@ -0,0 +1,125 @@ +""" +Serializers for appointment scheduling. +""" + +from datetime import timedelta + +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from apps.core.models import HealthFacility, User +from apps.patients.models import Patient + +from .models import Appointment + + +class AppointmentSerializer(serializers.ModelSerializer): + """ + Lightweight serializer for listing appointments. + """ + + patient_name = serializers.CharField(source="patient.get_full_name", read_only=True) + provider_name = serializers.CharField(source="provider.get_full_name", read_only=True) + facility_name = serializers.CharField(source="facility.name", read_only=True) + + class Meta: + model = Appointment + fields = [ + "id", + "patient", + "patient_name", + "provider", + "provider_name", + "facility", + "facility_name", + "appointment_type", + "reason", + "status", + "start_time", + "end_time", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + +class AppointmentDetailSerializer(AppointmentSerializer): + """ + Detailed serializer that includes notification metadata. + """ + + class Meta(AppointmentSerializer.Meta): + fields = AppointmentSerializer.Meta.fields + ["notifications_sent"] + read_only_fields = AppointmentSerializer.Meta.read_only_fields + ["notifications_sent"] + + +class AppointmentCreateSerializer(serializers.ModelSerializer): + """ + Serializer for creating and updating appointments with conflict detection. + """ + + class Meta: + model = Appointment + fields = [ + "patient", + "provider", + "facility", + "appointment_type", + "reason", + "status", + "start_time", + "end_time", + ] + + def validate(self, attrs): + attrs = super().validate(attrs) + start = attrs.get("start_time") or getattr(self.instance, "start_time", None) + end = attrs.get("end_time") or getattr(self.instance, "end_time", None) + if start is None or end is None: + raise serializers.ValidationError(_("Start and end times are required.")) + if start >= end: + raise serializers.ValidationError(_("End time must be after start time.")) + if end - start < timedelta(minutes=5): + raise serializers.ValidationError(_("Appointment must be at least 5 minutes long.")) + + provider = attrs.get("provider") or getattr(self.instance, "provider", None) + patient = attrs.get("patient") or getattr(self.instance, "patient", None) + facility = attrs.get("facility") or getattr(self.instance, "facility", None) + + if provider and provider.user_type not in {"doctor", "nurse", "midwife", "community_worker"}: + raise serializers.ValidationError(_("Selected provider is not eligible for appointments.")) + + active_statuses = [Appointment.Status.SCHEDULED, Appointment.Status.NO_SHOW] + query_kwargs = {"status__in": active_statuses, "start_time__lt": end, "end_time__gt": start} + appointments = Appointment.objects.filter(**query_kwargs) + if self.instance: + appointments = appointments.exclude(pk=self.instance.pk) + + if provider and appointments.filter(provider=provider).exists(): + raise serializers.ValidationError({"provider": _("Provider already has an appointment in this window.")}) + if patient and appointments.filter(patient=patient).exists(): + raise serializers.ValidationError({"patient": _("Patient already has an appointment in this window.")}) + if facility and appointments.filter(facility=facility).exists(): + raise serializers.ValidationError({"facility": _("Facility already has an appointment in this window.")}) + + # Additional validation: check if appointment is in the past + if start and start < timezone.now(): + raise serializers.ValidationError({"start_time": _("Cannot schedule appointments in the past.")}) + + return attrs + + def validate_provider(self, value): + if not value.is_active: + raise serializers.ValidationError(_("Provider account is inactive.")) + return value + + def validate_patient(self, value): + if not value.is_active: + raise serializers.ValidationError(_("Patient profile is inactive.")) + return value + + def create(self, validated_data): + request = self.context.get("request") + validated_data.setdefault("created_by", getattr(request, "user", None)) + return super().create(validated_data) diff --git a/apps/appointments/tests/__init__.py b/apps/appointments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/appointments/tests/test_appointments_api.py b/apps/appointments/tests/test_appointments_api.py new file mode 100644 index 0000000..57d8538 --- /dev/null +++ b/apps/appointments/tests/test_appointments_api.py @@ -0,0 +1,431 @@ +""" +Comprehensive tests for the appointment scheduling API. +""" + +from datetime import datetime, timedelta + +from django.urls import reverse +from django.utils import timezone +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APITestCase + +from apps.core.models import HealthFacility, Location +from apps.patients.models import Patient +from apps.appointments.models import Appointment + + +User = get_user_model() + + +class AppointmentAPITests(APITestCase): + """Test appointment CRUD operations and conflict detection.""" + + def setUp(self): + self.location = Location.objects.create( + name="Central Region", + location_type="region" + ) + self.facility = HealthFacility.objects.create( + name="Hope Clinic", + facility_type="clinic", + location=self.location, + address="456 Care Street", + phone_number="+256701000002", + email="clinic@example.com", + website="", + is_24_hours=False, + contact_person_name="Admin", + contact_person_phone="+256701000003", + services_offered=[], + ) + + self.provider = User.objects.create_user( + username="provider1", + password="password123", + user_type="doctor", + role=User.Role.PROVIDER, + first_name="Provider", + last_name="One", + email="provider@example.com", + phone_number="+256701000100", + ) + + self.patient = Patient.objects.create( + patient_id="PAT-000100", + first_name="Patient", + last_name="One", + date_of_birth=datetime(1990, 1, 1).date(), + gender="M", + phone_number="+256701000000", + email="patient@example.com", + address="123 Wellness Road", + location=self.location, + emergency_contact_name="Emergency Contact", + emergency_contact_phone="+256701000001", + emergency_contact_relationship="Sibling", + registered_facility=self.facility, + ) + + self.client.force_authenticate(self.provider) + self.list_url = reverse("api:appointment-list") + + def _appointment_payload(self, **kwargs): + """Helper to create appointment payload.""" + start = timezone.now() + timedelta(hours=1) + end = start + timedelta(minutes=30) + payload = { + "patient": self.patient.pk, + "provider": self.provider.pk, + "facility": self.facility.pk, + "appointment_type": "consultation", + "reason": "Routine checkup", + "status": Appointment.Status.SCHEDULED, + "start_time": start.isoformat(), + "end_time": end.isoformat(), + } + payload.update(kwargs) + return payload + + def test_create_appointment(self): + """Test creating a new appointment.""" + response = self.client.post(self.list_url, data=self._appointment_payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Appointment.objects.count(), 1) + appointment = Appointment.objects.first() + self.assertEqual(appointment.provider, self.provider) + self.assertEqual(appointment.patient, self.patient) + self.assertEqual(appointment.status, Appointment.Status.SCHEDULED) + + def test_list_appointments(self): + """Test listing appointments.""" + Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + ) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + + def test_retrieve_appointment(self): + """Test retrieving a single appointment.""" + appointment = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + ) + url = reverse("api:appointment-detail", args=[appointment.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], appointment.pk) + + def test_update_appointment(self): + """Test updating an appointment.""" + appointment = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + ) + url = reverse("api:appointment-detail", args=[appointment.pk]) + new_reason = "Follow-up consultation" + response = self.client.patch(url, {"reason": new_reason}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + appointment.refresh_from_db() + self.assertEqual(appointment.reason, new_reason) + + def test_delete_appointment(self): + """Test deleting an appointment.""" + appointment = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + ) + url = reverse("api:appointment-detail", args=[appointment.pk]) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Appointment.objects.filter(pk=appointment.pk).exists()) + + def test_provider_conflict_detection(self): + """Test that provider cannot have overlapping appointments.""" + self.client.post(self.list_url, data=self._appointment_payload(), format="json") + # Try to create another appointment with same provider at overlapping time + start = timezone.now() + timedelta(hours=1, minutes=15) + end = start + timedelta(minutes=30) + payload = self._appointment_payload( + start_time=start.isoformat(), + end_time=end.isoformat() + ) + response = self.client.post(self.list_url, data=payload, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("provider", response.data) + + def test_patient_conflict_detection(self): + """Test that patient cannot have overlapping appointments.""" + another_provider = User.objects.create_user( + username="provider2", + password="password123", + user_type="doctor", + role=User.Role.PROVIDER, + first_name="Provider", + last_name="Two", + ) + self.client.post(self.list_url, data=self._appointment_payload(), format="json") + # Try to create another appointment with same patient at overlapping time + start = timezone.now() + timedelta(hours=1, minutes=15) + end = start + timedelta(minutes=30) + payload = self._appointment_payload( + provider=another_provider.pk, + start_time=start.isoformat(), + end_time=end.isoformat() + ) + response = self.client.post(self.list_url, data=payload, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("patient", response.data) + + def test_facility_conflict_detection(self): + """Test that facility cannot have overlapping appointments.""" + another_patient = Patient.objects.create( + patient_id="PAT-000101", + first_name="Patient", + last_name="Two", + date_of_birth=datetime(1992, 1, 1).date(), + gender="F", + phone_number="+256701000010", + email="patient2@example.com", + address="789 Health Road", + location=self.location, + emergency_contact_name="Emergency2", + emergency_contact_phone="+256701000011", + emergency_contact_relationship="Parent", + registered_facility=self.facility, + ) + self.client.post(self.list_url, data=self._appointment_payload(), format="json") + # Try to create another appointment at same facility at overlapping time + start = timezone.now() + timedelta(hours=1, minutes=15) + end = start + timedelta(minutes=30) + payload = self._appointment_payload( + patient=another_patient.pk, + start_time=start.isoformat(), + end_time=end.isoformat() + ) + response = self.client.post(self.list_url, data=payload, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("facility", response.data) + + def test_cannot_schedule_in_past(self): + """Test that appointments cannot be scheduled in the past.""" + past_time = timezone.now() - timedelta(hours=1) + payload = self._appointment_payload( + start_time=past_time.isoformat(), + end_time=(past_time + timedelta(minutes=30)).isoformat() + ) + response = self.client.post(self.list_url, data=payload, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("start_time", response.data) + + def test_minimum_duration_validation(self): + """Test that appointments must be at least 5 minutes long.""" + start = timezone.now() + timedelta(hours=1) + end = start + timedelta(minutes=3) # Too short + payload = self._appointment_payload( + start_time=start.isoformat(), + end_time=end.isoformat() + ) + response = self.client.post(self.list_url, data=payload, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_upcoming_appointments_action(self): + """Test the upcoming appointments endpoint.""" + # Create past appointment + Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() - timedelta(hours=1), + end_time=timezone.now() - timedelta(minutes=30), + status=Appointment.Status.SCHEDULED, + ) + # Create future appointment + future_appointment = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + status=Appointment.Status.SCHEDULED, + ) + url = reverse("api:appointment-upcoming") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["id"], future_appointment.pk) + + def test_by_provider_action(self): + """Test filtering appointments by provider.""" + another_provider = User.objects.create_user( + username="provider2", + password="password123", + user_type="nurse", + role=User.Role.PROVIDER, + ) + appointment1 = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + ) + Appointment.objects.create( + patient=self.patient, + provider=another_provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=2), + end_time=timezone.now() + timedelta(hours=2, minutes=30), + ) + url = reverse("api:appointment-by-provider", args=[self.provider.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["id"], appointment1.pk) + + def test_by_patient_action(self): + """Test filtering appointments by patient.""" + another_patient = Patient.objects.create( + patient_id="PAT-000102", + first_name="Patient", + last_name="Three", + date_of_birth=datetime(1993, 1, 1).date(), + gender="F", + phone_number="+256701000020", + email="patient3@example.com", + address="999 Health Road", + location=self.location, + emergency_contact_name="Emergency3", + emergency_contact_phone="+256701000021", + emergency_contact_relationship="Spouse", + registered_facility=self.facility, + ) + appointment1 = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + ) + Appointment.objects.create( + patient=another_patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=2), + end_time=timezone.now() + timedelta(hours=2, minutes=30), + ) + url = reverse("api:appointment-by-patient", args=[self.patient.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["id"], appointment1.pk) + + def test_cancel_appointment_action(self): + """Test cancelling an appointment.""" + appointment = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + status=Appointment.Status.SCHEDULED, + ) + url = reverse("api:appointment-cancel", args=[appointment.pk]) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + appointment.refresh_from_db() + self.assertEqual(appointment.status, Appointment.Status.CANCELLED) + + def test_complete_appointment_action(self): + """Test marking an appointment as completed.""" + appointment = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() - timedelta(hours=1), + end_time=timezone.now() - timedelta(minutes=30), + status=Appointment.Status.SCHEDULED, + ) + url = reverse("api:appointment-complete", args=[appointment.pk]) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + appointment.refresh_from_db() + self.assertEqual(appointment.status, Appointment.Status.COMPLETED) + + def test_mark_no_show_action(self): + """Test marking an appointment as no-show.""" + appointment = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() - timedelta(hours=1), + end_time=timezone.now() - timedelta(minutes=30), + status=Appointment.Status.SCHEDULED, + ) + url = reverse("api:appointment-mark-no-show", args=[appointment.pk]) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + appointment.refresh_from_db() + self.assertEqual(appointment.status, Appointment.Status.NO_SHOW) + + def test_check_conflicts_action(self): + """Test the check conflicts endpoint.""" + appointment1 = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1, minutes=30), + status=Appointment.Status.SCHEDULED, + ) + # Create overlapping appointment + appointment2 = Appointment.objects.create( + patient=self.patient, + provider=self.provider, + facility=self.facility, + start_time=timezone.now() + timedelta(hours=1, minutes=15), + end_time=timezone.now() + timedelta(hours=1, minutes=45), + status=Appointment.Status.SCHEDULED, + ) + url = reverse("api:appointment-check-conflicts", args=[appointment2.pk]) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["has_conflicts"]) + + def test_rbac_patient_blocked(self): + """Test that patients cannot access appointment endpoints.""" + patient_user = User.objects.create_user( + username="patient_user", + password="password123", + role=User.Role.PATIENT, + email="patient_user@example.com", + ) + self.client.force_authenticate(patient_user) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_rbac_admin_can_access(self): + """Test that admins can access appointment endpoints.""" + admin_user = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="password123", + role=User.Role.ADMIN, + ) + self.client.force_authenticate(admin_user) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/apps/appointments/views.py b/apps/appointments/views.py new file mode 100644 index 0000000..9a3f41b --- /dev/null +++ b/apps/appointments/views.py @@ -0,0 +1,166 @@ +""" +ViewSet for appointment scheduling. +""" + +from django.utils import timezone +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from apps.api.mixins import AuditLogMixin +from apps.api.permissions import RoleRequired +from django.contrib.auth import get_user_model +from .models import Appointment +from .serializers import ( + AppointmentCreateSerializer, + AppointmentDetailSerializer, + AppointmentSerializer, +) +from .notifications import send_notification + +User = get_user_model() + + +class AppointmentViewSet(AuditLogMixin, viewsets.ModelViewSet): + """ + Manage patient-provider appointments with conflict detection. + """ + + queryset = ( + Appointment.objects.select_related("patient", "provider", "facility", "created_by") + .all() + ) + permission_classes = [IsAuthenticated, RoleRequired] + required_roles = frozenset({User.Role.ADMIN, User.Role.PROVIDER}) + serializer_class = AppointmentSerializer + filterset_fields = [ + "provider", + "patient", + "facility", + "status", + "appointment_type", + ] + search_fields = [ + "patient__patient_id", + "patient__first_name", + "patient__last_name", + "provider__first_name", + "provider__last_name", + ] + ordering_fields = ["start_time", "created_at"] + ordering = ["start_time"] + + serializer_action_map = { + "list": AppointmentSerializer, + "retrieve": AppointmentDetailSerializer, + "create": AppointmentCreateSerializer, + "update": AppointmentCreateSerializer, + "partial_update": AppointmentCreateSerializer, + } + + def get_serializer_class(self): + return self.serializer_action_map.get(self.action, self.serializer_class) + + def perform_create(self, serializer): + appointment = super().perform_create(serializer) + send_notification(appointment, "created") + return appointment + + def perform_update(self, serializer): + appointment = super().perform_update(serializer) + send_notification(appointment, "updated") + return appointment + + def perform_destroy(self, instance): + send_notification(instance, "cancelled") + super().perform_destroy(instance) + + @action(detail=False, methods=["get"], url_path="upcoming") + def upcoming(self, request): + """Get all upcoming appointments.""" + now = timezone.now() + queryset = self.get_queryset().filter( + start_time__gt=now, + status=Appointment.Status.SCHEDULED + ) + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page or queryset, many=True) + if page is not None: + return self.get_paginated_response(serializer.data) + return Response(serializer.data) + + @action(detail=False, methods=["get"], url_path="by-provider/(?P[^/.]+)") + def by_provider(self, request, provider_id=None): + """Get appointments for a specific provider.""" + queryset = self.get_queryset().filter(provider_id=provider_id) + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page or queryset, many=True) + if page is not None: + return self.get_paginated_response(serializer.data) + return Response(serializer.data) + + @action(detail=False, methods=["get"], url_path="by-patient/(?P[^/.]+)") + def by_patient(self, request, patient_id=None): + """Get appointments for a specific patient.""" + queryset = self.get_queryset().filter(patient_id=patient_id) + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page or queryset, many=True) + if page is not None: + return self.get_paginated_response(serializer.data) + return Response(serializer.data) + + @action(detail=True, methods=["post"], url_path="check-conflicts") + def check_conflicts(self, request, pk=None): + """Check for scheduling conflicts for this appointment.""" + appointment = self.get_object() + conflicts = appointment.check_conflicts(exclude_pk=appointment.pk) + return Response({ + "has_conflicts": conflicts is not None, + "conflicts": conflicts or {} + }) + + @action(detail=True, methods=["post"], url_path="cancel") + def cancel(self, request, pk=None): + """Cancel an appointment.""" + appointment = self.get_object() + if appointment.status == Appointment.Status.CANCELLED: + return Response( + {"error": "Appointment is already cancelled."}, + status=status.HTTP_400_BAD_REQUEST + ) + appointment.status = Appointment.Status.CANCELLED + appointment.save() + send_notification(appointment, "cancelled") + serializer = self.get_serializer(appointment) + return Response(serializer.data) + + @action(detail=True, methods=["post"], url_path="complete") + def complete(self, request, pk=None): + """Mark an appointment as completed.""" + appointment = self.get_object() + if appointment.status == Appointment.Status.COMPLETED: + return Response( + {"error": "Appointment is already completed."}, + status=status.HTTP_400_BAD_REQUEST + ) + appointment.status = Appointment.Status.COMPLETED + appointment.save() + send_notification(appointment, "updated") + serializer = self.get_serializer(appointment) + return Response(serializer.data) + + @action(detail=True, methods=["post"], url_path="mark-no-show") + def mark_no_show(self, request, pk=None): + """Mark an appointment as no-show.""" + appointment = self.get_object() + if appointment.status == Appointment.Status.NO_SHOW: + return Response( + {"error": "Appointment is already marked as no-show."}, + status=status.HTTP_400_BAD_REQUEST + ) + appointment.status = Appointment.Status.NO_SHOW + appointment.save() + send_notification(appointment, "updated") + serializer = self.get_serializer(appointment) + return Response(serializer.data) diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/admin.py b/apps/core/admin.py new file mode 100644 index 0000000..328a110 --- /dev/null +++ b/apps/core/admin.py @@ -0,0 +1,195 @@ +""" +Admin configuration for core models. +""" + +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth import get_user_model +from django.utils.html import format_html +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from .models import Location, HealthFacility, AuditTrail, SystemConfiguration + +User = get_user_model() + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """ + Admin interface for User model. + """ + list_display = [ + 'username', 'email', 'first_name', 'last_name', 'role', 'user_type', + 'phone_number', 'is_active', 'date_joined', 'last_login' + ] + list_filter = [ + 'role', 'user_type', 'is_active', 'is_staff', 'is_superuser', + 'date_joined', 'last_login' + ] + search_fields = ['username', 'first_name', 'last_name', 'email', 'phone_number'] + ordering = ['-date_joined'] + + fieldsets = ( + (None, {'fields': ('username', 'password')}), + (_('Personal info'), { + 'fields': ( + 'first_name', 'last_name', 'email', 'phone_number', + 'date_of_birth', 'profile_picture' + ) + }), + (_('Professional info'), { + 'fields': ( + 'role', 'user_type', 'license_number', 'specialization', + 'years_of_experience' + ) + }), + (_('Permissions'), { + 'fields': ( + 'is_active', 'is_staff', 'is_superuser', 'groups', + 'user_permissions' + ), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + ) + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ( + 'username', 'email', 'password1', 'password2', + 'first_name', 'last_name', 'role', 'user_type' + ), + }), + ) + + +@admin.register(Location) +class LocationAdmin(admin.ModelAdmin): + """ + Admin interface for Location model. + """ + list_display = [ + 'name', 'location_type', 'parent', 'latitude', 'longitude', + 'get_full_location' + ] + list_filter = ['location_type', 'parent'] + search_fields = ['name', 'parent__name'] + ordering = ['location_type', 'name'] + + fieldsets = ( + (_('Basic Information'), { + 'fields': ('name', 'location_type', 'parent') + }), + (_('Geographic Information'), { + 'fields': ('latitude', 'longitude') + }), + ) + + def get_full_location(self, obj): + if obj.parent: + return f"{obj.name}, {obj.parent}" + return obj.name + get_full_location.short_description = _('Full Location') + + +@admin.register(HealthFacility) +class HealthFacilityAdmin(admin.ModelAdmin): + """ + Admin interface for HealthFacility model. + """ + list_display = [ + 'name', 'facility_type', 'location', 'phone_number', + 'is_24_hours', 'contact_person_name', 'created_at' + ] + list_filter = [ + 'facility_type', 'is_24_hours', 'location', 'created_at' + ] + search_fields = ['name', 'address', 'contact_person_name', 'phone_number'] + ordering = ['name'] + + fieldsets = ( + (_('Basic Information'), { + 'fields': ('name', 'facility_type', 'location', 'address') + }), + (_('Contact Information'), { + 'fields': ( + 'phone_number', 'email', 'website', 'contact_person_name', + 'contact_person_phone' + ) + }), + (_('Operating Hours'), { + 'fields': ('is_24_hours', 'opening_time', 'closing_time') + }), + (_('Services'), { + 'fields': ('services_offered',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(AuditTrail) +class AuditTrailAdmin(admin.ModelAdmin): + """ + Admin interface for AuditTrail model. + """ + list_display = [ + 'user', 'action', 'model_name', 'object_id', 'timestamp', + 'ip_address' + ] + list_filter = ['action', 'model_name', 'timestamp'] + search_fields = ['user__username', 'user__first_name', 'user__last_name', 'model_name'] + ordering = ['-timestamp'] + + fieldsets = ( + (_('Action Details'), { + 'fields': ('user', 'action', 'model_name', 'object_id') + }), + (_('Changes'), { + 'fields': ('changes',) + }), + (_('Context'), { + 'fields': ('ip_address', 'user_agent', 'timestamp') + }), + ) + + readonly_fields = ['timestamp'] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(SystemConfiguration) +class SystemConfigurationAdmin(admin.ModelAdmin): + """ + Admin interface for SystemConfiguration model. + """ + list_display = ['key', 'value', 'is_public', 'created_at', 'updated_at'] + list_filter = ['is_public', 'created_at'] + search_fields = ['key', 'description'] + ordering = ['key'] + + fieldsets = ( + (_('Configuration'), { + 'fields': ('key', 'value', 'description', 'is_public') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ['created_at', 'updated_at'] + + +# Customize admin site +admin.site.site_header = _('OpenCare-Africa Administration') +admin.site.site_title = _('OpenCare-Africa Admin') +admin.site.index_title = _('Welcome to OpenCare-Africa Admin') diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..d899cbd --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.core' + verbose_name = 'Core' diff --git a/apps/core/audit.py b/apps/core/audit.py new file mode 100644 index 0000000..b8df0a5 --- /dev/null +++ b/apps/core/audit.py @@ -0,0 +1,95 @@ +""" +Utility helpers for writing sanitized audit trail entries. +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable, Optional + +from django.db import transaction +from .models import AuditTrail + +ALLOWED_CHANGE_KEYS = {"fields", "summary", "count", "filters", "metadata"} + + +def _get_client_ip(request) -> Optional[str]: + """ + Extract the originating client IP address from request headers. + """ + if request is None: + return None + forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if forwarded_for: + # X-Forwarded-For may contain multiple comma-separated values. + return forwarded_for.split(",")[0].strip() + return request.META.get("REMOTE_ADDR") + + +def _get_user_agent(request) -> str: + """ + Return a truncated user agent string from the request. + """ + if request is None: + return "" + user_agent = request.META.get("HTTP_USER_AGENT", "") + # Prevent oversized headers from being stored. + return user_agent[:512] + + +def sanitize_change_payload(changes: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """ + Ensure the change payload only contains whitelisted keys and simple values. + """ + if not isinstance(changes, dict): + return {} + sanitized: Dict[str, Any] = {} + for key, value in changes.items(): + if key not in ALLOWED_CHANGE_KEYS: + continue + if key in {"fields", "filters"} and isinstance(value, Iterable): + sanitized[key] = sorted({str(item) for item in value}) + elif key in {"summary", "metadata"}: + sanitized[key] = str(value) + elif key == "count": + try: + sanitized[key] = int(value) + except (TypeError, ValueError): + sanitized[key] = 0 + return sanitized + + +def log_audit_event( + *, + user, + action: str, + model_name: str, + object_id: str, + request=None, + changes: Optional[Dict[str, Any]] = None, +) -> None: + """ + Persist a sanitized audit trail entry. + """ + if not model_name: + raise ValueError("model_name is required for audit logging") + + sanitized_changes = sanitize_change_payload(changes) + ip_address = _get_client_ip(request) + user_agent = _get_user_agent(request) + actor = user if getattr(user, "is_authenticated", False) else None + + def _create_entry(): + AuditTrail.objects.create( + user=actor, + action=action, + model_name=model_name, + object_id=str(object_id) if object_id is not None else "", + changes=sanitized_changes, + ip_address=ip_address, + user_agent=user_agent, + ) + + if transaction.get_connection().in_atomic_block: + transaction.on_commit(_create_entry) + else: + _create_entry() diff --git a/apps/core/management/__init__.py b/apps/core/management/__init__.py new file mode 100644 index 0000000..f490419 --- /dev/null +++ b/apps/core/management/__init__.py @@ -0,0 +1 @@ +# Management package for core app diff --git a/apps/core/management/commands/__init__.py b/apps/core/management/commands/__init__.py new file mode 100644 index 0000000..2cb1af6 --- /dev/null +++ b/apps/core/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands package for core app diff --git a/apps/core/management/commands/setup_project.py b/apps/core/management/commands/setup_project.py new file mode 100644 index 0000000..daaaba8 --- /dev/null +++ b/apps/core/management/commands/setup_project.py @@ -0,0 +1,526 @@ +""" +Management command to set up the OpenCare-Africa project with initial data. +""" + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from apps.core.models import Location, HealthFacility, SystemConfiguration +from apps.patients.models import Patient +from apps.health_workers.models import HealthWorkerProfile +from apps.facilities.models import FacilityService +from apps.analytics.models import HealthMetrics +from apps.records.models import HealthRecord +import os +from datetime import date, datetime + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Set up OpenCare-Africa project with initial data and configuration' + + def add_arguments(self, parser): + parser.add_argument( + '--skip-data', + action='store_true', + help='Skip creating sample data', + ) + parser.add_argument( + '--admin-email', + type=str, + default='admin@opencare-africa.com', + help='Admin user email address', + ) + parser.add_argument( + '--admin-password', + type=str, + default='admin123', + help='Admin user password', + ) + + def handle(self, *args, **options): + self.stdout.write( + self.style.SUCCESS('πŸš€ Setting up OpenCare-Africa project...') + ) + + # Create superuser if it doesn't exist + if not User.objects.filter(is_superuser=True).exists(): + self.create_superuser(options['admin_email'], options['admin_password']) + + # Update site configuration + self.update_site_config() + + # Create system configurations + self.create_system_configs() + + if not options['skip_data']: + # Create sample data + self.create_sample_locations() + self.create_sample_facilities() + self.create_sample_services() + self.create_sample_health_workers() + self.create_sample_patients() + self.create_sample_health_records() + self.create_sample_analytics() + + self.stdout.write( + self.style.SUCCESS('βœ… OpenCare-Africa project setup completed successfully!') + ) + + def create_superuser(self, email, password): + """Create a superuser account.""" + try: + user = User.objects.create_superuser( + username='admin', + email=email, + password=password, + first_name='Admin', + last_name='User', + user_type='admin' + ) + self.stdout.write( + self.style.SUCCESS(f'βœ… Superuser created: {user.username}') + ) + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Superuser creation failed: {e}') + ) + + def update_site_config(self): + """Update Django site configuration.""" + try: + site, created = Site.objects.get_or_create( + id=1, + defaults={ + 'domain': 'opencare-africa.com', + 'name': 'OpenCare-Africa' + } + ) + if created: + self.stdout.write('βœ… Site configuration created') + else: + self.stdout.write('βœ… Site configuration updated') + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Site configuration failed: {e}') + ) + + def create_system_configs(self): + """Create system configuration entries.""" + configs = [ + { + 'key': 'system_name', + 'value': 'OpenCare-Africa', + 'description': 'System name for display purposes', + 'is_public': True + }, + { + 'key': 'system_version', + 'value': '1.0.0', + 'description': 'Current system version', + 'is_public': True + }, + { + 'key': 'maintenance_mode', + 'value': 'false', + 'description': 'System maintenance mode flag', + 'is_public': False + }, + { + 'key': 'max_file_size', + 'value': '10485760', + 'description': 'Maximum file upload size in bytes (10MB)', + 'is_public': True + }, + { + 'key': 'session_timeout', + 'value': '3600', + 'description': 'Session timeout in seconds', + 'is_public': False + } + ] + + for config in configs: + SystemConfiguration.objects.get_or_create( + key=config['key'], + defaults=config + ) + + self.stdout.write('βœ… System configurations created') + + def create_sample_locations(self): + """Create sample location hierarchy.""" + try: + # Create country + kenya, created = Location.objects.get_or_create( + name='Kenya', + defaults={ + 'location_type': 'country', + 'latitude': -1.2921, + 'longitude': 36.8219 + } + ) + if created: + self.stdout.write('βœ… Sample country created: Kenya') + + # Create regions + regions = [ + { + 'name': 'Nairobi', + 'location_type': 'region', + 'parent': kenya, + 'latitude': -1.2921, + 'longitude': 36.8219 + }, + { + 'name': 'Mombasa', + 'location_type': 'region', + 'parent': kenya, + 'latitude': -4.0435, + 'longitude': 39.6682 + }, + { + 'name': 'Kisumu', + 'location_type': 'region', + 'parent': kenya, + 'latitude': -0.1022, + 'longitude': 34.7617 + } + ] + + for region_data in regions: + region, created = Location.objects.get_or_create( + name=region_data['name'], + parent=region_data['parent'], + defaults=region_data + ) + if created: + self.stdout.write(f'βœ… Sample region created: {region.name}') + + # Create districts + nairobi = Location.objects.get(name='Nairobi', parent=kenya) + districts = [ + { + 'name': 'Nairobi County', + 'location_type': 'district', + 'parent': nairobi, + 'latitude': -1.2921, + 'longitude': 36.8219 + } + ] + + for district_data in districts: + district, created = Location.objects.get_or_create( + name=district_data['name'], + parent=district_data['parent'], + defaults=district_data + ) + if created: + self.stdout.write(f'βœ… Sample district created: {district.name}') + + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Sample locations creation failed: {e}') + ) + + def create_sample_facilities(self): + """Create sample health facilities.""" + try: + nairobi_county = Location.objects.get(name='Nairobi County') + + facilities = [ + { + 'name': 'Kenyatta National Hospital', + 'facility_type': 'hospital', + 'location': nairobi_county, + 'address': 'Hospital Road, Nairobi', + 'phone_number': '+254-20-2726300', + 'email': 'info@knh.or.ke', + 'website': 'https://knh.or.ke', + 'is_24_hours': True, + 'contact_person_name': 'Dr. John Doe', + 'contact_person_phone': '+254-700-000000', + 'services_offered': ['emergency_care', 'surgery', 'maternity', 'pediatrics'] + }, + { + 'name': 'Mama Lucy Kibaki Hospital', + 'facility_type': 'hospital', + 'location': nairobi_county, + 'address': 'Kangundo Road, Nairobi', + 'phone_number': '+254-20-1234567', + 'email': 'info@mlkh.or.ke', + 'website': '', + 'is_24_hours': True, + 'contact_person_name': 'Dr. Jane Smith', + 'contact_person_phone': '+254-700-000001', + 'services_offered': ['emergency_care', 'maternity', 'pediatrics'] + }, + { + 'name': 'Nairobi West Hospital', + 'facility_type': 'health_center', + 'location': nairobi_county, + 'address': 'Westlands, Nairobi', + 'phone_number': '+254-20-9876543', + 'email': 'info@nwh.or.ke', + 'website': '', + 'is_24_hours': False, + 'contact_person_name': 'Dr. Robert Johnson', + 'contact_person_phone': '+254-700-000002', + 'services_offered': ['primary_care', 'laboratory', 'pharmacy'] + } + ] + + for facility_data in facilities: + facility, created = HealthFacility.objects.get_or_create( + name=facility_data['name'], + defaults=facility_data + ) + if created: + self.stdout.write(f'βœ… Sample facility created: {facility.name}') + + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Sample facilities creation failed: {e}') + ) + + def create_sample_services(self): + """Create sample facility services.""" + try: + facility = HealthFacility.objects.get(name='Kenyatta National Hospital') + + services = [ + { + 'facility': facility, + 'name': 'Emergency Care', + 'category': 'emergency_care', + 'description': '24/7 emergency medical services', + 'is_available': True, + 'cost': 5000.00, + 'duration_minutes': 120 + }, + { + 'facility': facility, + 'name': 'General Surgery', + 'category': 'specialized_care', + 'description': 'Surgical procedures and consultations', + 'is_available': True, + 'cost': 15000.00, + 'duration_minutes': 180 + }, + { + 'facility': facility, + 'name': 'Maternity Care', + 'category': 'maternity', + 'description': 'Prenatal, delivery, and postnatal care', + 'is_available': True, + 'cost': 8000.00, + 'duration_minutes': 60 + } + ] + + for service_data in services: + service, created = FacilityService.objects.get_or_create( + facility=service_data['facility'], + name=service_data['name'], + defaults=service_data + ) + if created: + self.stdout.write(f'βœ… Sample service created: {service.name}') + + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Sample services creation failed: {e}') + ) + + def create_sample_health_workers(self): + """Create sample health workers.""" + try: + facility = HealthFacility.objects.get(name='Kenyatta National Hospital') + + # Create health worker users + workers = [ + { + 'username': 'dr.doe', + 'email': 'john.doe@knh.or.ke', + 'first_name': 'John', + 'last_name': 'Doe', + 'user_type': 'doctor', + 'phone_number': '+254-700-000000', + 'license_number': 'MED001', + 'specialization': 'Emergency Medicine', + 'years_of_experience': 8 + }, + { + 'username': 'nurse.smith', + 'email': 'jane.smith@knh.or.ke', + 'first_name': 'Jane', + 'last_name': 'Smith', + 'user_type': 'nurse', + 'phone_number': '+254-700-000001', + 'license_number': 'NUR001', + 'specialization': 'Critical Care', + 'years_of_experience': 5 + } + ] + + for worker_data in workers: + user, created = User.objects.get_or_create( + username=worker_data['username'], + defaults=worker_data + ) + + if created: + user.set_password('password123') + user.save() + + # Create health worker profile + profile, profile_created = HealthWorkerProfile.objects.get_or_create( + user=user, + defaults={ + 'license_number': worker_data['license_number'], + 'specialization': worker_data['specialization'], + 'years_of_experience': worker_data['years_of_experience'], + 'primary_facility': facility, + 'is_licensed': True + } + ) + + if profile_created: + self.stdout.write(f'βœ… Sample health worker created: {user.get_full_name()}') + + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Sample health workers creation failed: {e}') + ) + + def create_sample_patients(self): + """Create sample patients.""" + try: + facility = HealthFacility.objects.get(name='Kenyatta National Hospital') + location = Location.objects.get(name='Nairobi County') + + patients = [ + { + 'patient_id': 'P001', + 'first_name': 'Mary', + 'last_name': 'Wanjiku', + 'date_of_birth': date(1985, 6, 15), + 'gender': 'F', + 'phone_number': '+254-700-000100', + 'address': '123 Kimathi Street, Nairobi', + 'location': location, + 'registered_facility': facility, + 'emergency_contact_name': 'John Wanjiku', + 'emergency_contact_phone': '+254-700-000101', + 'emergency_contact_relationship': 'Husband', + 'blood_type': 'O+', + 'allergies': ['Penicillin'], + 'chronic_conditions': ['Hypertension'] + }, + { + 'patient_id': 'P002', + 'first_name': 'James', + 'last_name': 'Kamau', + 'date_of_birth': date(1990, 3, 22), + 'gender': 'M', + 'phone_number': '+254-700-000200', + 'address': '456 Moi Avenue, Nairobi', + 'location': location, + 'registered_facility': facility, + 'emergency_contact_name': 'Grace Kamau', + 'emergency_contact_phone': '+254-700-000201', + 'emergency_contact_relationship': 'Wife', + 'blood_type': 'A+', + 'allergies': [], + 'chronic_conditions': [] + } + ] + + for patient_data in patients: + patient, created = Patient.objects.get_or_create( + patient_id=patient_data['patient_id'], + defaults=patient_data + ) + if created: + self.stdout.write(f'βœ… Sample patient created: {patient.get_full_name()}') + + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Sample patients creation failed: {e}') + ) + + def create_sample_health_records(self): + """Create sample health records.""" + try: + patient = Patient.objects.get(patient_id='P001') + facility = HealthFacility.objects.get(name='Kenyatta National Hospital') + provider = User.objects.get(username='dr.doe') + + record, created = HealthRecord.objects.get_or_create( + patient=patient, + facility=facility, + record_type='medical', + defaults={ + 'record_date': datetime.now(), + 'attending_provider': provider, + 'chief_complaint': 'Headache and fever for 3 days', + 'history_of_present_illness': 'Patient reports severe headache and fever starting 3 days ago', + 'assessment': 'Suspected viral infection', + 'treatment_plan': 'Rest, fluids, and pain management', + 'notes': 'Follow up in 1 week if symptoms persist' + } + ) + + if created: + self.stdout.write(f'βœ… Sample health record created for {patient.get_full_name()}') + + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Sample health records creation failed: {e}') + ) + + def create_sample_analytics(self): + """Create sample analytics data.""" + try: + location = Location.objects.get(name='Nairobi County') + facility = HealthFacility.objects.get(name='Kenyatta National Hospital') + + metrics = [ + { + 'metric_type': 'patient_count', + 'value': 150, + 'unit': 'patients', + 'location': location, + 'facility': facility, + 'date': date.today(), + 'period': 'daily' + }, + { + 'metric_type': 'visit_count', + 'value': 45, + 'unit': 'visits', + 'location': location, + 'facility': facility, + 'date': date.today(), + 'period': 'daily' + } + ] + + for metric_data in metrics: + metric, created = HealthMetrics.objects.get_or_create( + metric_type=metric_data['metric_type'], + location=metric_data['location'], + facility=metric_data['facility'], + date=metric_data['date'], + period=metric_data['period'], + defaults=metric_data + ) + + if created: + self.stdout.write(f'βœ… Sample metric created: {metric.get_metric_type_display()}') + + except Exception as e: + self.stdout.write( + self.style.WARNING(f'⚠️ Sample analytics creation failed: {e}') + ) diff --git a/apps/core/migrations/0001_add_role_field.py b/apps/core/migrations/0001_add_role_field.py new file mode 100644 index 0000000..8832809 --- /dev/null +++ b/apps/core/migrations/0001_add_role_field.py @@ -0,0 +1,24 @@ +# Generated migration for adding role field to User model + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='role', + field=models.CharField( + choices=[('admin', 'Administrator'), ('provider', 'Healthcare Provider'), ('patient', 'Patient')], + default='provider', + help_text='High-level persona used for role-based access control.', + max_length=20 + ), + ), + ] + diff --git a/apps/core/migrations/0002_set_admin_role_default.py b/apps/core/migrations/0002_set_admin_role_default.py new file mode 100644 index 0000000..66c31f9 --- /dev/null +++ b/apps/core/migrations/0002_set_admin_role_default.py @@ -0,0 +1,21 @@ +# Generated migration for setting admin role for superusers + +from django.db import migrations + + +def _set_admin_role(apps, schema_editor): + """Set admin role for existing superusers.""" + User = apps.get_model("core", "User") + User.objects.filter(is_superuser=True).update(role="admin") + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0001_add_role_field"), + ] + + operations = [ + migrations.RunPython(_set_admin_role, migrations.RunPython.noop), + ] + diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..486c751 --- /dev/null +++ b/apps/core/models.py @@ -0,0 +1,203 @@ +""" +Core models for OpenCare-Africa health system. +""" + +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.utils.translation import gettext_lazy as _ +from django.core.validators import RegexValidator + + +class User(AbstractUser): + """ + Custom user model for health workers and administrators. + """ + class Role(models.TextChoices): + ADMIN = 'admin', _('Administrator') + PROVIDER = 'provider', _('Healthcare Provider') + PATIENT = 'patient', _('Patient') + + USER_TYPE_CHOICES = [ + ('admin', _('Administrator')), + ('doctor', _('Doctor')), + ('nurse', _('Nurse')), + ('midwife', _('Midwife')), + ('community_worker', _('Community Health Worker')), + ('pharmacist', _('Pharmacist')), + ('lab_technician', _('Laboratory Technician')), + ] + + role = models.CharField( + max_length=20, + choices=Role.choices, + default=Role.PROVIDER, + help_text=_('High-level persona used for role-based access control.') + ) + user_type = models.CharField( + max_length=20, + choices=USER_TYPE_CHOICES, + default='community_worker' + ) + + phone_number = models.CharField( + max_length=15, + validators=[ + RegexValidator( + regex=r'^\+?1?\d{9,15}$', + message=_('Phone number must be entered in the format: +999999999. Up to 15 digits allowed.') + ) + ], + blank=True + ) + + date_of_birth = models.DateField(null=True, blank=True) + profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True) + + # Health worker specific fields + license_number = models.CharField(max_length=50, blank=True) + specialization = models.CharField(max_length=100, blank=True) + years_of_experience = models.PositiveIntegerField(default=0) + + class Meta: + verbose_name = _('User') + verbose_name_plural = _('Users') + + def __str__(self): + return f"{self.get_full_name()} ({self.get_user_type_display()})" + + @property + def is_admin_role(self): + """Check if user has admin role.""" + return self.role == self.Role.ADMIN + + @property + def is_provider_role(self): + """Check if user has provider role.""" + return self.role == self.Role.PROVIDER + + @property + def is_patient_role(self): + """Check if user has patient role.""" + return self.role == self.Role.PATIENT + + +class Location(models.Model): + """ + Geographic location model for health facilities and patients. + """ + LOCATION_TYPE_CHOICES = [ + ('country', _('Country')), + ('region', _('Region')), + ('district', _('District')), + ('subcounty', _('Sub-county')), + ('parish', _('Parish')), + ('village', _('Village')), + ] + + name = models.CharField(max_length=100) + location_type = models.CharField(max_length=20, choices=LOCATION_TYPE_CHOICES) + parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + + class Meta: + unique_together = ['name', 'parent', 'location_type'] + verbose_name = _('Location') + verbose_name_plural = _('Locations') + + def __str__(self): + if self.parent: + return f"{self.name}, {self.parent}" + return self.name + + +class HealthFacility(models.Model): + """ + Health facility model. + """ + FACILITY_TYPE_CHOICES = [ + ('hospital', _('Hospital')), + ('health_center', _('Health Center')), + ('clinic', _('Clinic')), + ('dispensary', _('Dispensary')), + ('laboratory', _('Laboratory')), + ('pharmacy', _('Pharmacy')), + ] + + name = models.CharField(max_length=200) + facility_type = models.CharField(max_length=20, choices=FACILITY_TYPE_CHOICES) + location = models.ForeignKey(Location, on_delete=models.CASCADE) + address = models.TextField() + phone_number = models.CharField(max_length=15) + email = models.EmailField(blank=True) + website = models.URLField(blank=True) + + # Operating hours + is_24_hours = models.BooleanField(default=False) + opening_time = models.TimeField(null=True, blank=True) + closing_time = models.TimeField(null=True, blank=True) + + # Services offered + services_offered = models.JSONField(default=list) + + # Contact person + contact_person_name = models.CharField(max_length=100) + contact_person_phone = models.CharField(max_length=15) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Health Facility') + verbose_name_plural = _('Health Facilities') + + def __str__(self): + return f"{self.name} ({self.get_facility_type_display()})" + + +class AuditTrail(models.Model): + """ + Audit trail model for tracking changes to health records. + """ + ACTION_CHOICES = [ + ('create', _('Create')), + ('update', _('Update')), + ('delete', _('Delete')), + ('view', _('View')), + ] + + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + action = models.CharField(max_length=10, choices=ACTION_CHOICES) + model_name = models.CharField(max_length=100) + object_id = models.CharField(max_length=100) + changes = models.JSONField(default=dict) + timestamp = models.DateTimeField(auto_now_add=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True) + + class Meta: + verbose_name = _('Audit Trail') + verbose_name_plural = _('Audit Trails') + ordering = ['-timestamp'] + + def __str__(self): + return f"{self.action} on {self.model_name} by {self.user} at {self.timestamp}" + + +class SystemConfiguration(models.Model): + """ + System configuration model for storing application settings. + """ + key = models.CharField(max_length=100, unique=True) + value = models.TextField() + description = models.TextField(blank=True) + is_public = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('System Configuration') + verbose_name_plural = _('System Configurations') + + def __str__(self): + return self.key diff --git a/apps/core/serializers.py b/apps/core/serializers.py new file mode 100644 index 0000000..ed89085 --- /dev/null +++ b/apps/core/serializers.py @@ -0,0 +1,182 @@ +""" +Core serializers for OpenCare-Africa health system. +""" + +from rest_framework import serializers +from django.contrib.auth import get_user_model +from .models import Location, HealthFacility, AuditTrail, SystemConfiguration + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + """ + Serializer for User model. + """ + full_name = serializers.SerializerMethodField() + user_type_display = serializers.CharField(source='get_user_type_display', read_only=True) + + class Meta: + model = User + fields = [ + 'id', 'username', 'email', 'first_name', 'last_name', 'full_name', + 'user_type', 'user_type_display', 'phone_number', 'date_of_birth', + 'profile_picture', 'license_number', 'specialization', 'years_of_experience', + 'is_active', 'date_joined', 'last_login' + ] + read_only_fields = ['id', 'date_joined', 'last_login'] + extra_kwargs = { + 'password': {'write_only': True} + } + + def get_full_name(self, obj): + return obj.get_full_name() + + def create(self, validated_data): + password = validated_data.pop('password', None) + user = super().create(validated_data) + if password: + user.set_password(password) + user.save() + return user + + +class UserCreateSerializer(serializers.ModelSerializer): + """ + Serializer for creating new users. + """ + password = serializers.CharField(write_only=True) + password_confirm = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = [ + 'username', 'email', 'first_name', 'last_name', 'password', + 'password_confirm', 'user_type', 'phone_number', 'date_of_birth' + ] + + def validate(self, attrs): + if attrs['password'] != attrs['password_confirm']: + raise serializers.ValidationError("Passwords don't match") + return attrs + + def create(self, validated_data): + validated_data.pop('password_confirm') + password = validated_data.pop('password') + user = User.objects.create_user(**validated_data) + user.set_password(password) + user.save() + return user + + +class LocationSerializer(serializers.ModelSerializer): + """ + Serializer for Location model. + """ + location_type_display = serializers.CharField(source='get_location_type_display', read_only=True) + parent_name = serializers.CharField(source='parent.name', read_only=True) + + class Meta: + model = Location + fields = [ + 'id', 'name', 'location_type', 'location_type_display', + 'parent', 'parent_name', 'latitude', 'longitude' + ] + + def to_representation(self, instance): + representation = super().to_representation(instance) + if instance.parent: + representation['full_location'] = f"{instance.name}, {instance.parent}" + else: + representation['full_location'] = instance.name + return representation + + +class LocationTreeSerializer(serializers.ModelSerializer): + """ + Serializer for hierarchical location display. + """ + children = serializers.SerializerMethodField() + + class Meta: + model = Location + fields = ['id', 'name', 'location_type', 'children'] + + def get_children(self, obj): + children = Location.objects.filter(parent=obj) + return LocationTreeSerializer(children, many=True).data + + +class HealthFacilitySerializer(serializers.ModelSerializer): + """ + Serializer for HealthFacility model. + """ + facility_type_display = serializers.CharField(source='get_facility_type_display', read_only=True) + location_name = serializers.CharField(source='location.name', read_only=True) + services_count = serializers.SerializerMethodField() + + class Meta: + model = HealthFacility + fields = [ + 'id', 'name', 'facility_type', 'facility_type_display', + 'location', 'location_name', 'address', 'phone_number', + 'email', 'website', 'is_24_hours', 'opening_time', + 'closing_time', 'services_offered', 'contact_person_name', + 'contact_person_phone', 'services_count', 'created_at', 'updated_at' + ] + read_only_fields = ['created_at', 'updated_at'] + + def get_services_count(self, obj): + return len(obj.services_offered) if obj.services_offered else 0 + + +class HealthFacilityDetailSerializer(HealthFacilitySerializer): + """ + Detailed serializer for HealthFacility with related data. + """ + location_detail = LocationSerializer(source='location', read_only=True) + + class Meta(HealthFacilitySerializer.Meta): + fields = HealthFacilitySerializer.Meta.fields + ['location_detail'] + + +class AuditTrailSerializer(serializers.ModelSerializer): + """ + Serializer for AuditTrail model. + """ + user_name = serializers.CharField(source='user.get_full_name', read_only=True) + action_display = serializers.CharField(source='get_action_display', read_only=True) + + class Meta: + model = AuditTrail + fields = [ + 'id', 'user', 'user_name', 'action', 'action_display', + 'model_name', 'object_id', 'changes', 'timestamp', + 'ip_address', 'user_agent' + ] + read_only_fields = ['id', 'timestamp'] + + +class SystemConfigurationSerializer(serializers.ModelSerializer): + """ + Serializer for SystemConfiguration model. + """ + class Meta: + model = SystemConfiguration + fields = ['id', 'key', 'value', 'description', 'is_public', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class DashboardStatsSerializer(serializers.Serializer): + """ + Serializer for dashboard statistics. + """ + total_patients = serializers.IntegerField() + total_health_workers = serializers.IntegerField() + total_facilities = serializers.IntegerField() + total_visits_today = serializers.IntegerField() + active_outbreaks = serializers.IntegerField() + system_health = serializers.CharField() + last_backup = serializers.DateTimeField() + database_size = serializers.CharField() + storage_usage = serializers.CharField() diff --git a/apps/core/urls.py b/apps/core/urls.py new file mode 100644 index 0000000..568d7af --- /dev/null +++ b/apps/core/urls.py @@ -0,0 +1,41 @@ +""" +URL configuration for core app. +""" + +from django.urls import path +from . import views + +app_name = 'core' + +urlpatterns = [ + # Dashboard + path('', views.dashboard, name='dashboard'), + path('dashboard/', views.dashboard, name='dashboard'), + + # Home page + path('home/', views.home, name='home'), + + # System information + path('system-info/', views.system_info, name='system_info'), + path('health-status/', views.health_status, name='health_status'), + + # User management + path('profile/', views.user_profile, name='user_profile'), + path('profile/edit/', views.edit_profile, name='edit_profile'), + + # Location management + path('locations/', views.location_list, name='location_list'), + path('locations//', views.location_detail, name='location_detail'), + + # Facility management + path('facilities/', views.facility_list, name='facility_list'), + path('facilities//', views.facility_detail, name='facility_detail'), + + # Reports and analytics + path('reports/', views.reports, name='reports'), + path('analytics/', views.analytics, name='analytics'), + + # Settings + path('settings/', views.settings_view, name='settings'), + path('settings/system/', views.system_settings, name='system_settings'), +] diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..d5e08ec --- /dev/null +++ b/apps/core/views.py @@ -0,0 +1,217 @@ +""" +Core views for OpenCare-Africa health system. +""" + +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.utils.decorators import method_decorator +from django.views import View +from django.contrib.auth.mixins import LoginRequiredMixin + + +def home(request): + """Home page view.""" + context = { + 'title': 'OpenCare-Africa - Health Informatics Platform', + 'description': 'Empowering health systems across Africa with innovative technology solutions.', + } + return render(request, 'core/home.html', context) + + +@login_required +def dashboard(request): + """Dashboard view for authenticated users.""" + context = { + 'title': 'Dashboard - OpenCare-Africa', + 'user': request.user, + } + return render(request, 'core/dashboard.html', context) + + +def about(request): + """About page view.""" + context = { + 'title': 'About - OpenCare-Africa', + 'description': 'Learn more about our mission to improve healthcare in Africa.', + } + return render(request, 'core/about.html', context) + + +def contact(request): + """Contact page view.""" + context = { + 'title': 'Contact - OpenCare-Africa', + 'description': 'Get in touch with our team.', + } + return render(request, 'core/contact.html', context) + + +@require_http_methods(["GET"]) +def health_check(request): + """Health check endpoint for monitoring.""" + return JsonResponse({ + 'status': 'healthy', + 'service': 'OpenCare-Africa', + 'version': '1.0.0', + 'timestamp': '2024-01-01T00:00:00Z' + }) + + +class HealthMetricsView(LoginRequiredMixin, View): + """View for displaying health system metrics.""" + + def get(self, request): + """Get health metrics data.""" + # This would typically fetch data from various models + metrics = { + 'total_patients': 0, # Patient.objects.count() + 'total_health_workers': 0, # User.objects.filter(user_type__in=['doctor', 'nurse', 'midwife']).count() + 'total_facilities': 0, # HealthFacility.objects.count() + 'active_visits': 0, # PatientVisit.objects.filter(status='in_progress').count() + } + + context = { + 'title': 'Health Metrics - OpenCare-Africa', + 'metrics': metrics, + } + return render(request, 'core/health_metrics.html', context) + + +class SystemStatusView(LoginRequiredMixin, View): + """View for displaying system status.""" + + def get(self, request): + """Get system status information.""" + # This would typically check various system components + system_status = { + 'database': 'healthy', + 'cache': 'healthy', + 'celery': 'healthy', + 'external_apis': 'healthy', + } + + context = { + 'title': 'System Status - OpenCare-Africa', + 'system_status': system_status, + } + return render(request, 'core/system_status.html', context) + + +@require_http_methods(["GET"]) +def system_info(request): + """System information endpoint.""" + return JsonResponse({ + 'name': 'OpenCare-Africa', + 'version': '1.0.0', + 'description': 'Health Informatics Platform for Africa', + 'features': [ + 'Patient Management', + 'Health Worker Management', + 'Facility Management', + 'Health Records', + 'Analytics & Reporting' + ] + }) + + +@require_http_methods(["GET"]) +def health_status(request): + """Health status endpoint.""" + return JsonResponse({ + 'status': 'healthy', + 'service': 'OpenCare-Africa', + 'version': '1.0.0', + 'timestamp': '2024-01-01T00:00:00Z' + }) + + +@login_required +def user_profile(request): + """User profile view.""" + context = { + 'title': 'User Profile - OpenCare-Africa', + 'user': request.user, + } + return render(request, 'core/user_profile.html', context) + + +@login_required +def edit_profile(request): + """Edit user profile view.""" + context = { + 'title': 'Edit Profile - OpenCare-Africa', + 'user': request.user, + } + return render(request, 'core/edit_profile.html', context) + + +def location_list(request): + """Location list view.""" + context = { + 'title': 'Locations - OpenCare-Africa', + } + return render(request, 'core/location_list.html', context) + + +def location_detail(request, pk): + """Location detail view.""" + context = { + 'title': 'Location Details - OpenCare-Africa', + 'location_id': pk, + } + return render(request, 'core/location_detail.html', context) + + +def facility_list(request): + """Facility list view.""" + context = { + 'title': 'Health Facilities - OpenCare-Africa', + } + return render(request, 'core/facility_list.html', context) + + +def facility_detail(request, pk): + """Facility detail view.""" + context = { + 'title': 'Facility Details - OpenCare-Africa', + 'facility_id': pk, + } + return render(request, 'core/facility_detail.html', context) + + +@login_required +def reports(request): + """Reports view.""" + context = { + 'title': 'Reports - OpenCare-Africa', + } + return render(request, 'core/reports.html', context) + + +@login_required +def analytics(request): + """Analytics view.""" + context = { + 'title': 'Analytics - OpenCare-Africa', + } + return render(request, 'core/analytics.html', context) + + +@login_required +def settings_view(request): + """Settings view.""" + context = { + 'title': 'Settings - OpenCare-Africa', + } + return render(request, 'core/settings.html', context) + + +@login_required +def system_settings(request): + """System settings view.""" + context = { + 'title': 'System Settings - OpenCare-Africa', + } + return render(request, 'core/system_settings.html', context) diff --git a/apps/facilities/__init__.py b/apps/facilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/facilities/models.py b/apps/facilities/models.py new file mode 100644 index 0000000..92106e4 --- /dev/null +++ b/apps/facilities/models.py @@ -0,0 +1,233 @@ +""" +Facility models for OpenCare-Africa health system. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.core.models import HealthFacility, Location, User + + +class FacilityService(models.Model): + """ + Model for managing services offered by health facilities. + """ + SERVICE_CATEGORY_CHOICES = [ + ('primary_care', _('Primary Care')), + ('emergency_care', _('Emergency Care')), + ('specialized_care', _('Specialized Care')), + ('diagnostic', _('Diagnostic Services')), + ('pharmacy', _('Pharmacy Services')), + ('laboratory', _('Laboratory Services')), + ('maternity', _('Maternity Services')), + ('pediatric', _('Pediatric Services')), + ('mental_health', _('Mental Health Services')), + ('rehabilitation', _('Rehabilitation Services')), + ] + + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE, related_name='services') + name = models.CharField(max_length=200) + category = models.CharField(max_length=30, choices=SERVICE_CATEGORY_CHOICES) + description = models.TextField() + + # Service availability + is_available = models.BooleanField(default=True) + availability_schedule = models.JSONField(default=dict) # Store schedule as JSON + + # Service details + cost = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + duration_minutes = models.PositiveIntegerField(null=True, blank=True) + requirements = models.TextField(blank=True) + + # Staff requirements + required_staff_count = models.PositiveIntegerField(default=1) + required_qualifications = models.JSONField(default=list) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Facility Service') + verbose_name_plural = _('Facility Services') + unique_together = ['facility', 'name'] + ordering = ['category', 'name'] + + def __str__(self): + return f"{self.name} at {self.facility}" + + +class FacilityStaff(models.Model): + """ + Model for managing staff at health facilities. + """ + staff = models.ForeignKey(User, on_delete=models.CASCADE) + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE) + + # Employment details + position = models.CharField(max_length=100) + department = models.CharField(max_length=100, blank=True) + employment_type = models.CharField(max_length=20, choices=[ + ('full_time', _('Full Time')), + ('part_time', _('Part Time')), + ('contract', _('Contract')), + ('volunteer', _('Volunteer')), + ]) + + # Work schedule + work_schedule = models.JSONField(default=dict) + is_active = models.BooleanField(default=True) + + # Employment dates + hire_date = models.DateField() + termination_date = models.DateField(null=True, blank=True) + + # Additional information + supervisor = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True) + notes = models.TextField(blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Facility Staff') + verbose_name_plural = _('Facility Staff') + unique_together = ['staff', 'facility'] + ordering = ['facility', 'department', 'position'] + + def __str__(self): + return f"{self.staff} - {self.position} at {self.facility}" + + +class FacilityEquipment(models.Model): + """ + Model for managing medical equipment and resources at facilities. + """ + EQUIPMENT_STATUS_CHOICES = [ + ('operational', _('Operational')), + ('maintenance', _('Under Maintenance')), + ('repair', _('Under Repair')), + ('out_of_order', _('Out of Order')), + ('retired', _('Retired')), + ] + + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE, related_name='equipment') + name = models.CharField(max_length=200) + model = models.CharField(max_length=100, blank=True) + serial_number = models.CharField(max_length=100, blank=True) + + # Equipment details + category = models.CharField(max_length=100) + manufacturer = models.CharField(max_length=100, blank=True) + purchase_date = models.DateField(null=True, blank=True) + warranty_expiry = models.DateField(null=True, blank=True) + + # Status and maintenance + status = models.CharField(max_length=20, choices=EQUIPMENT_STATUS_CHOICES, default='operational') + last_maintenance = models.DateField(null=True, blank=True) + next_maintenance = models.DateField(null=True, blank=True) + + # Location within facility + location_in_facility = models.CharField(max_length=100, blank=True) + assigned_to = models.ForeignKey(FacilityStaff, on_delete=models.SET_NULL, null=True, blank=True) + + # Cost information + purchase_cost = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + maintenance_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Facility Equipment') + verbose_name_plural = _('Facility Equipment') + ordering = ['facility', 'category', 'name'] + + def __str__(self): + return f"{self.name} ({self.get_status_display()}) at {self.facility}" + + +class FacilityInventory(models.Model): + """ + Model for managing medical supplies and inventory at facilities. + """ + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE, related_name='inventory') + item_name = models.CharField(max_length=200) + category = models.CharField(max_length=100) + + # Quantity and units + current_quantity = models.PositiveIntegerField(default=0) + minimum_quantity = models.PositiveIntegerField(default=0) + unit = models.CharField(max_length=20) + + # Stock information + supplier = models.CharField(max_length=100, blank=True) + batch_number = models.CharField(max_length=100, blank=True) + expiry_date = models.DateField(null=True, blank=True) + + # Cost information + unit_cost = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + total_value = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + + # Storage location + storage_location = models.CharField(max_length=100, blank=True) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Facility Inventory') + verbose_name_plural = _('Facility Inventory') + unique_together = ['facility', 'item_name', 'batch_number'] + ordering = ['facility', 'category', 'item_name'] + + def __str__(self): + return f"{self.item_name} ({self.current_quantity} {self.unit}) at {self.facility}" + + def save(self, *args, **kwargs): + # Calculate total value + if self.unit_cost and self.current_quantity: + self.total_value = self.unit_cost * self.current_quantity + super().save(*args, **kwargs) + + +class FacilitySchedule(models.Model): + """ + Model for managing facility operating schedules and appointments. + """ + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE, related_name='schedules') + service = models.ForeignKey(FacilityService, on_delete=models.CASCADE, null=True, blank=True) + + # Schedule details + day_of_week = models.PositiveIntegerField(choices=[ + (0, _('Monday')), + (1, _('Tuesday')), + (2, _('Wednesday')), + (3, _('Thursday')), + (4, _('Friday')), + (5, _('Saturday')), + (6, _('Sunday')), + ]) + start_time = models.TimeField() + end_time = models.TimeField() + + # Availability + is_available = models.BooleanField(default=True) + max_appointments = models.PositiveIntegerField(null=True, blank=True) + current_appointments = models.PositiveIntegerField(default=0) + + # Staff assignment + assigned_staff = models.ManyToManyField(FacilityStaff, blank=True) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Facility Schedule') + verbose_name_plural = _('Facility Schedules') + unique_together = ['facility', 'day_of_week', 'start_time'] + ordering = ['facility', 'day_of_week', 'start_time'] + + def __str__(self): + return f"{self.facility} - {self.get_day_of_week_display()} {self.start_time}-{self.end_time}" diff --git a/apps/health_workers/__init__.py b/apps/health_workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/health_workers/models.py b/apps/health_workers/models.py new file mode 100644 index 0000000..187febf --- /dev/null +++ b/apps/health_workers/models.py @@ -0,0 +1,242 @@ +""" +Health Workers models for OpenCare-Africa health system. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.core.models import User, HealthFacility, Location + + +class HealthWorkerProfile(models.Model): + """ + Extended profile for health workers with additional professional information. + """ + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='health_worker_profile') + + # Professional Information + license_number = models.CharField(max_length=50, unique=True) + specialization = models.CharField(max_length=100, blank=True) + sub_specialization = models.CharField(max_length=100, blank=True) + years_of_experience = models.PositiveIntegerField(default=0) + + # Employment Details + primary_facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE, related_name='primary_staff') + secondary_facilities = models.ManyToManyField(HealthFacility, blank=True, related_name='secondary_staff') + + # Professional Status + is_licensed = models.BooleanField(default=True) + license_expiry_date = models.DateField(null=True, blank=True) + is_active_practitioner = models.BooleanField(default=True) + + # Contact Information + emergency_contact_name = models.CharField(max_length=100, blank=True) + emergency_contact_phone = models.CharField(max_length=15, blank=True) + emergency_contact_relationship = models.CharField(max_length=50, blank=True) + + # Additional Information + bio = models.TextField(blank=True) + languages_spoken = models.JSONField(default=list) + certifications = models.JSONField(default=list) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Health Worker Profile') + verbose_name_plural = _('Health Worker Profiles') + ordering = ['user__last_name', 'user__first_name'] + + def __str__(self): + return f"{self.user.get_full_name()} - {self.specialization}" + + +class ProfessionalQualification(models.Model): + """ + Model for storing professional qualifications and certifications. + """ + health_worker = models.ForeignKey(HealthWorkerProfile, on_delete=models.CASCADE, related_name='qualifications') + + # Qualification Details + qualification_type = models.CharField(max_length=100, choices=[ + ('degree', _('Academic Degree')), + ('diploma', _('Diploma')), + ('certification', _('Professional Certification')), + ('license', _('Professional License')), + ('training', _('Training Program')), + ]) + + title = models.CharField(max_length=200) + institution = models.CharField(max_length=200) + country = models.CharField(max_length=100) + + # Dates + start_date = models.DateField() + completion_date = models.DateField() + expiry_date = models.DateField(null=True, blank=True) + + # Additional Information + grade = models.CharField(max_length=20, blank=True) + description = models.TextField(blank=True) + certificate_file = models.FileField(upload_to='qualifications/', null=True, blank=True) + + is_verified = models.BooleanField(default=False) + verified_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + verification_date = models.DateField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Professional Qualification') + verbose_name_plural = _('Professional Qualifications') + ordering = ['-completion_date'] + + def __str__(self): + return f"{self.title} - {self.health_worker.user.get_full_name()}" + + +class WorkSchedule(models.Model): + """ + Model for managing health worker work schedules. + """ + health_worker = models.ForeignKey(HealthWorkerProfile, on_delete=models.CASCADE, related_name='work_schedules') + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE) + + # Schedule Details + day_of_week = models.PositiveIntegerField(choices=[ + (0, _('Monday')), + (1, _('Tuesday')), + (2, _('Wednesday')), + (3, _('Thursday')), + (4, _('Friday')), + (5, _('Saturday')), + (6, _('Sunday')), + ]) + + start_time = models.TimeField() + end_time = models.TimeField() + + # Schedule Type + schedule_type = models.CharField(max_length=20, choices=[ + ('regular', _('Regular Schedule')), + ('on_call', _('On-Call')), + ('overtime', _('Overtime')), + ('emergency', _('Emergency Coverage')), + ], default='regular') + + # Availability + is_available = models.BooleanField(default=True) + notes = models.TextField(blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Work Schedule') + verbose_name_plural = _('Work Schedules') + unique_together = ['health_worker', 'facility', 'day_of_week', 'start_time'] + ordering = ['health_worker', 'day_of_week', 'start_time'] + + def __str__(self): + return f"{self.health_worker.user.get_full_name()} - {self.get_day_of_week_display()} {self.start_time}-{self.end_time}" + + +class ProfessionalDevelopment(models.Model): + """ + Model for tracking professional development activities. + """ + health_worker = models.ForeignKey(HealthWorkerProfile, on_delete=models.CASCADE, related_name='professional_development') + + # Activity Details + activity_type = models.CharField(max_length=50, choices=[ + ('training', _('Training Program')), + ('conference', _('Conference/Workshop')), + ('seminar', _('Seminar')), + ('online_course', _('Online Course')), + ('research', _('Research Project')), + ('publication', _('Publication')), + ('presentation', _('Presentation')), + ]) + + title = models.CharField(max_length=200) + description = models.TextField() + organizer = models.CharField(max_length=200) + + # Dates and Duration + start_date = models.DateField() + end_date = models.DateField() + duration_hours = models.PositiveIntegerField(null=True, blank=True) + + # Outcomes + certificate_received = models.BooleanField(default=False) + certificate_file = models.FileField(upload_to='professional_development/', null=True, blank=True) + credits_earned = models.PositiveIntegerField(null=True, blank=True) + + # Cost and Funding + cost = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + funding_source = models.CharField(max_length=100, blank=True) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Professional Development') + verbose_name_plural = _('Professional Development') + ordering = ['-start_date'] + + def __str__(self): + return f"{self.title} - {self.health_worker.user.get_full_name()}" + + +class PerformanceEvaluation(models.Model): + """ + Model for tracking health worker performance evaluations. + """ + health_worker = models.ForeignKey(HealthWorkerProfile, on_delete=models.CASCADE, related_name='performance_evaluations') + evaluator = models.ForeignKey(User, on_delete=models.CASCADE, related_name='evaluations_given') + + # Evaluation Details + evaluation_date = models.DateField() + evaluation_period_start = models.DateField() + evaluation_period_end = models.DateField() + + # Performance Ratings (1-5 scale) + clinical_skills = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)]) + communication_skills = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)]) + teamwork = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)]) + professionalism = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)]) + productivity = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)]) + + # Overall Rating + overall_rating = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)]) + + # Feedback + strengths = models.TextField() + areas_for_improvement = models.TextField() + goals = models.TextField() + + # Action Items + action_items = models.JSONField(default=list) + follow_up_date = models.DateField(null=True, blank=True) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Performance Evaluation') + verbose_name_plural = _('Performance Evaluations') + ordering = ['-evaluation_date'] + + def __str__(self): + return f"Performance Evaluation - {self.health_worker.user.get_full_name()} ({self.evaluation_date})" + + def save(self, *args, **kwargs): + # Calculate overall rating as average of individual ratings + ratings = [ + self.clinical_skills, self.communication_skills, self.teamwork, + self.professionalism, self.productivity + ] + self.overall_rating = sum(ratings) // len(ratings) + super().save(*args, **kwargs) diff --git a/apps/patients/__init__.py b/apps/patients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/patients/admin.py b/apps/patients/admin.py new file mode 100644 index 0000000..2ed539a --- /dev/null +++ b/apps/patients/admin.py @@ -0,0 +1,229 @@ +""" +Admin configuration for patient models. +""" + +from django.contrib import admin +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ +from .models import Patient, PatientVisit, PatientMedicalHistory + + +@admin.register(Patient) +class PatientAdmin(admin.ModelAdmin): + """ + Admin interface for Patient model. + """ + list_display = [ + 'patient_id', 'first_name', 'last_name', 'gender', 'age', + 'phone_number', 'location', 'registered_facility', 'is_active', + 'registration_date' + ] + list_filter = [ + 'gender', 'marital_status', 'blood_type', 'is_active', + 'location', 'registered_facility', 'registration_date' + ] + search_fields = [ + 'patient_id', 'first_name', 'last_name', 'phone_number', + 'email', 'emergency_contact_name' + ] + ordering = ['-registration_date'] + list_per_page = 25 + + fieldsets = ( + (_('Patient Information'), { + 'fields': ( + 'patient_id', 'first_name', 'last_name', 'middle_name', + 'date_of_birth', 'gender', 'marital_status' + ) + }), + (_('Contact Information'), { + 'fields': ( + 'phone_number', 'email', 'address', 'location' + ) + }), + (_('Emergency Contact'), { + 'fields': ( + 'emergency_contact_name', 'emergency_contact_phone', + 'emergency_contact_relationship' + ) + }), + (_('Medical Information'), { + 'fields': ( + 'blood_type', 'allergies', 'chronic_conditions', + 'current_medications' + ) + }), + (_('Registration'), { + 'fields': ( + 'registered_facility', 'is_active', 'registration_date' + ) + }), + (_('Additional Information'), { + 'fields': ( + 'occupation', 'education_level', 'religion', 'ethnicity' + ), + 'classes': ('collapse',) + }), + (_('Insurance & Payment'), { + 'fields': ( + 'insurance_provider', 'insurance_number', 'payment_method' + ), + 'classes': ('collapse',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ['patient_id', 'registration_date', 'created_at', 'updated_at'] + + def age(self, obj): + return obj.get_age() + age.short_description = _('Age') + + def get_queryset(self, request): + return super().get_queryset(request).select_related('location', 'registered_facility') + + +@admin.register(PatientVisit) +class PatientVisitAdmin(admin.ModelAdmin): + """ + Admin interface for PatientVisit model. + """ + list_display = [ + 'patient', 'facility', 'visit_type', 'status', 'scheduled_date', + 'attending_provider', 'payment_status' + ] + list_filter = [ + 'visit_type', 'status', 'facility', 'scheduled_date', + 'payment_status', 'created_at' + ] + search_fields = [ + 'patient__first_name', 'patient__last_name', 'patient__patient_id', + 'facility__name', 'attending_provider__username' + ] + ordering = ['-scheduled_date'] + list_per_page = 25 + + fieldsets = ( + (_('Visit Information'), { + 'fields': ( + 'patient', 'facility', 'visit_type', 'status' + ) + }), + (_('Schedule'), { + 'fields': ('scheduled_date', 'actual_date') + }), + (_('Clinical Information'), { + 'fields': ( + 'chief_complaint', 'diagnosis', 'treatment_plan', + 'prescription' + ) + }), + (_('Healthcare Provider'), { + 'fields': ('attending_provider',) + }), + (_('Financial'), { + 'fields': ( + 'consultation_fee', 'total_cost', 'payment_status' + ) + }), + (_('Notes'), { + 'fields': ('notes',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ['created_at', 'updated_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + 'patient', 'facility', 'attending_provider' + ) + + +@admin.register(PatientMedicalHistory) +class PatientMedicalHistoryAdmin(admin.ModelAdmin): + """ + Admin interface for PatientMedicalHistory model. + """ + list_display = [ + 'patient', 'condition', 'diagnosis_date', 'severity', + 'is_active', 'facility', 'diagnosed_by' + ] + list_filter = [ + 'severity', 'is_active', 'facility', 'diagnosis_date', + 'created_at' + ] + search_fields = [ + 'patient__first_name', 'patient__last_name', 'patient__patient_id', + 'condition', 'facility__name', 'diagnosed_by__username' + ] + ordering = ['-diagnosis_date'] + list_per_page = 25 + + fieldsets = ( + (_('Patient & Condition'), { + 'fields': ( + 'patient', 'condition', 'diagnosis_date', 'severity', + 'is_active' + ) + }), + (_('Treatment Information'), { + 'fields': ( + 'treatment', 'medications', 'outcomes' + ) + }), + (_('Healthcare Provider'), { + 'fields': ('diagnosed_by', 'facility') + }), + (_('Notes'), { + 'fields': ('notes',) + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ['created_at', 'updated_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related( + 'patient', 'facility', 'diagnosed_by' + ) + + +# Inline admin for related models +class PatientVisitInline(admin.TabularInline): + """ + Inline admin for PatientVisit in Patient admin. + """ + model = PatientVisit + extra = 0 + readonly_fields = ['created_at'] + fields = ['visit_type', 'status', 'scheduled_date', 'facility', 'attending_provider'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('facility', 'attending_provider') + + +class PatientMedicalHistoryInline(admin.TabularInline): + """ + Inline admin for PatientMedicalHistory in Patient admin. + """ + model = PatientMedicalHistory + extra = 0 + readonly_fields = ['created_at'] + fields = ['condition', 'diagnosis_date', 'severity', 'is_active', 'facility'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('facility') + + +# Add inlines to Patient admin +PatientAdmin.inlines = [PatientVisitInline, PatientMedicalHistoryInline] diff --git a/apps/patients/apps.py b/apps/patients/apps.py new file mode 100644 index 0000000..3e1e9c4 --- /dev/null +++ b/apps/patients/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PatientsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.patients' + verbose_name = 'Patients' diff --git a/apps/patients/models.py b/apps/patients/models.py new file mode 100644 index 0000000..4d5f293 --- /dev/null +++ b/apps/patients/models.py @@ -0,0 +1,201 @@ +""" +Patient models for OpenCare-Africa health system. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.core.validators import RegexValidator +from apps.core.models import Location, HealthFacility + + +class Patient(models.Model): + """ + Patient model for storing patient information. + """ + GENDER_CHOICES = [ + ('M', _('Male')), + ('F', _('Female')), + ('O', _('Other')), + ] + + MARITAL_STATUS_CHOICES = [ + ('single', _('Single')), + ('married', _('Married')), + ('divorced', _('Divorced')), + ('widowed', _('Widowed')), + ] + + BLOOD_TYPE_CHOICES = [ + ('A+', 'A+'), + ('A-', 'A-'), + ('B+', 'B+'), + ('B-', 'B-'), + ('AB+', 'AB+'), + ('AB-', 'AB-'), + ('O+', 'O+'), + ('O-', 'O-'), + ] + + # Basic Information + patient_id = models.CharField(max_length=20, unique=True) + first_name = models.CharField(max_length=50) + last_name = models.CharField(max_length=50) + middle_name = models.CharField(max_length=50, blank=True) + date_of_birth = models.DateField() + gender = models.CharField(max_length=1, choices=GENDER_CHOICES) + marital_status = models.CharField(max_length=20, choices=MARITAL_STATUS_CHOICES, blank=True) + + # Contact Information + phone_number = models.CharField( + max_length=15, + validators=[ + RegexValidator( + regex=r'^\+?1?\d{9,15}$', + message=_('Phone number must be entered in the format: +999999999. Up to 15 digits allowed.') + ) + ] + ) + email = models.EmailField(blank=True) + address = models.TextField() + location = models.ForeignKey(Location, on_delete=models.CASCADE) + + # Emergency Contact + emergency_contact_name = models.CharField(max_length=100) + emergency_contact_phone = models.CharField(max_length=15) + emergency_contact_relationship = models.CharField(max_length=50) + + # Medical Information + blood_type = models.CharField(max_length=3, choices=BLOOD_TYPE_CHOICES, blank=True) + allergies = models.JSONField(default=list) + chronic_conditions = models.JSONField(default=list) + current_medications = models.JSONField(default=list) + + # Insurance & Financial + insurance_provider = models.CharField(max_length=100, blank=True) + insurance_number = models.CharField(max_length=50, blank=True) + payment_method = models.CharField(max_length=50, blank=True) + + # Registration + registered_facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE) + registration_date = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + # Additional Information + occupation = models.CharField(max_length=100, blank=True) + education_level = models.CharField(max_length=50, blank=True) + religion = models.CharField(max_length=50, blank=True) + ethnicity = models.CharField(max_length=50, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Patient') + verbose_name_plural = _('Patients') + ordering = ['-registration_date'] + + def __str__(self): + return f"{self.first_name} {self.last_name} ({self.patient_id})" + + def get_full_name(self): + if self.middle_name: + return f"{self.first_name} {self.middle_name} {self.last_name}" + return f"{self.first_name} {self.last_name}" + + def get_age(self): + from datetime import date + today = date.today() + return today.year - self.date_of_birth.year - ((today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)) + + +class PatientVisit(models.Model): + """ + Model for tracking patient visits to health facilities. + """ + VISIT_TYPE_CHOICES = [ + ('consultation', _('Consultation')), + ('emergency', _('Emergency')), + ('follow_up', _('Follow-up')), + ('vaccination', _('Vaccination')), + ('laboratory', _('Laboratory Test')), + ('pharmacy', _('Pharmacy')), + ] + + STATUS_CHOICES = [ + ('scheduled', _('Scheduled')), + ('in_progress', _('In Progress')), + ('completed', _('Completed')), + ('cancelled', _('Cancelled')), + ('no_show', _('No Show')), + ] + + patient = models.ForeignKey(Patient, on_delete=models.CASCADE) + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE) + visit_type = models.CharField(max_length=20, choices=VISIT_TYPE_CHOICES) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='scheduled') + + # Visit Details + scheduled_date = models.DateTimeField() + actual_date = models.DateTimeField(null=True, blank=True) + chief_complaint = models.TextField(blank=True) + diagnosis = models.TextField(blank=True) + treatment_plan = models.TextField(blank=True) + prescription = models.JSONField(default=list) + + # Healthcare Provider + attending_provider = models.ForeignKey('core.User', on_delete=models.SET_NULL, null=True, blank=True) + + # Financial + consultation_fee = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + total_cost = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + payment_status = models.CharField(max_length=20, default='pending') + + # Notes + notes = models.TextField(blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Patient Visit') + verbose_name_plural = _('Patient Visits') + ordering = ['-scheduled_date'] + + def __str__(self): + return f"{self.patient} - {self.get_visit_type_display()} at {self.facility}" + + +class PatientMedicalHistory(models.Model): + """ + Model for storing patient medical history. + """ + patient = models.ForeignKey(Patient, on_delete=models.CASCADE) + condition = models.CharField(max_length=200) + diagnosis_date = models.DateField() + is_active = models.BooleanField(default=True) + severity = models.CharField(max_length=20, choices=[ + ('mild', _('Mild')), + ('moderate', _('Moderate')), + ('severe', _('Severe')), + ]) + + # Treatment Information + treatment = models.TextField(blank=True) + medications = models.JSONField(default=list) + outcomes = models.TextField(blank=True) + + # Healthcare Provider + diagnosed_by = models.ForeignKey('core.User', on_delete=models.SET_NULL, null=True, blank=True) + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Patient Medical History') + verbose_name_plural = _('Patient Medical Histories') + ordering = ['-diagnosis_date'] + + def __str__(self): + return f"{self.patient} - {self.condition} ({self.diagnosis_date})" diff --git a/apps/patients/serializers.py b/apps/patients/serializers.py new file mode 100644 index 0000000..cacbe41 --- /dev/null +++ b/apps/patients/serializers.py @@ -0,0 +1,202 @@ +""" +Patient serializers for OpenCare-Africa health system. +""" + +from rest_framework import serializers +from django.utils.crypto import get_random_string +from .models import Patient, PatientVisit, PatientMedicalHistory +from apps.core.serializers import LocationSerializer, HealthFacilitySerializer, UserSerializer + + +class PatientSerializer(serializers.ModelSerializer): + """ + Serializer for Patient model. + """ + gender_display = serializers.CharField(source='get_gender_display', read_only=True) + marital_status_display = serializers.CharField(source='get_marital_status_display', read_only=True) + blood_type_display = serializers.CharField(source='get_blood_type_display', read_only=True) + location_name = serializers.CharField(source='location.name', read_only=True) + facility_name = serializers.CharField(source='registered_facility.name', read_only=True) + age = serializers.SerializerMethodField() + + class Meta: + model = Patient + fields = [ + 'id', 'patient_id', 'first_name', 'last_name', 'middle_name', + 'date_of_birth', 'age', 'gender', 'gender_display', 'marital_status', + 'marital_status_display', 'phone_number', 'email', 'address', + 'location', 'location_name', 'emergency_contact_name', + 'emergency_contact_phone', 'emergency_contact_relationship', + 'blood_type', 'blood_type_display', 'allergies', 'chronic_conditions', + 'current_medications', 'insurance_provider', 'insurance_number', + 'payment_method', 'registered_facility', 'facility_name', + 'registration_date', 'is_active', 'occupation', 'education_level', + 'religion', 'ethnicity', 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'patient_id', 'registration_date', 'created_at', 'updated_at'] + + def get_age(self, obj): + return obj.get_age() + + +class PatientDetailSerializer(PatientSerializer): + """ + Detailed serializer for Patient with related data. + """ + location_detail = LocationSerializer(source='location', read_only=True) + facility_detail = HealthFacilitySerializer(source='registered_facility', read_only=True) + visits_count = serializers.SerializerMethodField() + medical_history_count = serializers.SerializerMethodField() + + class Meta(PatientSerializer.Meta): + fields = PatientSerializer.Meta.fields + [ + 'location_detail', 'facility_detail', 'visits_count', 'medical_history_count' + ] + + def get_visits_count(self, obj): + return obj.patientvisit_set.count() + + def get_medical_history_count(self, obj): + return obj.patientmedicalhistory_set.count() + + +class PatientCreateSerializer(serializers.ModelSerializer): + """ + Serializer for creating new patients. + """ + class Meta: + model = Patient + fields = [ + 'first_name', 'last_name', 'middle_name', 'date_of_birth', + 'gender', 'marital_status', 'phone_number', 'email', 'address', + 'location', 'emergency_contact_name', 'emergency_contact_phone', + 'emergency_contact_relationship', 'blood_type', 'allergies', + 'chronic_conditions', 'current_medications', 'insurance_provider', + 'insurance_number', 'payment_method', 'registered_facility', + 'occupation', 'education_level', 'religion', 'ethnicity' + ] + + def _generate_patient_id(self) -> str: + prefix = "PAT" + random_id = get_random_string(8).upper() + return f"{prefix}-{random_id}" + + def create(self, validated_data): + patient_id = self._generate_patient_id() + while Patient.objects.filter(patient_id=patient_id).exists(): + patient_id = self._generate_patient_id() + validated_data["patient_id"] = patient_id + return Patient.objects.create(**validated_data) + + +class PatientVisitSerializer(serializers.ModelSerializer): + """ + Serializer for PatientVisit model. + """ + visit_type_display = serializers.CharField(source='get_visit_type_display', read_only=True) + status_display = serializers.CharField(source='get_status_display', read_only=True) + patient_name = serializers.CharField(source='patient.get_full_name', read_only=True) + facility_name = serializers.CharField(source='facility.name', read_only=True) + provider_name = serializers.CharField(source='attending_provider.get_full_name', read_only=True) + + class Meta: + model = PatientVisit + fields = [ + 'id', 'patient', 'patient_name', 'facility', 'facility_name', + 'visit_type', 'visit_type_display', 'status', 'status_display', + 'scheduled_date', 'actual_date', 'chief_complaint', 'diagnosis', + 'treatment_plan', 'prescription', 'attending_provider', 'provider_name', + 'consultation_fee', 'total_cost', 'payment_status', 'notes', + 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class PatientVisitDetailSerializer(PatientVisitSerializer): + """ + Detailed serializer for PatientVisit with related data. + """ + patient_detail = PatientSerializer(source='patient', read_only=True) + facility_detail = HealthFacilitySerializer(source='facility', read_only=True) + provider_detail = UserSerializer(source='attending_provider', read_only=True) + + class Meta(PatientVisitSerializer.Meta): + fields = PatientVisitSerializer.Meta.fields + [ + 'patient_detail', 'facility_detail', 'provider_detail' + ] + + +class PatientVisitCreateSerializer(serializers.ModelSerializer): + """ + Serializer for creating new patient visits. + """ + class Meta: + model = PatientVisit + fields = [ + 'patient', 'facility', 'visit_type', 'scheduled_date', + 'chief_complaint', 'consultation_fee', 'notes' + ] + + +class PatientMedicalHistorySerializer(serializers.ModelSerializer): + """ + Serializer for PatientMedicalHistory model. + """ + severity_display = serializers.CharField(source='get_severity_display', read_only=True) + patient_name = serializers.CharField(source='patient.get_full_name', read_only=True) + facility_name = serializers.CharField(source='facility.name', read_only=True) + provider_name = serializers.CharField(source='diagnosed_by.get_full_name', read_only=True) + + class Meta: + model = PatientMedicalHistory + fields = [ + 'id', 'patient', 'patient_name', 'condition', 'diagnosis_date', + 'is_active', 'severity', 'severity_display', 'treatment', + 'medications', 'outcomes', 'diagnosed_by', 'provider_name', + 'facility', 'facility_name', 'notes', 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class PatientMedicalHistoryDetailSerializer(PatientMedicalHistorySerializer): + """ + Detailed serializer for PatientMedicalHistory with related data. + """ + patient_detail = PatientSerializer(source='patient', read_only=True) + facility_detail = HealthFacilitySerializer(source='facility', read_only=True) + provider_detail = UserSerializer(source='diagnosed_by', read_only=True) + + class Meta(PatientMedicalHistorySerializer.Meta): + fields = PatientMedicalHistorySerializer.Meta.fields + [ + 'patient_detail', 'facility_detail', 'provider_detail' + ] + + +class PatientSearchSerializer(serializers.Serializer): + """ + Serializer for patient search functionality. + """ + query = serializers.CharField(max_length=100) + search_type = serializers.ChoiceField(choices=[ + ('name', 'Name'), + ('patient_id', 'Patient ID'), + ('phone', 'Phone Number'), + ('location', 'Location'), + ('facility', 'Facility') + ]) + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + + +class PatientStatsSerializer(serializers.Serializer): + """ + Serializer for patient statistics. + """ + total_patients = serializers.IntegerField() + active_patients = serializers.IntegerField() + new_patients_this_month = serializers.IntegerField() + patients_by_gender = serializers.DictField() + patients_by_age_group = serializers.DictField() + patients_by_location = serializers.DictField() + patients_by_facility = serializers.DictField() + common_conditions = serializers.ListField() + average_age = serializers.FloatField() diff --git a/apps/patients/urls.py b/apps/patients/urls.py new file mode 100644 index 0000000..e9a825a --- /dev/null +++ b/apps/patients/urls.py @@ -0,0 +1,48 @@ +""" +URL configuration for patients app. +""" + +from django.urls import path +from . import views + +app_name = 'patients' + +urlpatterns = [ + # Patient management + path('', views.patient_list, name='patient_list'), + path('create/', views.patient_create, name='patient_create'), + path('/', views.patient_detail, name='patient_detail'), + path('/edit/', views.patient_edit, name='patient_edit'), + path('/delete/', views.patient_delete, name='patient_delete'), + + # Patient search + path('search/', views.patient_search, name='patient_search'), + path('advanced-search/', views.advanced_search, name='advanced_search'), + + # Patient visits + path('/visits/', views.patient_visits, name='patient_visits'), + path('/visits/create/', views.visit_create, name='visit_create'), + path('visits//', views.visit_detail, name='visit_detail'), + path('visits//edit/', views.visit_edit, name='visit_edit'), + path('visits//delete/', views.visit_delete, name='visit_delete'), + + # Patient medical history + path('/medical-history/', views.medical_history, name='medical_history'), + path('/medical-history/create/', views.medical_history_create, name='medical_history_create'), + path('medical-history//', views.medical_history_detail, name='medical_history_detail'), + path('medical-history//edit/', views.medical_history_edit, name='medical_history_edit'), + path('medical-history//delete/', views.medical_history_delete, name='medical_history_delete'), + + # Patient statistics and reports + path('statistics/', views.patient_statistics, name='patient_statistics'), + path('reports/', views.patient_reports, name='patient_reports'), + path('export/', views.export_patients, name='export_patients'), + + # Bulk operations + path('bulk-import/', views.bulk_import, name='bulk_import'), + path('bulk-export/', views.bulk_export, name='bulk_export'), + + # Patient dashboard + path('/dashboard/', views.patient_dashboard, name='patient_dashboard'), + path('/timeline/', views.patient_timeline, name='patient_timeline'), +] diff --git a/apps/patients/views.py b/apps/patients/views.py new file mode 100644 index 0000000..2e40cb3 --- /dev/null +++ b/apps/patients/views.py @@ -0,0 +1,260 @@ +""" +Views for patients app. +""" + +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.utils.decorators import method_decorator +from django.views import View +from django.contrib.auth.mixins import LoginRequiredMixin + + +def patient_list(request): + """Patient list view.""" + context = { + 'title': 'Patients - OpenCare-Africa', + } + return render(request, 'patients/patient_list.html', context) + + +@login_required +def patient_detail(request, pk): + """Patient detail view.""" + context = { + 'title': 'Patient Details - OpenCare-Africa', + 'patient_id': pk, + } + return render(request, 'patients/patient_detail.html', context) + + +@login_required +def patient_create(request): + """Create new patient view.""" + context = { + 'title': 'Add New Patient - OpenCare-Africa', + } + return render(request, 'patients/patient_create.html', context) + + +@login_required +def patient_edit(request, pk): + """Edit patient view.""" + context = { + 'title': 'Edit Patient - OpenCare-Africa', + 'patient_id': pk, + } + return render(request, 'patients/patient_edit.html', context) + + +def visit_list(request): + """Patient visit list view.""" + context = { + 'title': 'Patient Visits - OpenCare-Africa', + } + return render(request, 'patients/visit_list.html', context) + + +@login_required +def visit_detail(request, pk): + """Patient visit detail view.""" + context = { + 'title': 'Visit Details - OpenCare-Africa', + 'visit_id': pk, + } + return render(request, 'patients/visit_detail.html', context) + + +@login_required +def visit_create(request): + """Create new visit view.""" + context = { + 'title': 'New Visit - OpenCare-Africa', + } + return render(request, 'patients/visit_create.html', context) + + +@require_http_methods(["GET"]) +def patient_search(request): + """Patient search endpoint.""" + query = request.GET.get('q', '') + return JsonResponse({ + 'message': 'Patient search endpoint', + 'query': query, + 'results': [] + }) + + +@require_http_methods(["GET"]) +def patient_stats(request): + """Patient statistics endpoint.""" + return JsonResponse({ + 'total_patients': 0, + 'active_patients': 0, + 'new_patients_this_month': 0, + 'visits_today': 0 + }) + + +@login_required +def patient_delete(request, pk): + """Delete patient view.""" + context = { + 'title': 'Delete Patient - OpenCare-Africa', + 'patient_id': pk, + } + return render(request, 'patients/patient_delete.html', context) + + +def advanced_search(request): + """Advanced patient search view.""" + context = { + 'title': 'Advanced Search - OpenCare-Africa', + } + return render(request, 'patients/advanced_search.html', context) + + +@login_required +def patient_visits(request, patient_pk): + """Patient visits list view.""" + context = { + 'title': 'Patient Visits - OpenCare-Africa', + 'patient_id': patient_pk, + } + return render(request, 'patients/patient_visits.html', context) + + +@login_required +def visit_edit(request, pk): + """Edit visit view.""" + context = { + 'title': 'Edit Visit - OpenCare-Africa', + 'visit_id': pk, + } + return render(request, 'patients/visit_edit.html', context) + + +@login_required +def visit_delete(request, pk): + """Delete visit view.""" + context = { + 'title': 'Delete Visit - OpenCare-Africa', + 'visit_id': pk, + } + return render(request, 'patients/visit_delete.html', context) + + +@login_required +def medical_history(request, patient_pk): + """Patient medical history view.""" + context = { + 'title': 'Medical History - OpenCare-Africa', + 'patient_id': patient_pk, + } + return render(request, 'patients/medical_history.html', context) + + +@login_required +def medical_history_create(request, patient_pk): + """Create medical history view.""" + context = { + 'title': 'Add Medical History - OpenCare-Africa', + 'patient_id': patient_pk, + } + return render(request, 'patients/medical_history_create.html', context) + + +@login_required +def medical_history_detail(request, pk): + """Medical history detail view.""" + context = { + 'title': 'Medical History Details - OpenCare-Africa', + 'history_id': pk, + } + return render(request, 'patients/medical_history_detail.html', context) + + +@login_required +def medical_history_edit(request, pk): + """Edit medical history view.""" + context = { + 'title': 'Edit Medical History - OpenCare-Africa', + 'history_id': pk, + } + return render(request, 'patients/medical_history_edit.html', context) + + +@login_required +def medical_history_delete(request, pk): + """Delete medical history view.""" + context = { + 'title': 'Delete Medical History - OpenCare-Africa', + 'history_id': pk, + } + return render(request, 'patients/medical_history_delete.html', context) + + +@login_required +def patient_statistics(request): + """Patient statistics view.""" + context = { + 'title': 'Patient Statistics - OpenCare-Africa', + } + return render(request, 'patients/patient_statistics.html', context) + + +@login_required +def patient_reports(request): + """Patient reports view.""" + context = { + 'title': 'Patient Reports - OpenCare-Africa', + } + return render(request, 'patients/patient_reports.html', context) + + +@login_required +def export_patients(request): + """Export patients view.""" + context = { + 'title': 'Export Patients - OpenCare-Africa', + } + return render(request, 'patients/export_patients.html', context) + + +@login_required +def bulk_import(request): + """Bulk import patients view.""" + context = { + 'title': 'Bulk Import Patients - OpenCare-Africa', + } + return render(request, 'patients/bulk_import.html', context) + + +@login_required +def bulk_export(request): + """Bulk export patients view.""" + context = { + 'title': 'Bulk Export Patients - OpenCare-Africa', + } + return render(request, 'patients/bulk_export.html', context) + + +@login_required +def patient_dashboard(request, pk): + """Patient dashboard view.""" + context = { + 'title': 'Patient Dashboard - OpenCare-Africa', + 'patient_id': pk, + } + return render(request, 'patients/patient_dashboard.html', context) + + +@login_required +def patient_timeline(request, pk): + """Patient timeline view.""" + context = { + 'title': 'Patient Timeline - OpenCare-Africa', + 'patient_id': pk, + } + return render(request, 'patients/patient_timeline.html', context) diff --git a/apps/records/__init__.py b/apps/records/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/records/filters.py b/apps/records/filters.py new file mode 100644 index 0000000..931ec4b --- /dev/null +++ b/apps/records/filters.py @@ -0,0 +1,38 @@ +""" +Filter definitions for health record queries. +""" + +from __future__ import annotations + +import django_filters + +from .models import HealthRecord + + +class HealthRecordFilter(django_filters.FilterSet): + """ + Provide rich filtering options for health record collections. + + Supports record date ranges, facility/provider scoping, and partial patient + ID searches so clinicians can rapidly locate relevant entries. + """ + + record_date = django_filters.DateFromToRangeFilter() + patient_id = django_filters.CharFilter( + field_name="patient__patient_id", lookup_expr="icontains" + ) + provider = django_filters.NumberFilter(field_name="attending_provider") + facility = django_filters.NumberFilter(field_name="facility") + record_type = django_filters.CharFilter(field_name="record_type") + + class Meta: + model = HealthRecord + fields = [ + "record_date", + "patient", + "patient_id", + "provider", + "facility", + "record_type", + "is_confidential", + ] diff --git a/apps/records/models.py b/apps/records/models.py new file mode 100644 index 0000000..d17b7f7 --- /dev/null +++ b/apps/records/models.py @@ -0,0 +1,317 @@ +""" +Health Records models for OpenCare-Africa health system. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.core.models import User, HealthFacility +from apps.patients.models import Patient + + +class HealthRecord(models.Model): + """ + Main model for patient health records. + """ + RECORD_TYPE_CHOICES = [ + ('medical', _('Medical Record')), + ('dental', _('Dental Record')), + ('mental_health', _('Mental Health Record')), + ('maternity', _('Maternity Record')), + ('pediatric', _('Pediatric Record')), + ('emergency', _('Emergency Record')), + ('laboratory', _('Laboratory Record')), + ('imaging', _('Imaging Record')), + ] + + patient = models.ForeignKey(Patient, on_delete=models.CASCADE, related_name='health_records') + facility = models.ForeignKey(HealthFacility, on_delete=models.CASCADE) + record_type = models.CharField(max_length=20, choices=RECORD_TYPE_CHOICES) + + # Record Details + record_date = models.DateTimeField() + attending_provider = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + + # Clinical Information + chief_complaint = models.TextField(blank=True) + history_of_present_illness = models.TextField(blank=True) + past_medical_history = models.TextField(blank=True) + family_history = models.TextField(blank=True) + social_history = models.TextField(blank=True) + + # Physical Examination + vital_signs = models.JSONField(default=dict) + physical_examination = models.TextField(blank=True) + + # Assessment and Plan + assessment = models.TextField(blank=True) + diagnosis = models.JSONField(default=list) + treatment_plan = models.TextField(blank=True) + follow_up_plan = models.TextField(blank=True) + + # Additional Information + notes = models.TextField(blank=True) + attachments = models.JSONField(default=list) # Store file paths + + # Record Status + is_active = models.BooleanField(default=True) + is_confidential = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Health Record') + verbose_name_plural = _('Health Records') + ordering = ['-record_date'] + + def __str__(self): + return f"{self.patient} - {self.get_record_type_display()} ({self.record_date})" + + +class VitalSigns(models.Model): + """ + Model for storing patient vital signs. + """ + health_record = models.ForeignKey(HealthRecord, on_delete=models.CASCADE, related_name='vital_signs_detail') + + # Vital Signs + temperature = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True) # Celsius + blood_pressure_systolic = models.PositiveIntegerField(null=True, blank=True) # mmHg + blood_pressure_diastolic = models.PositiveIntegerField(null=True, blank=True) # mmHg + heart_rate = models.PositiveIntegerField(null=True, blank=True) # bpm + respiratory_rate = models.PositiveIntegerField(null=True, blank=True) # breaths/min + oxygen_saturation = models.PositiveIntegerField(null=True, blank=True) # % + + # Additional Measurements + height = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) # cm + weight = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) # kg + bmi = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True) + + # Pain Assessment + pain_scale = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(11)], null=True, blank=True) + + # Measurement Context + measurement_position = models.CharField(max_length=50, blank=True) # sitting, standing, lying + measurement_notes = models.TextField(blank=True) + + recorded_at = models.DateTimeField(auto_now_add=True) + recorded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + + class Meta: + verbose_name = _('Vital Signs') + verbose_name_plural = _('Vital Signs') + ordering = ['-recorded_at'] + + def __str__(self): + return f"Vital Signs for {self.health_record.patient} at {self.recorded_at}" + + def save(self, *args, **kwargs): + # Calculate BMI if height and weight are provided + if self.height and self.weight: + height_m = self.height / 100 # Convert cm to meters + self.bmi = round(self.weight / (height_m ** 2), 2) + super().save(*args, **kwargs) + + +class Medication(models.Model): + """ + Model for managing patient medications and prescriptions. + """ + health_record = models.ForeignKey(HealthRecord, on_delete=models.CASCADE, related_name='medications') + + # Medication Details + medication_name = models.CharField(max_length=200) + generic_name = models.CharField(max_length=200, blank=True) + dosage_form = models.CharField(max_length=50) # tablet, capsule, liquid, injection, etc. + strength = models.CharField(max_length=50) # 500mg, 10mg/ml, etc. + + # Prescription Details + dosage = models.CharField(max_length=100) # 1 tablet, 2ml, etc. + frequency = models.CharField(max_length=100) # twice daily, every 8 hours, etc. + route = models.CharField(max_length=50) # oral, intravenous, topical, etc. + duration = models.CharField(max_length=100, blank=True) # 7 days, until finished, etc. + + # Prescribing Information + prescribed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + prescription_date = models.DateTimeField(auto_now_add=True) + + # Instructions + instructions = models.TextField(blank=True) + special_instructions = models.TextField(blank=True) + + # Status + is_active = models.BooleanField(default=True) + start_date = models.DateField(null=True, blank=True) + end_date = models.DateField(null=True, blank=True) + + # Adverse Effects + adverse_effects = models.TextField(blank=True) + is_discontinued = models.BooleanField(default=False) + discontinuation_reason = models.TextField(blank=True) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Medication') + verbose_name_plural = _('Medications') + ordering = ['-prescription_date'] + + def __str__(self): + return f"{self.medication_name} - {self.dosage} {self.frequency} for {self.health_record.patient}" + + +class LaboratoryTest(models.Model): + """ + Model for laboratory test results. + """ + health_record = models.ForeignKey(HealthRecord, on_delete=models.CASCADE, related_name='laboratory_tests') + + # Test Information + test_name = models.CharField(max_length=200) + test_category = models.CharField(max_length=100) # blood, urine, stool, etc. + test_code = models.CharField(max_length=50, blank=True) + + # Test Details + ordered_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + ordered_date = models.DateTimeField(auto_now_add=True) + collection_date = models.DateTimeField(null=True, blank=True) + result_date = models.DateTimeField(null=True, blank=True) + + # Test Results + results = models.JSONField(default=dict) # Store test parameters and values + reference_range = models.JSONField(default=dict) # Store normal ranges + units = models.JSONField(default=dict) # Store units for each parameter + + # Result Interpretation + is_abnormal = models.BooleanField(default=False) + interpretation = models.TextField(blank=True) + clinical_significance = models.TextField(blank=True) + + # Quality Control + is_verified = models.BooleanField(default=False) + verified_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='verified_tests') + verification_date = models.DateTimeField(null=True, blank=True) + + # Additional Information + specimen_quality = models.CharField(max_length=50, blank=True) + notes = models.TextField(blank=True) + attachments = models.JSONField(default=list) # Store result files + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Laboratory Test') + verbose_name_plural = _('Laboratory Tests') + ordering = ['-ordered_date'] + + def __str__(self): + return f"{self.test_name} for {self.health_record.patient} ({self.ordered_date})" + + +class ImagingStudy(models.Model): + """ + Model for imaging study results (X-rays, CT scans, MRI, etc.). + """ + health_record = models.ForeignKey(HealthRecord, on_delete=models.CASCADE, related_name='imaging_studies') + + # Study Information + study_type = models.CharField(max_length=100) # X-ray, CT, MRI, ultrasound, etc. + body_part = models.CharField(max_length=100) # chest, abdomen, head, etc. + study_description = models.TextField() + + # Study Details + ordered_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + ordered_date = models.DateTimeField(auto_now_add=True) + performed_date = models.DateTimeField(null=True, blank=True) + reported_date = models.DateTimeField(null=True, blank=True) + + # Technical Details + technique = models.TextField(blank=True) + contrast_used = models.BooleanField(default=False) + contrast_type = models.CharField(max_length=100, blank=True) + + # Results + findings = models.TextField(blank=True) + impression = models.TextField(blank=True) + recommendations = models.TextField(blank=True) + + # Radiologist + radiologist = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='reported_studies') + + # Quality and Safety + radiation_dose = models.CharField(max_length=100, blank=True) + safety_checks = models.JSONField(default=list) + + # Files and Attachments + image_files = models.JSONField(default=list) # Store image file paths + report_file = models.FileField(upload_to='imaging_reports/', null=True, blank=True) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Imaging Study') + verbose_name_plural = _('Imaging Studies') + ordering = ['-ordered_date'] + + def __str__(self): + return f"{self.study_type} - {self.body_part} for {self.health_record.patient}" + + +class TreatmentPlan(models.Model): + """ + Model for comprehensive treatment plans. + """ + health_record = models.ForeignKey(HealthRecord, on_delete=models.CASCADE, related_name='treatment_plans') + + # Plan Details + plan_name = models.CharField(max_length=200) + plan_type = models.CharField(max_length=50, choices=[ + ('acute', _('Acute Care')), + ('chronic', _('Chronic Disease Management')), + ('preventive', _('Preventive Care')), + ('rehabilitation', _('Rehabilitation')), + ('palliative', _('Palliative Care')), + ]) + + # Plan Components + goals = models.JSONField(default=list) + interventions = models.JSONField(default=list) + expected_outcomes = models.TextField() + timeline = models.CharField(max_length=100, blank=True) + + # Care Team + primary_provider = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + care_team = models.ManyToManyField(User, blank=True, related_name='care_plans') + + # Plan Status + status = models.CharField(max_length=20, choices=[ + ('active', _('Active')), + ('completed', _('Completed')), + ('discontinued', _('Discontinued')), + ('on_hold', _('On Hold')), + ], default='active') + + # Progress Tracking + progress_notes = models.JSONField(default=list) + milestones = models.JSONField(default=list) + + # Evaluation + effectiveness_rating = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)], null=True, blank=True) + patient_satisfaction = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)], null=True, blank=True) + + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Treatment Plan') + verbose_name_plural = _('Treatment Plans') + ordering = ['-created_at'] + + def __str__(self): + return f"{self.plan_name} for {self.health_record.patient} ({self.get_status_display()})" diff --git a/apps/records/serializers.py b/apps/records/serializers.py new file mode 100644 index 0000000..cb9ca17 --- /dev/null +++ b/apps/records/serializers.py @@ -0,0 +1,217 @@ +""" +Serializers for patient health record APIs. +""" + +from __future__ import annotations + +from rest_framework import serializers + +from apps.core.models import User +from apps.patients.models import Patient + +from .models import HealthRecord, VitalSigns, Medication, LaboratoryTest + + +class HealthRecordSerializer(serializers.ModelSerializer): + """ + Lightweight representation of a health record for list views. + """ + + record_type_display = serializers.CharField(source="get_record_type_display", read_only=True) + patient_name = serializers.CharField(source="patient.get_full_name", read_only=True) + facility_name = serializers.CharField(source="facility.name", read_only=True) + provider_name = serializers.CharField( + source="attending_provider.get_full_name", read_only=True + ) + + class Meta: + model = HealthRecord + fields = [ + "id", + "patient", + "patient_name", + "facility", + "facility_name", + "record_type", + "record_type_display", + "record_date", + "attending_provider", + "provider_name", + "chief_complaint", + "assessment", + "diagnosis", + "treatment_plan", + "follow_up_plan", + "is_active", + "is_confidential", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + +class HealthRecordDetailSerializer(HealthRecordSerializer): + """ + Extend the base health record serializer with full clinical context. + """ + + class Meta(HealthRecordSerializer.Meta): + fields = HealthRecordSerializer.Meta.fields + [ + "history_of_present_illness", + "past_medical_history", + "family_history", + "social_history", + "vital_signs", + "physical_examination", + "notes", + "attachments", + ] + + +class HealthRecordCreateSerializer(serializers.ModelSerializer): + """ + Handle creation and updates while validating related foreign keys. + """ + + class Meta: + model = HealthRecord + fields = [ + "patient", + "facility", + "record_type", + "record_date", + "attending_provider", + "chief_complaint", + "history_of_present_illness", + "past_medical_history", + "family_history", + "social_history", + "vital_signs", + "physical_examination", + "assessment", + "diagnosis", + "treatment_plan", + "follow_up_plan", + "notes", + "attachments", + "is_confidential", + "is_active", + ] + + def validate(self, attrs): + """ + Ensure the referenced patient, provider, and facility are active entries. + """ + patient: Patient = attrs.get("patient") + provider: User | None = attrs.get("attending_provider") + if patient and not patient.is_active: + raise serializers.ValidationError("Patient profile is inactive.") + + if provider and not provider.is_active: + raise serializers.ValidationError("Attending provider account is inactive.") + + return attrs + + +class VitalSignsSerializer(serializers.ModelSerializer): + """ + Serializer for VitalSigns associated with a health record. + """ + + recorded_by_name = serializers.CharField(source="recorded_by.get_full_name", read_only=True) + + class Meta: + model = VitalSigns + fields = [ + "id", + "health_record", + "temperature", + "blood_pressure_systolic", + "blood_pressure_diastolic", + "heart_rate", + "respiratory_rate", + "oxygen_saturation", + "height", + "weight", + "bmi", + "pain_scale", + "measurement_position", + "measurement_notes", + "recorded_at", + "recorded_by", + "recorded_by_name", + ] + read_only_fields = ["id", "recorded_at", "bmi"] + + +class MedicationSerializer(serializers.ModelSerializer): + """ + Serializer for Medication entries on a health record. + """ + + prescribed_by_name = serializers.CharField(source="prescribed_by.get_full_name", read_only=True) + + class Meta: + model = Medication + fields = [ + "id", + "health_record", + "medication_name", + "dosage_form", + "strength", + "dosage", + "frequency", + "route", + "duration", + "prescribed_by", + "prescribed_by_name", + "instructions", + "special_instructions", + "is_active", + "start_date", + "end_date", + "notes", + "is_discontinued", + "discontinuation_reason", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + +class LaboratoryTestSerializer(serializers.ModelSerializer): + """ + Serializer for laboratory test results linked to a health record. + """ + + ordered_by_name = serializers.CharField(source="ordered_by.get_full_name", read_only=True) + verified_by_name = serializers.CharField(source="verified_by.get_full_name", read_only=True) + + class Meta: + model = LaboratoryTest + fields = [ + "id", + "health_record", + "test_name", + "test_category", + "test_code", + "ordered_by", + "ordered_by_name", + "ordered_date", + "collection_date", + "result_date", + "results", + "reference_range", + "units", + "is_abnormal", + "interpretation", + "clinical_significance", + "is_verified", + "verified_by", + "verified_by_name", + "verification_date", + "specimen_quality", + "notes", + "attachments", + ] + read_only_fields = ["id", "ordered_date", "verification_date"] diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ae2d0ab --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for OpenCare_Africa project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/dev/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'OpenCare_Africa.settings') + +application = get_asgi_application() diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..d85bf64 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,44 @@ +""" +Celery configuration for opencare-africa project. +""" + +import os +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') + +app = Celery('healthcare_backend') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f'Request: {self.request!r}') + + +# Celery Beat Schedule +app.conf.beat_schedule = { + 'health-check-daily': { + 'task': 'apps.analytics.tasks.daily_health_check', + 'schedule': 86400.0, # Daily + }, + 'backup-database-weekly': { + 'task': 'apps.core.tasks.backup_database', + 'schedule': 604800.0, # Weekly + }, + 'cleanup-old-records': { + 'task': 'apps.core.tasks.cleanup_old_records', + 'schedule': 2592000.0, # Monthly + }, + 'generate-analytics-report': { + 'task': 'apps.analytics.tasks.generate_analytics_report', + 'schedule': 86400.0, # Daily + }, +} diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..d568ad7 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,15 @@ +""" +Django settings for OpenCare_Africa project. +""" + +import os + +# Set the Django settings module based on environment +DJANGO_ENV = os.environ.get('DJANGO_ENV', 'development') + +if DJANGO_ENV == 'production': + from .settings.production import * +elif DJANGO_ENV == 'test': + from .settings.test import * +else: + from .settings.development import * diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..fab5d58 --- /dev/null +++ b/config/settings/__init__.py @@ -0,0 +1 @@ +# Settings package for OpenCare-Africa diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..97e81ab --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,259 @@ +""" +Base settings for opencare-africa project. +""" + +import os +from pathlib import Path +from decouple import config + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = config('SECRET_KEY', default='django-insecure-change-in-production') + +# Application definition +DJANGO_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', +] + +THIRD_PARTY_APPS = [ + 'rest_framework', + 'rest_framework_simplejwt', + 'corsheaders', + 'django_filters', + 'django_extensions', + 'django_celery_beat', + 'django_celery_results', + 'django_redis', + 'drf_spectacular', + 'health_check', + 'health_check.db', + 'health_check.cache', + 'health_check.storage', +] + +LOCAL_APPS = [ + 'apps.core', + 'apps.patients', + 'apps.health_workers', + 'apps.facilities', + 'apps.records', + 'apps.analytics', + 'apps.api', + 'apps.appointments', +] + +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' +ASGI_APPLICATION = 'config.asgi.application' + +# Database +DATABASES = { + 'default': { + 'ENGINE': config('DB_ENGINE', default='django.db.backends.postgresql'), + 'NAME': config('DB_NAME', default='opencare_africa'), + 'USER': config('DB_USER', default='opencare_user'), + 'PASSWORD': config('DB_PASSWORD', default='opencare_password'), + 'HOST': config('DB_HOST', default='localhost'), + 'PORT': config('DB_PORT', default='5432'), + } +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +LANGUAGE_CODE = config('LANGUAGE_CODE', default='en-us') +TIME_ZONE = config('TIME_ZONE', default='UTC') +USE_I18N = config('USE_I18N', default=True, cast=bool) +USE_L10N = config('USE_L10N', default=True, cast=bool) +USE_TZ = config('USE_TZ', default=True, cast=bool) + +# Static files (CSS, JavaScript, Images) +STATIC_URL = config('STATIC_URL', default='/static/') +STATIC_ROOT = BASE_DIR / config('STATIC_ROOT', default='staticfiles') +STATICFILES_DIRS = [ + BASE_DIR / 'static', +] + +# Media files +MEDIA_URL = config('MEDIA_URL', default='/media/') +MEDIA_ROOT = BASE_DIR / config('MEDIA_ROOT', default='media') + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Site ID +SITE_ID = 1 + +# Custom User Model +AUTH_USER_MODEL = 'core.User' + +# REST Framework +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_FILTER_BACKENDS': ( + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'EXCEPTION_HANDLER': 'apps.api.exceptions.sanitized_exception_handler', +} + +# JWT Settings +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=config('JWT_ACCESS_TOKEN_LIFETIME', default=5, cast=int)), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=config('JWT_REFRESH_TOKEN_LIFETIME', default=1, cast=int)), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, +} + +# CORS Settings +CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', default='http://localhost:3000,http://localhost:8080').split(',') +CORS_ALLOW_CREDENTIALS = True + +# Redis Configuration +REDIS_HOST = config('REDIS_HOST', default='localhost') +REDIS_PORT = config('REDIS_PORT', default=6379, cast=int) +REDIS_DB = config('REDIS_DB', default=0, cast=int) + +# Cache Configuration +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': f'redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}', + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + } + } +} + +# Celery Configuration +CELERY_BROKER_URL = config('CELERY_BROKER_URL', default=f'redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}') +CELERY_RESULT_BACKEND = config('CELERY_RESULT_BACKEND', default=f'redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}') +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE + +# Email Configuration +EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') +EMAIL_HOST = config('EMAIL_HOST', default='localhost') +EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) +EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool) +EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') + +# Logging Configuration +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'django.log', + 'formatter': 'verbose', + }, + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + }, + 'root': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': False, + }, + }, +} + +# Health Check +HEALTH_CHECK = { + 'DISK_USAGE_MAX': 90, # percentage + 'MEMORY_MIN': 100, # in MB + 'CACHE_BACKENDS': [], # Disable Redis health check for development +} + +# API Documentation +SPECTACULAR_SETTINGS = { + 'TITLE': 'OpenCare-Africa API', + 'DESCRIPTION': 'Health Informatics Platform API for Africa', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} diff --git a/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..52bf44f --- /dev/null +++ b/config/settings/development.py @@ -0,0 +1,57 @@ +""" +Development settings for opencare-africa project. +""" + +from .base import * + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = config('DEBUG', default=True, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1,0.0.0.0').split(',') + +# Database - Use SQLite for development +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +# Debug Toolbar +if config('USE_DEBUG_TOOLBAR', default=True, cast=bool): + INSTALLED_APPS += ['debug_toolbar'] + MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] + INTERNAL_IPS = ['127.0.0.1', 'localhost'] + +# Silk Profiler +if config('USE_SILK_PROFILER', default=False, cast=bool): + INSTALLED_APPS += ['silk'] + MIDDLEWARE += ['silk.middleware.SilkyMiddleware'] + +# Email - Use console backend for development +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# Static files - Serve from development server +STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage' + +# Media files - Serve from development server +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# Logging - More verbose for development +LOGGING['root']['level'] = 'DEBUG' +LOGGING['loggers']['django']['level'] = 'DEBUG' + +# CORS - Allow all origins in development +CORS_ALLOW_ALL_ORIGINS = True + +# Disable HTTPS requirements +SECURE_SSL_REDIRECT = False +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False + +# Allow all hosts for development +ALLOWED_HOSTS = ['*'] + +# Development-specific apps +# Note: django_extensions is already included in base settings diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..fecbbb3 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,122 @@ +""" +Production settings for OpenCare-Africa project. +""" + +from .base import * + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',') + +# Security Settings +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_SECONDS = 31536000 +SECURE_REDIRECT_EXEMPT = [] +SECURE_SSL_REDIRECT = config('SECURE_SSL_REDIRECT', default=True, cast=bool) +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +X_FRAME_OPTIONS = 'DENY' + +# Database - Use PostgreSQL for production +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': config('DB_NAME'), + 'USER': config('DB_USER'), + 'PASSWORD': config('DB_PASSWORD'), + 'HOST': config('DB_HOST'), + 'PORT': config('DB_PORT'), + 'OPTIONS': { + 'sslmode': 'require', + }, + } +} + +# Static files - Use S3 or CDN in production +if config('USE_S3', default=False, cast=bool): + AWS_ACCESS_KEY_ID = config('AWS_ACCESS_KEY_ID') + AWS_SECRET_ACCESS_KEY = config('AWS_SECRET_ACCESS_KEY') + AWS_STORAGE_BUCKET_NAME = config('AWS_STORAGE_BUCKET_NAME') + AWS_S3_REGION_NAME = config('AWS_S3_REGION_NAME') + AWS_S3_CUSTOM_DOMAIN = config('AWS_S3_CUSTOM_DOMAIN') + AWS_S3_OBJECT_PARAMETERS = { + 'CacheControl': 'max-age=86400', + } + AWS_LOCATION = 'static' + STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/' +else: + STATIC_ROOT = BASE_DIR / 'staticfiles' + STATIC_URL = '/static/' + +# Media files - Use S3 in production +if config('USE_S3', default=False, cast=bool): + MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/' + DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' +else: + MEDIA_ROOT = BASE_DIR / 'media' + MEDIA_URL = '/media/' + +# Email - Use SMTP in production +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = config('EMAIL_HOST') +EMAIL_PORT = config('EMAIL_PORT', cast=int) +EMAIL_USE_TLS = config('EMAIL_USE_TLS', cast=bool) +EMAIL_HOST_USER = config('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD') +DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@opencare-africa.org') + +# Logging - Less verbose for production +LOGGING['root']['level'] = 'WARNING' +LOGGING['loggers']['django']['level'] = 'WARNING' + +# CORS - Restrict origins in production +CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS').split(',') +CORS_ALLOW_CREDENTIALS = True + +# Cache - Use Redis in production +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': config('REDIS_URL'), + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'CONNECTION_POOL_KWARGS': { + 'max_connections': 50, + }, + }, + 'KEY_PREFIX': 'opencare_africa', + 'TIMEOUT': 300, + } +} + +# Session - Use Redis in production +SESSION_ENGINE = 'django.contrib.sessions.backends.cache' +SESSION_CACHE_ALIAS = 'default' + +# Celery - Use Redis in production +CELERY_BROKER_URL = config('CELERY_BROKER_URL') +CELERY_RESULT_BACKEND = config('CELERY_RESULT_BACKEND') + +# Monitoring +if config('SENTRY_DSN', default=''): + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + + sentry_sdk.init( + dsn=config('SENTRY_DSN'), + integrations=[DjangoIntegration()], + traces_sample_rate=0.1, + send_default_pii=True, + ) + +# Performance optimizations +CONN_MAX_AGE = 60 +OPTIMIZE_TABLE_NAMES = True + +# Backup settings +BACKUP_DIR = BASE_DIR / 'backups' +BACKUP_RETENTION_DAYS = 30 diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..be56170 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,54 @@ +""" +Test settings for OpenCare-Africa project. +""" + +from .base import * + +# Use in-memory SQLite database for testing +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +# Use console email backend for testing +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# Disable debug mode for testing +DEBUG = False + +# Use fast password hasher for testing +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] + +# Disable logging during tests +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True, +} + +# Use dummy cache for testing +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } +} + +# Disable Celery tasks during testing +CELERY_ALWAYS_EAGER = True +CELERY_EAGER_PROPAGATES_EXCEPTIONS = True + +# Test-specific settings +TEST_RUNNER = 'django.test.runner.DiscoverRunner' + +# Disable migrations during tests +class DisableMigrations: + def __contains__(self, item): + return True + + def __getitem__(self, item): + return None + +MIGRATION_MODULES = DisableMigrations() diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..56e0528 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,60 @@ +""" +URL configuration for opencare-africa project. +""" + +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +# Admin site customization +admin.site.site_header = 'OpenCare-Africa Administration' +admin.site.site_title = 'OpenCare-Africa Admin' +admin.site.index_title = 'Welcome to OpenCare-Africa Admin' + +urlpatterns = [ + # Django Admin + path('admin/', admin.site.urls), + + # Health Check + path('health/', include('health_check.urls')), + + # API Documentation + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + + # API Endpoints + path('api/v1/', include('apps.api.urls')), + + # Core App URLs + path('', include('apps.core.urls')), + + # Patient Management + path('patients/', include('apps.patients.urls')), + + # Health Workers - temporarily commented out + # path('health-workers/', include('apps.health_workers.urls')), + + # Facilities - temporarily commented out + # path('facilities/', include('apps.facilities.urls')), + + # Health Records - temporarily commented out + # path('records/', include('apps.records.urls')), + + # Analytics - temporarily commented out + # path('analytics/', include('apps.analytics.urls')), +] + +# Serve static and media files in development +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + # Debug toolbar + if 'debug_toolbar' in settings.INSTALLED_APPS: + import debug_toolbar + urlpatterns += [ + path('__debug__/', include(debug_toolbar.urls)), + ] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..ae9e11d --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for OpenCare_Africa project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'OpenCare_Africa.settings') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c2a026 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,142 @@ +version: '3.8' + +services: + # Django Web Application + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/app + - static_volume:/app/staticfiles + - media_volume:/app/media + ports: + - "8000:8000" + environment: + - DEBUG=True + - DB_HOST=db + - REDIS_HOST=redis + depends_on: + - db + - redis + networks: + - opencare_network + + # PostgreSQL Database + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql + environment: + - POSTGRES_DB=opencare_africa + - POSTGRES_USER=opencare_user + - POSTGRES_PASSWORD=opencare_password + ports: + - "5432:5432" + networks: + - opencare_network + + # Redis for Caching and Celery + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - opencare_network + + # Celery Worker + celery: + build: . + command: celery -A healthcare_backend worker -l info + volumes: + - .:/app + environment: + - DEBUG=True + - DB_HOST=db + - REDIS_HOST=redis + depends_on: + - db + - redis + networks: + - opencare_network + + # Celery Beat (Scheduler) + celery-beat: + build: . + command: celery -A healthcare_backend beat -l info + volumes: + - .:/app + environment: + - DEBUG=True + - DB_HOST=db + - REDIS_HOST=redis + depends_on: + - db + - redis + networks: + - opencare_network + + # Metabase for Analytics + metabase: + image: metabase/metabase:latest + ports: + - "3000:3000" + environment: + - MB_DB_TYPE=postgres + - MB_DB_DBNAME=metabase + - MB_DB_PORT=5432 + - MB_DB_FILE=/metabase-data/metabase.db + - MB_DB_HOST=db + - MB_DB_USER=opencare_user + - MB_DB_PASS=opencare_password + volumes: + - metabase_data:/metabase-data + depends_on: + - db + networks: + - opencare_network + + # Apache Superset for Advanced Analytics + superset: + image: apache/superset:latest + ports: + - "8088:8088" + environment: + - SUPERSET_SECRET_KEY=your-superset-secret-key + - SUPERSET_CONFIG_PATH=/app/pythonpath/superset_config.py + volumes: + - superset_data:/app/superset_home + depends_on: + - db + networks: + - opencare_network + + # Nginx Reverse Proxy + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + - static_volume:/app/staticfiles + - media_volume:/app/media + depends_on: + - web + networks: + - opencare_network + +volumes: + postgres_data: + redis_data: + metabase_data: + superset_data: + static_volume: + media_volume: + +networks: + opencare_network: + driver: bridge diff --git a/docs/appointments.md b/docs/appointments.md new file mode 100644 index 0000000..df5c2c9 --- /dev/null +++ b/docs/appointments.md @@ -0,0 +1,365 @@ +# Appointment Scheduling API + +The Appointment Scheduling API provides comprehensive endpoints for managing appointments between patients and healthcare providers. It includes conflict detection, notification hooks, and role-based access control. + +## Overview + +The appointment system ensures: +- **No double booking**: Prevents overlapping appointments for providers, patients, and facilities +- **Automatic notifications**: Sends email and SMS notifications for appointment events +- **Role-based access**: Only admins and providers can manage appointments +- **Status management**: Track appointment lifecycle (scheduled, completed, cancelled, no-show) + +## Endpoints + +### Base URL +All appointment endpoints are under `/api/v1/appointments/` + +### CRUD Operations + +#### List Appointments +``` +GET /api/v1/appointments/ +``` +Returns paginated list of appointments with filtering and search support. + +**Query Parameters:** +- `provider` - Filter by provider ID +- `patient` - Filter by patient ID +- `facility` - Filter by facility ID +- `status` - Filter by status (scheduled, completed, cancelled, no_show) +- `appointment_type` - Filter by appointment type +- `search` - Search by patient ID, patient name, or provider name +- `ordering` - Order by `start_time` or `created_at` (default: `start_time`) + +**Response:** +```json +{ + "count": 10, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "patient": 1, + "patient_name": "John Doe", + "provider": 5, + "provider_name": "Dr. Jane Smith", + "facility": 3, + "facility_name": "Hope Clinic", + "appointment_type": "consultation", + "reason": "Routine checkup", + "status": "scheduled", + "start_time": "2025-01-15T10:00:00Z", + "end_time": "2025-01-15T10:30:00Z", + "created_at": "2025-01-10T08:00:00Z", + "updated_at": "2025-01-10T08:00:00Z" + } + ] +} +``` + +#### Create Appointment +``` +POST /api/v1/appointments/ +``` +Creates a new appointment with automatic conflict detection and notifications. + +**Request Body:** +```json +{ + "patient": 1, + "provider": 5, + "facility": 3, + "appointment_type": "consultation", + "reason": "Routine checkup", + "start_time": "2025-01-15T10:00:00Z", + "end_time": "2025-01-15T10:30:00Z" +} +``` + +**Validation Rules:** +- `start_time` must be in the future +- `end_time` must be after `start_time` +- Minimum duration: 5 minutes +- No overlapping appointments for provider, patient, or facility +- Provider must be active and eligible (doctor, nurse, midwife, community_worker) +- Patient must be active + +**Response:** `201 Created` with appointment details + +**Error Responses:** +- `400 Bad Request` - Validation errors or conflicts + ```json + { + "provider": ["Provider already has an appointment in this window."], + "patient": ["Patient already has an appointment in this window."], + "facility": ["Facility already has an appointment in this window."] + } + ``` + +#### Retrieve Appointment +``` +GET /api/v1/appointments/{id}/ +``` +Returns detailed appointment information including notification history. + +**Response:** +```json +{ + "id": 1, + "patient": 1, + "patient_name": "John Doe", + "provider": 5, + "provider_name": "Dr. Jane Smith", + "facility": 3, + "facility_name": "Hope Clinic", + "appointment_type": "consultation", + "reason": "Routine checkup", + "status": "scheduled", + "start_time": "2025-01-15T10:00:00Z", + "end_time": "2025-01-15T10:30:00Z", + "notifications_sent": [ + { + "type": "email", + "recipient": "patient", + "method": "email", + "sent_at": "2025-01-10T08:00:00Z" + } + ], + "created_at": "2025-01-10T08:00:00Z", + "updated_at": "2025-01-10T08:00:00Z" +} +``` + +#### Update Appointment +``` +PATCH /api/v1/appointments/{id}/ +PUT /api/v1/appointments/{id}/ +``` +Updates appointment details. Triggers conflict detection and notifications. + +**Request Body:** (same as create, all fields optional) + +**Response:** `200 OK` with updated appointment + +#### Delete Appointment +``` +DELETE /api/v1/appointments/{id}/ +``` +Cancels and deletes an appointment. Sends cancellation notifications. + +**Response:** `204 No Content` + +### Custom Actions + +#### Upcoming Appointments +``` +GET /api/v1/appointments/upcoming/ +``` +Returns all scheduled appointments in the future. + +**Response:** Same format as list endpoint, filtered to future scheduled appointments only. + +#### Appointments by Provider +``` +GET /api/v1/appointments/by-provider/{provider_id}/ +``` +Returns all appointments for a specific provider. + +**Response:** Same format as list endpoint. + +#### Appointments by Patient +``` +GET /api/v1/appointments/by-patient/{patient_id}/ +``` +Returns all appointments for a specific patient. + +**Response:** Same format as list endpoint. + +#### Check Conflicts +``` +POST /api/v1/appointments/{id}/check-conflicts/ +``` +Manually check for scheduling conflicts for an appointment. + +**Response:** +```json +{ + "has_conflicts": true, + "conflicts": { + "provider": [ + { + "id": 2, + "start_time": "2025-01-15T10:15:00Z", + "end_time": "2025-01-15T10:45:00Z", + "patient__first_name": "Jane", + "patient__last_name": "Doe" + } + ] + } +} +``` + +#### Cancel Appointment +``` +POST /api/v1/appointments/{id}/cancel/ +``` +Marks an appointment as cancelled and sends notifications. + +**Response:** `200 OK` with updated appointment (status: "cancelled") + +#### Complete Appointment +``` +POST /api/v1/appointments/{id}/complete/ +``` +Marks an appointment as completed. + +**Response:** `200 OK` with updated appointment (status: "completed") + +#### Mark No-Show +``` +POST /api/v1/appointments/{id}/mark-no-show/ +``` +Marks an appointment as no-show. + +**Response:** `200 OK` with updated appointment (status: "no_show") + +## Conflict Detection + +The system prevents double booking by checking for overlapping appointments: + +1. **Provider conflicts**: A provider cannot have two appointments at the same time +2. **Patient conflicts**: A patient cannot have two appointments at the same time +3. **Facility conflicts**: A facility cannot have two appointments at the same time + +**Conflict Detection Logic:** +- Only considers appointments with status `scheduled` or `no_show` +- Checks for time overlap: `start_time < other.end_time AND end_time > other.start_time` +- Validates during create and update operations +- Can be manually checked using the `check-conflicts` endpoint + +## Notifications + +The system automatically sends notifications for appointment events: + +### Events +- **created**: When a new appointment is scheduled +- **updated**: When appointment details are changed +- **cancelled**: When an appointment is cancelled +- **reminder**: (Future) For appointment reminders + +### Notification Methods + +#### Email Notifications +- Sent to both patient and provider email addresses +- Includes appointment details, date/time, facility, and reason +- Subject line includes event type and appointment date + +#### SMS Notifications +- Sent to both patient and provider phone numbers +- Shorter format optimized for SMS +- Includes key details: date, time, provider/facility name + +### Notification Hooks + +The notification system is designed to integrate with: +- **Email**: Django's `send_mail` (configurable via `DEFAULT_FROM_EMAIL`) +- **SMS**: Hook ready for integration with: + - Twilio + - AWS SNS + - Africa's Talking + - Other SMS gateway services + +**Current Implementation:** +- Email notifications are fully functional +- SMS notifications are logged (ready for provider integration) + +**Notification History:** +All sent notifications are tracked in the `notifications_sent` field of each appointment. + +## Roles & Permissions + +- **Admin** (`admin` role): Full access to all appointment operations +- **Provider** (`provider` role): Can create, view, update, and cancel appointments +- **Patient** (`patient` role): Currently blocked (future: may view own appointments) + +All endpoints require authentication and appropriate role permissions. + +## Status Management + +Appointments can have the following statuses: + +- **scheduled**: Appointment is confirmed and upcoming +- **completed**: Appointment was successfully completed +- **cancelled**: Appointment was cancelled +- **no_show**: Patient did not show up for the appointment + +Status transitions: +- `scheduled` β†’ `completed` (via `complete` action) +- `scheduled` β†’ `cancelled` (via `cancel` action or delete) +- `scheduled` β†’ `no_show` (via `mark-no-show` action) + +## Model Properties + +The Appointment model includes helpful properties: + +- `duration_minutes`: Calculates appointment duration in minutes +- `is_upcoming`: Returns `True` if appointment is in the future and scheduled +- `check_conflicts()`: Method to manually check for scheduling conflicts + +## Example Usage + +### Create an Appointment +```bash +curl -X POST https://api.opencare-africa.com/api/v1/appointments/ \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "patient": 1, + "provider": 5, + "facility": 3, + "appointment_type": "consultation", + "reason": "Annual checkup", + "start_time": "2025-01-15T10:00:00Z", + "end_time": "2025-01-15T10:30:00Z" + }' +``` + +### List Upcoming Appointments +```bash +curl -X GET https://api.opencare-africa.com/api/v1/appointments/upcoming/ \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### Cancel an Appointment +```bash +curl -X POST https://api.opencare-africa.com/api/v1/appointments/1/cancel/ \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +## Testing + +Comprehensive test coverage includes: +- CRUD operations +- Conflict detection (provider, patient, facility) +- Validation rules (past dates, minimum duration) +- Custom actions (upcoming, by-provider, by-patient) +- Status management (cancel, complete, no-show) +- RBAC enforcement +- Notification triggers + +Run tests with: +```bash +python manage.py test apps.appointments.tests.test_appointments_api +``` + +## Future Enhancements + +- Patient portal access to view own appointments +- Appointment reminders (24 hours before) +- Recurring appointments +- Waitlist management +- Integration with calendar systems (Google Calendar, Outlook) +- Video consultation links +- Appointment ratings and feedback diff --git a/docs/audit-logs.md b/docs/audit-logs.md new file mode 100644 index 0000000..aa19c18 --- /dev/null +++ b/docs/audit-logs.md @@ -0,0 +1,61 @@ +# Audit Logs for Health Data Access + +OpenCare-Africa maintains a complete, tamper-evident record of access to +protected health information (PHI). Use this guide when designing new features, +reviewing incidents, or validating regulatory compliance. + +## What to Capture + +- **Who**: Record the authenticated user or service account ID. For anonymous + requests, capture the device fingerprint or session identifier. +- **What**: Log the model, primary key, and action (`read`, `update`, `delete`, + `export`, etc.). Avoid storing entire payloads; reference summarized fields + or hashes when necessary. +- **When**: Use timezone-aware UTC timestamps with millisecond precision. +- **Where**: Persist the source IP and, when available, facility or device + metadata to support forensic traces. +- **Why**: Include a concise justification (e.g., care team access, billing + reconciliation) supplied by workflows that require explicit reasons. + +## Storage and Retention + +- **Immutable store**: Append entries to the dedicated audit trail tables. Do + not allow in-place updates; use write-once semantics with versioning. +- **Retention**: Retain PHI access logs for the regulatory minimum (typically + 6+ years) or longer if policy requires. Provide scripts to export archives to + long-term storage. +- **Integrity**: Sign batches or use database-level checksum columns to detect + tampering. Prefer cryptographically verifiable chains for high-assurance + deployments. + +## Access Controls + +- **Restricted visibility**: Grant read access only to compliance officers and + authorized auditors. Application users must not see audit entries unless the + feature explicitly supports it (e.g., patient access reports). +- **Least privilege**: segregate audit writers from readers; the application + process writes entries, while reporting jobs run with read-only credentials. +- **Alerting**: Integrate with SIEM tooling to trigger alerts on suspicious + patterns, such as repeated failed access or off-hours bulk exports. + +## Testing and Validation + +- **Unit tests**: Assert that high-risk endpoints emit audit entries with the + correct actor, action, and object identifiers. +- **Integration tests**: Simulate representative API flows (patient lookups, + record edits, data exports) and confirm the audit trail captures them without + exposing PHI content. +- **Performance checks**: Verify that logging does not materially degrade + request latency. Use asynchronous tasks or buffered writes when necessary. +- **Compliance review**: Include audit log coverage in release checklists and + periodic HIPAA/GDPR assessments. + +## Reviewing Logs + +- Administrative users can access the read-only endpoint at `/api/v1/audit-logs/` + to review and filter audit events. +- Use query parameters such as `?model_name=patients.Patient` or `?action=view` + to narrow down entries when investigating incidents. + +Following these practices ensures every access to PHI is discoverable, auditable, +and compliant with healthcare regulations. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..65d673a --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,71 @@ +# 🀝 Contributing Guide + +Thank you for your interest in contributing to this project! +This guide will help you understand how to contribute effectively. + +--- + +## πŸ“Œ Getting Started + +### 1. Fork the Repository +- Click the "Fork" button on GitHub +- This creates your own copy of the project + +### 2. Clone the Repository +- Clone your fork to your local machine +- Navigate into the project directory + +--- + +## 🌿 Branching Strategy + +- Always create a new branch for your work +- Do not work directly on the main branch + +**Branch Naming Examples:** +- feature/add-login +- fix/api-error +- docs/update-readme + +--- + +## πŸ“ Commit Message Style + +Use clear and simple commit messages. + +**Format:** +type: short description + +**Examples:** +- feat: add user login endpoint +- fix: resolve API authentication bug +- docs: update README + +--- + +## πŸ”„ Pull Request Process + +1. Push your changes to your fork +2. Open a Pull Request (PR) to the main repository +3. Provide a clear title and description +4. Wait for review and feedback +5. Make changes if requested + +--- + +## βœ… Contribution Guidelines + +- Keep changes small and focused +- Follow existing project structure +- Write clear and readable code +- Update documentation where necessary + +--- + +## πŸ“Œ Summary + +- Fork β†’ Clone β†’ Branch β†’ Commit β†’ PR +- Follow naming and commit standards +- Ensure your changes are clear and useful + +Thank you for contributing! \ No newline at end of file diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..e765d82 --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,56 @@ +# Error Handling Guidelines + +OpenCare-Africa must protect sensitive health information at every layer. +These guidelines describe how to return helpful but safe API responses, +capture actionable diagnostics via logging, and verify everything with tests. + +## Sanitized Error Responses + +- **Prefer structured payloads**: Use Django REST Framework's `Response` with a + consistent schema (`code`, `message`, `errors`). Include only the context + clients need to proceed; never echo raw exception strings or database fields. +- **Centralized handler**: The custom exception handler at + `apps.api.exceptions.sanitized_exception_handler` enforces the standard shape + and replaces 5xx messages with a generic placeholder. +- **Remove sensitive values**: Strip patient identifiers, authentication + tokens, secrets, and raw query parameters from direct responses. When the + original error contains sensitive values, replace them with neutral language + (for example, `"message": "Unable to process request"`). +- **Map internal errors**: Convert uncaught exceptions to `HTTP_500_INTERNAL_SERVER_ERROR` + with a generic message. Record the exception details in logs instead of the + response body. +- **Validation errors**: Provide field-level messages while ensuring protected + data (e.g., partial SSNs, lab results) is masked or omitted. + +## Logging Policy + +- **Log at the boundary**: Capture errors in Django middleware, DRF exception + handlers, Celery tasks, and management commands. Use structured logging where + possible to enable filtering and redaction. +- **Separate concerns**: Application logs may contain sensitive context, so + restrict access to trusted operators. Route audit events to the configured + secure sink (e.g., STDOUT in production for centralized collection). +- **Redact secrets**: Before logging request data, remove passwords, tokens, + patient identifiers, and any HIPAA-protected information. Prefer + `extra={"user_id": user.pk}` over embedding full objects. +- **Include correlation IDs**: Attach `request.id` or another correlation value + to logs to help trace issues without exposing raw inputs. +- **Avoid duplicate noise**: Log exceptions once at the highest useful level to + prevent flooding alerting systems. + +## Tests and Verification + +- **Unit tests**: For custom exception handlers, assert that sensitive fields do + not appear in serialized responses and that HTTP status codes match the + contract. +- **Integration tests**: Use API tests to trigger representative errors (e.g., + invalid payloads, missing authentication) and validate that responses are + sanitized JSON structures. +- **Logging assertions**: When feasible, patch loggers to confirm redaction + logic strips secrets before log emission. +- **Security review**: Include error-handling verification in release and + incident postmortem checklists to prevent regressions. + +Following these practices keeps patient data secure while giving clients enough +information to recover from errors and operators the diagnostics required to fix +the underlying issues. diff --git a/docs/patient-records.md b/docs/patient-records.md new file mode 100644 index 0000000..c4fa85d --- /dev/null +++ b/docs/patient-records.md @@ -0,0 +1,53 @@ +# Patient Records API + +The patient records API provides secure CRUD access to clinical history across +OpenCare-Africa. Only authenticated clinical roles (doctor, nurse, midwife, +lab technician, pharmacist) and superusers may interact with these endpoints. + +## Endpoints + +- `GET /api/v1/records/` β€” List records with pagination. Supports filtering on + `record_type`, `facility`, `attending_provider`, `patient`, `patient_id`, and + `record_date` ranges (`record_date_after`, `record_date_before`). +- `POST /api/v1/records/` β€” Create a new record entry. +- `GET /api/v1/records/{id}/` β€” Retrieve full clinical details. +- `PATCH /api/v1/records/{id}/` β€” Update selected fields. +- `DELETE /api/v1/records/{id}/` β€” Remove a record. +- `GET /api/v1/records/by-patient/?patient_id=PAT-123` β€” Convenience helper + for pulling a patient's entire history by external identifier. + +## Roles & Security + +- Endpoints require JWT authentication (or a valid session). +- Only users with clinical roles or superuser privileges pass the permission + check enforced by `apps.api.permissions.IsClinicalStaff`. +- Each response omits sensitive attachments unless requested through the + detailed view, helping to balance usability and confidentiality. + +## Example Payload + +```json +{ + "patient": 1, + "facility": 3, + "record_type": "medical", + "record_date": "2025-01-15T10:00:00Z", + "attending_provider": 7, + "chief_complaint": "Headache", + "assessment": "Observation required", + "diagnosis": ["tension_headache"], + "treatment_plan": "Hydration and rest", + "follow_up_plan": "Return if symptoms persist", + "notes": "No contraindications noted", + "is_confidential": false +} +``` + +## Testing + +Automated tests covering create, read, update, delete, filtering, and access +control live in `apps/api/tests/test_health_records_api.py`. Run them with: + +```bash +python manage.py test apps.api.tests.test_health_records_api +``` diff --git a/docs/rbac.md b/docs/rbac.md new file mode 100644 index 0000000..fee916e --- /dev/null +++ b/docs/rbac.md @@ -0,0 +1,76 @@ +# Role-Based Access Control Guide + +This guide describes how role-based access control (RBAC) is implemented in OpenCare-Core and how to work with the role metadata introduced for [issue #6](https://github.com/bos-com/OpenCare-Core/issues/6). + +## Personas & Capabilities + +| Role | Description | Example capabilities | +|------|-------------|----------------------| +| `admin` | Platform administrators with broad configuration rights. | Workforce management, system exports, metrics, any provider action. | +| `provider` | Clinicians and allied health staff interacting with patient data. | Patient CRUD, visit coordination, facility lookup. | +| `patient` | Individuals accessing their personal health data. | Future-facing patient portal access. Currently blocked from staff endpoints. | + +`admin` users implicitly inherit all provider privileges. Patients are intentionally restricted until patient-facing endpoints are introduced. + +## API Permission Matrix + +| Endpoint | Allowed roles | Notes | +|----------|---------------|-------| +| `/api/v1/patients/` | `admin`, `provider` | Patient management reserved for staff. | +| `/api/v1/health-workers/` | `admin` | Workforce administration only. | +| `/api/v1/facilities/` | `admin`, `provider` | Operational facility data. | +| `/api/v1/visits/` | `admin`, `provider` | Visit scheduling and tracking. | +| `/api/v1/records/` | `admin`, `provider` | Clinical records. Patient self-access is future work. | +| `/api/v1/audit-logs/` | `admin` | Audit trail access. | +| `/api/v1/stats/` | `admin` | Operational metrics. | +| `/api/v1/export/` | `admin` | Bulk data export. | +| `/api/v1/health/` | Public | Health check kept open for infra probes. | + +Additional viewsets should set a `required_roles` frozenset and include `RoleRequired` in `permission_classes` to join the RBAC system. + +## Assigning Roles + +Users now expose a `role` field on the Django admin and `User` model: + +- Admin UI: update the **Role** dropdown when editing a user. +- Shell example: + +```python +from django.contrib.auth import get_user_model +User = get_user_model() + +User.objects.filter(username="alice").update(role=User.Role.ADMIN) +``` + +Newly created users default to `provider`. A data migration upgrades existing superusers to `admin` automatically. + +## Enforcement Helpers + +- `apps.api.permissions.RoleRequired` checks the authenticated user's `role` against the target view's `required_roles` attribute (admins always pass). +- `apps.api.permissions.require_roles(*roles)` decorator attaches metadata for function-based views (e.g., `@require_roles(User.Role.ADMIN)` on `/api/v1/stats/`). +- For DRF viewsets, set `permission_classes = [IsAuthenticated, RoleRequired]` and define `required_roles`. + +## Testing + +Automated coverage lives in `apps/api/tests/test_rbac.py`: +- Confirms patients receive 403s on staff endpoints. +- Blocks providers from admin-only metrics. +- Allows admins to reach restricted APIs. +- Exercises the permission utility directly for provider access. + +Run the RBAC suite with: + +```bash +DJANGO_SETTINGS_MODULE=config.settings.development python3 manage.py test apps.api.tests.test_rbac +``` + +## Extending RBAC + +When introducing new endpoints: +1. Decide which personas should access the route. +2. Add `required_roles` and include `RoleRequired`. +3. Update this matrix if the endpoint is public or custom. +4. Add tests covering both successful and forbidden flows. + +For finer-grained object-level rules (e.g., patient-specific record access), extend the permissions module with object checks once domain models solidify. + diff --git a/env.example b/env.example new file mode 100644 index 0000000..19c894d --- /dev/null +++ b/env.example @@ -0,0 +1,53 @@ +# Django Configuration +SECRET_KEY=your-secret-key-here +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +# Database Configuration +DB_ENGINE=django.db.backends.postgresql +DB_NAME=opencare_africa +DB_USER=opencare_user +DB_PASSWORD=opencare_password +DB_HOST=localhost +DB_PORT=5432 + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 + +# Celery Configuration +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +# JWT Configuration +JWT_ACCESS_TOKEN_LIFETIME=5 +JWT_REFRESH_TOKEN_LIFETIME=1 + +# CORS Configuration +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 + +# Email Configuration +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +EMAIL_HOST=localhost +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= + +# Internationalization +LANGUAGE_CODE=en-us +TIME_ZONE=UTC +USE_I18N=True +USE_L10N=True +USE_TZ=True + +# Static and Media Files +STATIC_URL=/static/ +STATIC_ROOT=staticfiles +MEDIA_URL=/media/ +MEDIA_ROOT=media + +# Development Tools +USE_DEBUG_TOOLBAR=True +USE_SILK_PROFILER=False diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0341587 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,38 @@ +# Include production requirements +-r requirements.txt + +# Development Tools +ipython==8.17.2 +ipdb==0.13.13 +django-debug-toolbar==4.2.0 +django-extensions==3.2.3 + +# Code Quality & Linting +black==23.11.0 +flake8==6.1.0 +isort==5.12.0 +pre-commit==3.5.0 +bandit==1.7.5 + +# Testing & Coverage +pytest==7.4.3 +pytest-django==4.7.0 +pytest-cov==4.1.0 +pytest-mock==3.12.0 +factory-boy==3.3.0 +coverage==7.3.2 + +# Documentation +sphinx==7.2.6 +sphinx-rtd-theme==1.3.0 + +# Performance Testing +django-silk==5.0.4 + +# Security Testing +bandit==1.7.5 +safety>=2.4.0 + +# Local Development +python-dotenv==1.0.0 +django-extensions==3.2.3 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..669b78b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,67 @@ +# Core Django +Django==4.2.7 +djangorestframework==3.14.0 +django-cors-headers==4.3.1 +django-filter==23.3 +django-extensions==3.2.3 + +# Database +psycopg2-binary==2.9.7 +redis==5.0.1 + +# Authentication & Security +djangorestframework-simplejwt==5.3.0 +django-oauth-toolkit==2.2.0 +django-allauth==0.57.0 + +# API Documentation +drf-spectacular==0.26.5 +drf-yasg==1.21.7 + +# Health Data Standards +fhirclient==3.2.0 +hl7==0.4.5 + +# Data Processing & Analytics +pandas>=2.1.4 +numpy>=1.26.0 +scikit-learn==1.3.2 +matplotlib==3.8.2 +seaborn==0.13.0 + +# Task Queue +celery==5.3.4 +django-celery-beat==2.5.0 +django-celery-results==2.5.1 + +# Caching +django-redis==5.4.0 + +# File Handling +Pillow==10.1.0 +django-storages==1.14.2 + +# Environment & Configuration +python-decouple==3.8 +django-environ==0.11.2 + +# Testing +pytest==7.4.3 +pytest-django==4.7.0 +pytest-cov==4.1.0 +factory-boy==3.3.0 + +# Code Quality +black==23.11.0 +flake8==6.1.0 +isort==5.12.0 + +# Monitoring & Logging +sentry-sdk==1.38.0 +django-debug-toolbar==4.2.0 + +# Internationalization +django-modeltranslation==0.18.12 + +# Health Specific +django-health-check==3.17.0 diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..aa7ff14 --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,137 @@ +-- Database initialization script for OpenCare-Africa +-- This script sets up the initial database structure and sample data + +-- Create database if it doesn't exist +-- Note: This should be run as a superuser or the database should be created manually + +-- Create extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- Create custom types if needed +-- (Django will handle most type creation) + +-- Set timezone +SET timezone = 'UTC'; + +-- Create indexes for better performance +-- (Django will create most indexes automatically) + +-- Sample data for locations (optional - can be added through Django admin) +-- INSERT INTO core_location (name, location_type, parent_id, latitude, longitude) VALUES +-- ('Kenya', 'country', NULL, -1.2921, 36.8219), +-- ('Nairobi', 'region', 1, -1.2921, 36.8219), +-- ('Nairobi County', 'district', 2, -1.2921, 36.8219); + +-- Sample data for health facilities (optional - can be added through Django admin) +-- INSERT INTO core_healthfacility (name, facility_type, location_id, address, phone_number, contact_person_name, contact_person_phone) VALUES +-- ('Kenyatta National Hospital', 'hospital', 3, 'Hospital Road, Nairobi', '+254-20-2726300', 'Dr. John Doe', '+254-700-000000'); + +-- Create a superuser account (optional - can be created through Django management command) +-- Note: This is just a template - actual passwords should be set securely +-- INSERT INTO auth_user (username, password, first_name, last_name, email, is_staff, is_superuser, is_active, date_joined) VALUES +-- ('admin', 'pbkdf2_sha256$600000$...', 'Admin', 'User', 'admin@opencare-africa.com', true, true, true, NOW()); + +-- Grant necessary permissions +-- (Django will handle most permission setup) + +-- Set up database configuration +ALTER DATABASE opencare_africa SET timezone TO 'UTC'; +ALTER DATABASE opencare_africa SET datestyle TO 'ISO, MDY'; + +-- Create a read-only user for reporting (optional) +-- CREATE USER opencare_readonly WITH PASSWORD 'secure_password_here'; +-- GRANT CONNECT ON DATABASE opencare_africa TO opencare_readonly; +-- GRANT USAGE ON SCHEMA public TO opencare_readonly; +-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO opencare_readonly; +-- ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO opencare_readonly; + +-- Create a backup user (optional) +-- CREATE USER opencare_backup WITH PASSWORD 'secure_password_here'; +-- GRANT CONNECT ON DATABASE opencare_africa TO opencare_backup; +-- GRANT USAGE ON SCHEMA public TO opencare_backup; +-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO opencare_backup; + +-- Set up connection limits +ALTER USER opencare_user CONNECTION LIMIT 100; +-- ALTER USER opencare_readonly CONNECTION LIMIT 50; +-- ALTER USER opencare_backup CONNECTION LIMIT 10; + +-- Create tablespace for better performance (optional) +-- CREATE TABLESPACE opencare_data LOCATION '/var/lib/postgresql/data/opencare_data'; +-- CREATE TABLESPACE opencare_index LOCATION '/var/lib/postgresql/data/opencare_index'; + +-- Set default tablespace for new tables (optional) +-- SET default_tablespace = 'opencare_data'; + +-- Optimize database settings for health care applications +-- These settings can be adjusted based on server resources +ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements'; +ALTER SYSTEM SET max_connections = 200; +ALTER SYSTEM SET shared_buffers = '256MB'; +ALTER SYSTEM SET effective_cache_size = '1GB'; +ALTER SYSTEM SET maintenance_work_mem = '64MB'; +ALTER SYSTEM SET checkpoint_completion_target = 0.9; +ALTER SYSTEM SET wal_buffers = '16MB'; +ALTER SYSTEM SET default_statistics_target = 100; + +-- Reload configuration +SELECT pg_reload_conf(); + +-- Create initial audit trail entry +-- INSERT INTO core_audittrail (user_id, action, model_name, object_id, changes, timestamp) VALUES +-- (1, 'create', 'database', 'initialization', '{"message": "Database initialized successfully"}', NOW()); + +-- Set up monitoring and logging +-- Enable pg_stat_statements for query monitoring +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + +-- Create views for common queries (optional) +-- CREATE VIEW patient_summary AS +-- SELECT +-- p.id, +-- p.patient_id, +-- p.first_name, +-- p.last_name, +-- p.gender, +-- p.date_of_birth, +-- p.phone_number, +-- l.name as location_name, +-- hf.name as facility_name, +-- COUNT(pv.id) as visit_count +-- FROM patients_patient p +-- LEFT JOIN core_location l ON p.location_id = l.id +-- LEFT JOIN core_healthfacility hf ON p.registered_facility_id = hf.id +-- LEFT JOIN patients_patientvisit pv ON p.id = pv.patient_id +-- GROUP BY p.id, p.patient_id, p.first_name, p.last_name, p.gender, p.date_of_birth, p.phone_number, l.name, hf.name; + +-- Grant access to views +-- GRANT SELECT ON patient_summary TO opencare_user; +-- GRANT SELECT ON patient_summary TO opencare_readonly; + +-- Set up partitioning for large tables (optional - for future scalability) +-- This can be implemented later when the system grows + +-- Create indexes for common search patterns +-- These will be created automatically by Django, but can be optimized here if needed + +-- Set up full-text search (optional) +-- ALTER TABLE patients_patient ADD COLUMN search_vector tsvector; +-- CREATE INDEX patient_search_idx ON patients_patient USING gin(search_vector); +-- CREATE TRIGGER patient_search_update BEFORE INSERT OR UPDATE ON patients_patient +-- FOR EACH ROW EXECUTE FUNCTION tsvector_update_trigger(search_vector, 'pg_catalog.english', first_name, last_name, notes); + +-- Final setup +-- Update statistics +ANALYZE; + +-- Create a completion message +DO $$ +BEGIN + RAISE NOTICE 'Database initialization completed successfully!'; + RAISE NOTICE 'Next steps:'; + RAISE NOTICE '1. Run Django migrations: python manage.py migrate'; + RAISE NOTICE '2. Create superuser: python manage.py createsuperuser'; + RAISE NOTICE '3. Load initial data: python manage.py loaddata initial_data'; + RAISE NOTICE '4. Start the application: python manage.py runserver'; +END $$; diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f504772 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,168 @@ + + + + + + {% block title %}OpenCare-Africa{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + + + + +
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock %} +
+ + + + + + + + + + {% block extra_js %}{% endblock %} + + diff --git a/templates/core/dashboard.html b/templates/core/dashboard.html new file mode 100644 index 0000000..4767793 --- /dev/null +++ b/templates/core/dashboard.html @@ -0,0 +1,253 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Dashboard - OpenCare-Africa{% endblock %} + +{% block content %} +
+ +
+

Dashboard

+
+ Welcome back, {{ user.get_full_name|default:user.username }} +
+
+ + +
+
+
+
+
+
+
+ Total Patients +
+
{{ metrics.total_patients|default:"0" }}
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ Health Workers +
+
{{ metrics.total_health_workers|default:"0" }}
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ Health Facilities +
+
{{ metrics.total_facilities|default:"0" }}
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ Active Visits +
+
{{ metrics.active_visits|default:"0" }}
+
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+
+
Recent Activity
+
+
+
+
+
+ + New patient registered +
+ 2 hours ago +
+
+
+ + Patient visit completed +
+ 4 hours ago +
+
+
+ + New health worker added +
+ 1 day ago +
+
+
+ + Facility information updated +
+ 2 days ago +
+
+
+
+
+ + +
+
+
+
System Status
+
+
+
+
+ Database + Healthy +
+
+
+
+
+ +
+
+ Cache + Healthy +
+
+
+
+
+ +
+
+ Celery + Healthy +
+
+
+
+
+ +
+
+ External APIs + Healthy +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
API & Development
+
+
+
+
+
API Documentation
+

+ Access comprehensive API documentation with interactive examples and testing tools. +

+ + View API Docs + +
+
+
Health Check Endpoint
+

+ Monitor system health and performance metrics through our health check API. +

+ + Health Status + +
+
+
+
+
+
+
+{% endblock %} diff --git a/templates/core/home.html b/templates/core/home.html new file mode 100644 index 0000000..d7fea9f --- /dev/null +++ b/templates/core/home.html @@ -0,0 +1,190 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}OpenCare-Africa - Health Informatics Platform{% endblock %} + +{% block content %} +
+ +
+
+

+ OpenCare-Africa +

+

+ Empowering health systems across Africa with innovative technology solutions. + Our comprehensive health informatics platform provides the tools needed to + improve healthcare delivery, patient outcomes, and system efficiency. +

+
+ {% if user.is_authenticated %} + + Go to Dashboard + + {% else %} + + Login + + {% endif %} + + Learn More + +
+
+
+ Healthcare in Africa +
+
+ + +
+
+

Platform Features

+

Comprehensive tools for modern healthcare management

+
+ +
+
+
+
+ +
+
Patient Management
+

+ Comprehensive patient registration, medical history tracking, + and visit management with FHIR-compliant data structures. +

+
+
+
+ +
+
+
+
+ +
+
Health Worker Management
+

+ Manage healthcare professionals, track qualifications, + and monitor performance across facilities. +

+
+
+
+ +
+
+
+
+ +
+
Facility Management
+

+ Comprehensive health facility management including + services, operating hours, and resource allocation. +

+
+
+
+ +
+
+
+
+ +
+
Analytics & Reporting
+

+ Advanced analytics for health trends, disease surveillance, + and performance metrics with interactive dashboards. +

+
+
+
+ +
+
+
+
+ +
+
Mobile Support
+

+ Mobile-first design for community health workers + with offline capabilities and real-time synchronization. +

+
+
+
+ +
+
+
+
+ +
+
Security & Compliance
+

+ HIPAA and GDPR compliant with role-based access control, + audit trails, and encrypted data transmission. +

+
+
+
+
+ + +
+
+

Developer-Friendly API

+

RESTful API with comprehensive documentation

+
+
+
+
+
+
+
OpenCare-Africa API
+

+ Access our comprehensive health data through our RESTful API. + Built with Django REST Framework and following FHIR standards + for healthcare interoperability. +

+
    +
  • FHIR-compliant data structures
  • +
  • JWT authentication
  • +
  • Comprehensive documentation
  • +
  • Rate limiting and monitoring
  • +
+
+ +
+
+
+
+
+ + +
+
+

Ready to Transform Healthcare?

+

+ Join the OpenCare-Africa community and help build a healthier future for Africa. +

+ +
+
+
+{% endblock %}