v1.0.0-alpha
This commit is contained in:
commit
d38f804983
183
.github/workflows/ci-cd.yml
vendored
Normal file
183
.github/workflows/ci-cd.yml
vendored
Normal file
@ -0,0 +1,183 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: test_db
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Install Frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
|
||||
- name: Run Backend tests
|
||||
env:
|
||||
DEBUG: True
|
||||
SECRET_KEY: test-secret-key
|
||||
DB_NAME: test_db
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
DB_HOST: localhost
|
||||
DB_PORT: 5432
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
run: |
|
||||
python manage.py test
|
||||
|
||||
- name: Run Frontend tests
|
||||
run: |
|
||||
cd frontend
|
||||
npm run test:ci
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
# Backend linting
|
||||
pip install flake8 black isort
|
||||
black --check .
|
||||
isort --check-only .
|
||||
flake8 .
|
||||
|
||||
# Frontend linting
|
||||
cd frontend
|
||||
npm run lint
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push Frontend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.frontend
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Configure kubectl
|
||||
uses: azure/k8s-set-context@v3
|
||||
with:
|
||||
method: kubeconfig
|
||||
kubeconfig: ${{ secrets.KUBE_CONFIG }}
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
run: |
|
||||
# Update image tags in k8s files
|
||||
sed -i "s|dubai-analytics:latest|${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest|g" k8s/backend.yaml
|
||||
sed -i "s|dubai-analytics-frontend:latest|${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:latest|g" k8s/frontend.yaml
|
||||
|
||||
# Apply Kubernetes manifests
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
kubectl apply -f k8s/secret.yaml
|
||||
kubectl apply -f k8s/postgres.yaml
|
||||
kubectl apply -f k8s/redis.yaml
|
||||
kubectl apply -f k8s/backend.yaml
|
||||
kubectl apply -f k8s/frontend.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
|
||||
# Wait for deployment to be ready
|
||||
kubectl rollout status deployment/backend -n dubai-analytics
|
||||
kubectl rollout status deployment/frontend -n dubai-analytics
|
||||
|
||||
- name: Run database migrations
|
||||
run: |
|
||||
kubectl exec -n dubai-analytics deployment/backend -- python manage.py migrate
|
||||
kubectl exec -n dubai-analytics deployment/backend -- python manage.py import_csv_data --data-dir sample\ data
|
||||
397
.gitignore
vendored
Normal file
397
.gitignore
vendored
Normal file
@ -0,0 +1,397 @@
|
||||
# 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/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.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/
|
||||
cover/
|
||||
|
||||
# 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
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# 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
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be added to the global gitignore or merged into this project gitignore. For a PyCharm
|
||||
# project, it is recommended to include the following files:
|
||||
# .idea/
|
||||
# *.iml
|
||||
# *.ipr
|
||||
# *.iws
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.bak
|
||||
*.backup
|
||||
|
||||
# Static files (Django)
|
||||
staticfiles/
|
||||
static/
|
||||
|
||||
# Media files (Django)
|
||||
media/
|
||||
|
||||
# Django migrations (keep the migrations folder but ignore specific migration files if needed)
|
||||
# apps/*/migrations/*.py
|
||||
|
||||
# Kubernetes secrets
|
||||
*.secret
|
||||
secrets/
|
||||
|
||||
# Local development files
|
||||
local_settings.py
|
||||
local_config.py
|
||||
|
||||
# SSL certificates
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# Environment files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
|
||||
# Jupyter notebooks checkpoints
|
||||
.ipynb_checkpoints/
|
||||
|
||||
# Backup files
|
||||
*.orig
|
||||
*.rej
|
||||
|
||||
# Local data files (uncomment if you don't want to track data files)
|
||||
# data/
|
||||
# *.csv
|
||||
# *.json
|
||||
# *.xlsx
|
||||
|
||||
# Keep important project files
|
||||
!requirements.txt
|
||||
!requirements-simple.txt
|
||||
!package.json
|
||||
!package-lock.json
|
||||
!Dockerfile*
|
||||
!docker-compose.yml
|
||||
!.gitignore
|
||||
!README.md
|
||||
!manage.py
|
||||
!init.sql
|
||||
!nginx.conf
|
||||
!scripts/
|
||||
!k8s/
|
||||
!sample data/
|
||||
!frontend/src/
|
||||
!apps/
|
||||
!dubai_analytics/
|
||||
70
Dockerfile
Normal file
70
Dockerfile
Normal file
@ -0,0 +1,70 @@
|
||||
# Multi-stage build for Django backend
|
||||
FROM python:3.11-slim as backend
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# 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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy project
|
||||
COPY . .
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /app/logs /app/media /app/staticfiles
|
||||
|
||||
# Collect static files
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run the application
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "dubai_analytics.wsgi:application"]
|
||||
|
||||
# Frontend build stage
|
||||
FROM node:18-alpine as frontend
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built frontend
|
||||
COPY --from=frontend /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy backend static files
|
||||
COPY --from=backend /app/staticfiles /usr/share/nginx/html/static
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
19
Dockerfile.frontend
Normal file
19
Dockerfile.frontend
Normal file
@ -0,0 +1,19 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
||||
2
apps/__init__.py
Normal file
2
apps/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Apps package
|
||||
|
||||
2
apps/analytics/__init__.py
Normal file
2
apps/analytics/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Analytics app
|
||||
|
||||
8
apps/analytics/apps.py
Normal file
8
apps/analytics/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnalyticsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.analytics'
|
||||
verbose_name = 'Analytics'
|
||||
|
||||
2
apps/analytics/management/__init__.py
Normal file
2
apps/analytics/management/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Management commands
|
||||
|
||||
2
apps/analytics/management/commands/__init__.py
Normal file
2
apps/analytics/management/commands/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Management commands
|
||||
|
||||
455
apps/analytics/management/commands/import_csv_data.py
Normal file
455
apps/analytics/management/commands/import_csv_data.py
Normal file
@ -0,0 +1,455 @@
|
||||
"""
|
||||
Django management command to import CSV data into the database.
|
||||
"""
|
||||
import pandas as pd
|
||||
import os
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.conf import settings
|
||||
from apps.analytics.models import (
|
||||
Broker, Developer, Project, Land, Transaction, Valuation, Rent
|
||||
)
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Import CSV data from sample data directory'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--data-dir',
|
||||
type=str,
|
||||
default='sample data',
|
||||
help='Directory containing CSV files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Batch size for bulk operations'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--clear-existing',
|
||||
action='store_true',
|
||||
help='Clear existing data before import'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
data_dir = options['data_dir']
|
||||
batch_size = options['batch_size']
|
||||
clear_existing = options['clear_existing']
|
||||
|
||||
if not os.path.exists(data_dir):
|
||||
raise CommandError(f'Data directory {data_dir} does not exist')
|
||||
|
||||
if clear_existing:
|
||||
self.stdout.write('Clearing existing data...')
|
||||
self.clear_data()
|
||||
|
||||
try:
|
||||
self.import_brokers(data_dir, batch_size)
|
||||
self.import_developers(data_dir, batch_size)
|
||||
self.import_projects(data_dir, batch_size)
|
||||
self.import_lands(data_dir, batch_size)
|
||||
self.import_transactions(data_dir, batch_size)
|
||||
self.import_valuations(data_dir, batch_size)
|
||||
self.import_rents(data_dir, batch_size)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('Successfully imported all CSV data')
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error importing data: {e}')
|
||||
raise CommandError(f'Failed to import data: {e}')
|
||||
|
||||
def clear_data(self):
|
||||
"""Clear existing data from all tables."""
|
||||
with transaction.atomic():
|
||||
Rent.objects.all().delete()
|
||||
Valuation.objects.all().delete()
|
||||
Transaction.objects.all().delete()
|
||||
Land.objects.all().delete()
|
||||
Project.objects.all().delete()
|
||||
Developer.objects.all().delete()
|
||||
Broker.objects.all().delete()
|
||||
|
||||
def import_brokers(self, data_dir, batch_size):
|
||||
"""Import brokers data."""
|
||||
self.stdout.write('Importing brokers...')
|
||||
csv_path = os.path.join(data_dir, 'brokers.csv')
|
||||
|
||||
if not os.path.exists(csv_path):
|
||||
self.stdout.write(f'Warning: {csv_path} not found, skipping brokers')
|
||||
return
|
||||
|
||||
df = pd.read_csv(csv_path)
|
||||
brokers = []
|
||||
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
broker = Broker(
|
||||
broker_number=str(row['BROKER_NUMBER']),
|
||||
broker_name_en=row['BROKER_EN'],
|
||||
gender=row['GENDER_EN'],
|
||||
license_start_date=self.parse_datetime(row['LICENSE_START_DATE']),
|
||||
license_end_date=self.parse_datetime(row['LICENSE_END_DATE']),
|
||||
webpage=row['WEBPAGE'] if pd.notna(row['WEBPAGE']) else '',
|
||||
phone=row['PHONE'] if pd.notna(row['PHONE']) else '',
|
||||
fax=row['FAX'] if pd.notna(row['FAX']) else '',
|
||||
real_estate_number=row['REAL_ESTATE_NUMBER'] if pd.notna(row['REAL_ESTATE_NUMBER']) else '',
|
||||
real_estate_name_en=row['REAL_ESTATE_EN'] if pd.notna(row['REAL_ESTATE_EN']) else '',
|
||||
)
|
||||
brokers.append(broker)
|
||||
|
||||
if len(brokers) >= batch_size:
|
||||
Broker.objects.bulk_create(brokers, ignore_conflicts=True)
|
||||
brokers = []
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error importing broker row: {e}')
|
||||
continue
|
||||
|
||||
if brokers:
|
||||
Broker.objects.bulk_create(brokers, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'Imported {Broker.objects.count()} brokers')
|
||||
|
||||
def import_developers(self, data_dir, batch_size):
|
||||
"""Import developers data from projects."""
|
||||
self.stdout.write('Importing developers...')
|
||||
csv_path = os.path.join(data_dir, 'projects.csv')
|
||||
|
||||
if not os.path.exists(csv_path):
|
||||
self.stdout.write(f'Warning: {csv_path} not found, skipping developers')
|
||||
return
|
||||
|
||||
df = pd.read_csv(csv_path)
|
||||
developers = []
|
||||
seen_developers = set()
|
||||
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
dev_number = str(row['DEVELOPER_NUMBER'])
|
||||
if dev_number in seen_developers:
|
||||
continue
|
||||
|
||||
developer = Developer(
|
||||
developer_number=dev_number,
|
||||
developer_name_en=row['DEVELOPER_EN'],
|
||||
)
|
||||
developers.append(developer)
|
||||
seen_developers.add(dev_number)
|
||||
|
||||
if len(developers) >= batch_size:
|
||||
Developer.objects.bulk_create(developers, ignore_conflicts=True)
|
||||
developers = []
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error importing developer row: {e}')
|
||||
continue
|
||||
|
||||
if developers:
|
||||
Developer.objects.bulk_create(developers, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'Imported {Developer.objects.count()} developers')
|
||||
|
||||
def import_projects(self, data_dir, batch_size):
|
||||
"""Import projects data."""
|
||||
self.stdout.write('Importing projects...')
|
||||
csv_path = os.path.join(data_dir, 'projects.csv')
|
||||
|
||||
if not os.path.exists(csv_path):
|
||||
self.stdout.write(f'Warning: {csv_path} not found, skipping projects')
|
||||
return
|
||||
|
||||
df = pd.read_csv(csv_path)
|
||||
projects = []
|
||||
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
developer = Developer.objects.get(developer_number=str(row['DEVELOPER_NUMBER']))
|
||||
|
||||
project = Project(
|
||||
project_number=str(row['PROJECT_NUMBER']),
|
||||
project_name_en=row['PROJECT_EN'],
|
||||
developer=developer,
|
||||
start_date=self.parse_datetime(row['START_DATE']),
|
||||
end_date=self.parse_datetime(row['END_DATE']) if pd.notna(row['END_DATE']) else None,
|
||||
adoption_date=self.parse_datetime(row['ADOPTION_DATE']) if pd.notna(row['ADOPTION_DATE']) else None,
|
||||
project_type=row['PRJ_TYPE_EN'],
|
||||
project_value=self.parse_decimal(row['PROJECT_VALUE']) if pd.notna(row['PROJECT_VALUE']) else None,
|
||||
escrow_account_number=row['ESCROW_ACCOUNT_NUMBER'] if pd.notna(row['ESCROW_ACCOUNT_NUMBER']) else '',
|
||||
project_status=row['PROJECT_STATUS'],
|
||||
percent_completed=self.parse_decimal(row['PERCENT_COMPLETED']) if pd.notna(row['PERCENT_COMPLETED']) else None,
|
||||
inspection_date=self.parse_datetime(row['INSPECTION_DATE']) if pd.notna(row['INSPECTION_DATE']) else None,
|
||||
completion_date=self.parse_datetime(row['COMPLETION_DATE']) if pd.notna(row['COMPLETION_DATE']) else None,
|
||||
description_en=row['DESCRIPTION_EN'] if pd.notna(row['DESCRIPTION_EN']) else '',
|
||||
area_en=row['AREA_EN'],
|
||||
zone_en=row['ZONE_EN'],
|
||||
count_land=int(row['CNT_LAND']) if pd.notna(row['CNT_LAND']) else 0,
|
||||
count_building=int(row['CNT_BUILDING']) if pd.notna(row['CNT_BUILDING']) else 0,
|
||||
count_villa=int(row['CNT_VILLA']) if pd.notna(row['CNT_VILLA']) else 0,
|
||||
count_unit=int(row['CNT_UNIT']) if pd.notna(row['CNT_UNIT']) else 0,
|
||||
master_project_en=row['MASTER_PROJECT_EN'] if pd.notna(row['MASTER_PROJECT_EN']) else '',
|
||||
)
|
||||
projects.append(project)
|
||||
|
||||
if len(projects) >= batch_size:
|
||||
Project.objects.bulk_create(projects, ignore_conflicts=True)
|
||||
projects = []
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error importing project row: {e}')
|
||||
continue
|
||||
|
||||
if projects:
|
||||
Project.objects.bulk_create(projects, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'Imported {Project.objects.count()} projects')
|
||||
|
||||
def import_lands(self, data_dir, batch_size):
|
||||
"""Import lands data."""
|
||||
self.stdout.write('Importing lands...')
|
||||
csv_path = os.path.join(data_dir, 'lands.csv')
|
||||
|
||||
if not os.path.exists(csv_path):
|
||||
self.stdout.write(f'Warning: {csv_path} not found, skipping lands')
|
||||
return
|
||||
|
||||
df = pd.read_csv(csv_path)
|
||||
lands = []
|
||||
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
project = None
|
||||
if pd.notna(row['PROJECT_NUMBER']):
|
||||
try:
|
||||
project = Project.objects.get(project_number=str(row['PROJECT_NUMBER']))
|
||||
except Project.DoesNotExist:
|
||||
pass
|
||||
|
||||
land = Land(
|
||||
land_type=row['LAND_TYPE_EN'],
|
||||
property_sub_type=row['PROP_SUB_TYPE_EN'],
|
||||
actual_area=self.parse_decimal(row['ACTUAL_AREA']),
|
||||
is_offplan=row['IS_OFFPLAN_EN'],
|
||||
pre_registration_number=row['PRE_REGISTRATION_NUMBER'] if pd.notna(row['PRE_REGISTRATION_NUMBER']) else '',
|
||||
is_freehold=row['IS_FREE_HOLD_EN'],
|
||||
dm_zip_code=row['DM_ZIP_CODE'] if pd.notna(row['DM_ZIP_CODE']) else '',
|
||||
master_project=row['MASTER_PROJECT_EN'] if pd.notna(row['MASTER_PROJECT_EN']) else '',
|
||||
project=project,
|
||||
area_en=row['AREA_EN'],
|
||||
zone_en=row['ZONE_EN'],
|
||||
)
|
||||
lands.append(land)
|
||||
|
||||
if len(lands) >= batch_size:
|
||||
Land.objects.bulk_create(lands, ignore_conflicts=True)
|
||||
lands = []
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error importing land row: {e}')
|
||||
continue
|
||||
|
||||
if lands:
|
||||
Land.objects.bulk_create(lands, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'Imported {Land.objects.count()} lands')
|
||||
|
||||
def import_transactions(self, data_dir, batch_size):
|
||||
"""Import transactions data."""
|
||||
self.stdout.write('Importing transactions...')
|
||||
csv_path = os.path.join(data_dir, 'transactions.csv')
|
||||
|
||||
if not os.path.exists(csv_path):
|
||||
self.stdout.write(f'Warning: {csv_path} not found, skipping transactions')
|
||||
return
|
||||
|
||||
df = pd.read_csv(csv_path)
|
||||
transactions = []
|
||||
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
project = None
|
||||
if pd.notna(row['PROJECT_EN']):
|
||||
try:
|
||||
project = Project.objects.get(project_name_en=row['PROJECT_EN'])
|
||||
except Project.DoesNotExist:
|
||||
pass
|
||||
|
||||
transaction = Transaction(
|
||||
transaction_number=row['TRANSACTION_NUMBER'],
|
||||
instance_date=self.parse_datetime(row['INSTANCE_DATE']),
|
||||
group=row['GROUP_EN'],
|
||||
procedure=row['PROCEDURE_EN'],
|
||||
is_offplan=row['IS_OFFPLAN_EN'],
|
||||
is_freehold=row['IS_FREE_HOLD_EN'],
|
||||
usage=row['USAGE_EN'],
|
||||
area_en=row['AREA_EN'],
|
||||
property_type=row['PROP_TYPE_EN'],
|
||||
property_sub_type=row['PROP_SB_TYPE_EN'],
|
||||
transaction_value=self.parse_decimal(row['TRANS_VALUE']),
|
||||
procedure_area=self.parse_decimal(row['PROCEDURE_AREA']),
|
||||
actual_area=self.parse_decimal(row['ACTUAL_AREA']),
|
||||
rooms=row['ROOMS_EN'] if pd.notna(row['ROOMS_EN']) else '',
|
||||
parking=row['PARKING'] if pd.notna(row['PARKING']) else '',
|
||||
nearest_metro=row['NEAREST_METRO_EN'] if pd.notna(row['NEAREST_METRO_EN']) else '',
|
||||
nearest_mall=row['NEAREST_MALL_EN'] if pd.notna(row['NEAREST_MALL_EN']) else '',
|
||||
nearest_landmark=row['NEAREST_LANDMARK_EN'] if pd.notna(row['NEAREST_LANDMARK_EN']) else '',
|
||||
total_buyer=int(row['TOTAL_BUYER']) if pd.notna(row['TOTAL_BUYER']) else 0,
|
||||
total_seller=int(row['TOTAL_SELLER']) if pd.notna(row['TOTAL_SELLER']) else 0,
|
||||
master_project=row['MASTER_PROJECT_EN'] if pd.notna(row['MASTER_PROJECT_EN']) else '',
|
||||
project=project,
|
||||
)
|
||||
transactions.append(transaction)
|
||||
|
||||
if len(transactions) >= batch_size:
|
||||
Transaction.objects.bulk_create(transactions, ignore_conflicts=True)
|
||||
transactions = []
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error importing transaction row: {e}')
|
||||
continue
|
||||
|
||||
if transactions:
|
||||
Transaction.objects.bulk_create(transactions, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'Imported {Transaction.objects.count()} transactions')
|
||||
|
||||
def import_valuations(self, data_dir, batch_size):
|
||||
"""Import valuations data."""
|
||||
self.stdout.write('Importing valuations...')
|
||||
csv_path = os.path.join(data_dir, 'valuations.csv')
|
||||
|
||||
if not os.path.exists(csv_path):
|
||||
self.stdout.write(f'Warning: {csv_path} not found, skipping valuations')
|
||||
return
|
||||
|
||||
df = pd.read_csv(csv_path)
|
||||
valuations = []
|
||||
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
valuation = Valuation(
|
||||
property_total_value=self.parse_decimal(row['PROPERTY_TOTAL_VALUE']),
|
||||
area_en=row['AREA_EN'],
|
||||
actual_area=self.parse_decimal(row['ACTUAL_AREA']),
|
||||
procedure_year=int(row['PROCEDURE_YEAR']),
|
||||
procedure_number=str(row['PROCEDURE_NUMBER']),
|
||||
instance_date=self.parse_datetime(row['INSTANCE_DATE']),
|
||||
actual_worth=self.parse_decimal(row['ACTUAL_WORTH']),
|
||||
procedure_area=self.parse_decimal(row['PROCEDURE_AREA']),
|
||||
property_type=row['PROPERTY_TYPE_EN'],
|
||||
property_sub_type=row['PROP_SUB_TYPE_EN'],
|
||||
)
|
||||
valuations.append(valuation)
|
||||
|
||||
if len(valuations) >= batch_size:
|
||||
Valuation.objects.bulk_create(valuations, ignore_conflicts=True)
|
||||
valuations = []
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error importing valuation row: {e}')
|
||||
continue
|
||||
|
||||
if valuations:
|
||||
Valuation.objects.bulk_create(valuations, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'Imported {Valuation.objects.count()} valuations')
|
||||
|
||||
def import_rents(self, data_dir, batch_size):
|
||||
"""Import rents data."""
|
||||
self.stdout.write('Importing rents...')
|
||||
csv_path = os.path.join(data_dir, 'rents.csv')
|
||||
|
||||
if not os.path.exists(csv_path):
|
||||
self.stdout.write(f'Warning: {csv_path} not found, skipping rents')
|
||||
return
|
||||
|
||||
df = pd.read_csv(csv_path)
|
||||
rents = []
|
||||
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
project = None
|
||||
if pd.notna(row['PROJECT_EN']):
|
||||
try:
|
||||
project = Project.objects.get(project_name_en=row['PROJECT_EN'])
|
||||
except Project.DoesNotExist:
|
||||
pass
|
||||
|
||||
rent = Rent(
|
||||
registration_date=self.parse_datetime(row['REGISTRATION_DATE']),
|
||||
start_date=self.parse_datetime(row['START_DATE']),
|
||||
end_date=self.parse_datetime(row['END_DATE']),
|
||||
version=row['VERSION_EN'],
|
||||
area_en=row['AREA_EN'],
|
||||
contract_amount=self.parse_decimal(row['CONTRACT_AMOUNT']),
|
||||
annual_amount=self.parse_decimal(row['ANNUAL_AMOUNT']),
|
||||
is_freehold=row['IS_FREE_HOLD_EN'],
|
||||
actual_area=self.parse_decimal(row['ACTUAL_AREA']),
|
||||
property_type=row['PROP_TYPE_EN'],
|
||||
property_sub_type=row['PROP_SUB_TYPE_EN'],
|
||||
rooms=row['ROOMS'] if pd.notna(row['ROOMS']) else '',
|
||||
usage=row['USAGE_EN'],
|
||||
nearest_metro=row['NEAREST_METRO_EN'] if pd.notna(row['NEAREST_METRO_EN']) else '',
|
||||
nearest_mall=row['NEAREST_MALL_EN'] if pd.notna(row['NEAREST_MALL_EN']) else '',
|
||||
nearest_landmark=row['NEAREST_LANDMARK_EN'] if pd.notna(row['NEAREST_LANDMARK_EN']) else '',
|
||||
parking=row['PARKING'] if pd.notna(row['PARKING']) else '',
|
||||
total_properties=int(row['TOTAL_PROPERTIES']) if pd.notna(row['TOTAL_PROPERTIES']) else 1,
|
||||
master_project=row['MASTER_PROJECT_EN'] if pd.notna(row['MASTER_PROJECT_EN']) else '',
|
||||
project=project,
|
||||
)
|
||||
rents.append(rent)
|
||||
|
||||
if len(rents) >= batch_size:
|
||||
Rent.objects.bulk_create(rents, ignore_conflicts=True)
|
||||
rents = []
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error importing rent row: {e}')
|
||||
continue
|
||||
|
||||
if rents:
|
||||
Rent.objects.bulk_create(rents, ignore_conflicts=True)
|
||||
|
||||
self.stdout.write(f'Imported {Rent.objects.count()} rents')
|
||||
|
||||
def parse_datetime(self, value):
|
||||
"""Parse datetime string."""
|
||||
if pd.isna(value) or value == '' or value == 'NULL':
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try different datetime formats
|
||||
formats = [
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%d',
|
||||
'%d/%m/%Y %H:%M:%S',
|
||||
'%d/%m/%Y',
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(str(value), fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# If all formats fail, try pandas parsing
|
||||
return pd.to_datetime(value)
|
||||
except:
|
||||
return None
|
||||
|
||||
def parse_decimal(self, value):
|
||||
"""Parse decimal value."""
|
||||
if pd.isna(value) or value == '' or value == 'NULL':
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(value)
|
||||
except:
|
||||
return None
|
||||
|
||||
278
apps/analytics/migrations/0001_initial.py
Normal file
278
apps/analytics/migrations/0001_initial.py
Normal file
@ -0,0 +1,278 @@
|
||||
# Generated by Django 4.2.24 on 2025-09-16 18:51
|
||||
|
||||
import django.contrib.postgres.indexes
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Developer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('developer_number', models.CharField(db_index=True, max_length=20, unique=True)),
|
||||
('developer_name_en', models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'developers',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Project',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('project_number', models.CharField(db_index=True, max_length=20, unique=True)),
|
||||
('project_name_en', models.CharField(max_length=255)),
|
||||
('start_date', models.DateTimeField()),
|
||||
('end_date', models.DateTimeField(blank=True, null=True)),
|
||||
('adoption_date', models.DateTimeField(blank=True, null=True)),
|
||||
('project_type', models.CharField(choices=[('Normal', 'Normal'), ('Escrow', 'Escrow')], max_length=50)),
|
||||
('project_value', models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True)),
|
||||
('escrow_account_number', models.CharField(blank=True, max_length=50)),
|
||||
('project_status', models.CharField(choices=[('ACTIVE', 'Active'), ('PENDING', 'Pending'), ('CANCELLED', 'Cancelled'), ('COMPLETED', 'Completed')], max_length=20)),
|
||||
('percent_completed', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||
('inspection_date', models.DateTimeField(blank=True, null=True)),
|
||||
('completion_date', models.DateTimeField(blank=True, null=True)),
|
||||
('description_en', models.TextField(blank=True)),
|
||||
('area_en', models.CharField(max_length=100)),
|
||||
('zone_en', models.CharField(max_length=100)),
|
||||
('count_land', models.IntegerField(default=0)),
|
||||
('count_building', models.IntegerField(default=0)),
|
||||
('count_villa', models.IntegerField(default=0)),
|
||||
('count_unit', models.IntegerField(default=0)),
|
||||
('master_project_en', models.CharField(blank=True, max_length=255)),
|
||||
('developer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='analytics.developer')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'projects',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Valuation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('property_total_value', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('area_en', models.CharField(max_length=100)),
|
||||
('actual_area', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('procedure_year', models.IntegerField()),
|
||||
('procedure_number', models.CharField(max_length=20)),
|
||||
('instance_date', models.DateTimeField()),
|
||||
('actual_worth', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('procedure_area', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('property_type', models.CharField(choices=[('Unit', 'Unit'), ('Villa', 'Villa'), ('Land', 'Land'), ('Building', 'Building')], max_length=50)),
|
||||
('property_sub_type', models.CharField(max_length=50)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'valuations',
|
||||
'indexes': [django.contrib.postgres.indexes.BTreeIndex(fields=['area_en'], name='valuations_area_en_ed1677_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['property_type'], name='valuations_propert_180e4b_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['procedure_year'], name='valuations_procedu_3bf0d9_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['instance_date'], name='valuations_instanc_68649f_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['property_total_value'], name='valuations_propert_e9ba02_btree')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MarketTrend',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('area_en', models.CharField(max_length=100)),
|
||||
('property_type', models.CharField(max_length=50)),
|
||||
('period_start', models.DateTimeField()),
|
||||
('period_end', models.DateTimeField()),
|
||||
('average_price', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('median_price', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('price_change_percent', models.DecimalField(decimal_places=2, max_digits=5)),
|
||||
('transaction_count', models.IntegerField()),
|
||||
('volume', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('price_per_sqft', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'market_trends',
|
||||
'indexes': [django.contrib.postgres.indexes.BTreeIndex(fields=['area_en'], name='market_tren_area_en_f508af_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['property_type'], name='market_tren_propert_cc3ff3_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['period_start'], name='market_tren_period__1c6afd_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['period_end'], name='market_tren_period__fdd6ac_btree')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Land',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('land_type', models.CharField(choices=[('Agricultural', 'Agricultural'), ('Commercial', 'Commercial'), ('Residential', 'Residential'), ('Industrial', 'Industrial')], max_length=50)),
|
||||
('property_sub_type', models.CharField(max_length=50)),
|
||||
('actual_area', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('is_offplan', models.CharField(choices=[('Ready', 'Ready'), ('Off-Plan', 'Off-Plan')], max_length=10)),
|
||||
('pre_registration_number', models.CharField(blank=True, max_length=50)),
|
||||
('is_freehold', models.CharField(choices=[('Free Hold', 'Free Hold'), ('Non Free Hold', 'Non Free Hold')], max_length=20)),
|
||||
('dm_zip_code', models.CharField(blank=True, max_length=10)),
|
||||
('master_project', models.CharField(blank=True, max_length=255)),
|
||||
('area_en', models.CharField(max_length=100)),
|
||||
('zone_en', models.CharField(max_length=100)),
|
||||
('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lands', to='analytics.project')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'lands',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Forecast',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('area_en', models.CharField(max_length=100)),
|
||||
('property_type', models.CharField(max_length=50)),
|
||||
('property_sub_type', models.CharField(blank=True, max_length=50)),
|
||||
('forecast_date', models.DateTimeField()),
|
||||
('predicted_price', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('confidence_interval_lower', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('confidence_interval_upper', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('model_version', models.CharField(default='1.0', max_length=20)),
|
||||
('accuracy_score', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True)),
|
||||
('metadata', models.JSONField(default=dict)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'forecasts',
|
||||
'indexes': [django.contrib.postgres.indexes.BTreeIndex(fields=['area_en'], name='forecasts_area_en_11cdb4_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['property_type'], name='forecasts_propert_ba8084_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['forecast_date'], name='forecasts_forecas_5092f3_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['predicted_price'], name='forecasts_predict_dd61f3_btree')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Broker',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('broker_number', models.CharField(db_index=True, max_length=20, unique=True)),
|
||||
('broker_name_en', models.CharField(max_length=255)),
|
||||
('gender', models.CharField(choices=[('male', 'Male'), ('female', 'Female'), ('أنثى', 'Female (Arabic)')], max_length=10)),
|
||||
('license_start_date', models.DateTimeField()),
|
||||
('license_end_date', models.DateTimeField()),
|
||||
('webpage', models.URLField(blank=True, null=True)),
|
||||
('phone', models.CharField(blank=True, max_length=50)),
|
||||
('fax', models.CharField(blank=True, max_length=50)),
|
||||
('real_estate_number', models.CharField(blank=True, max_length=20)),
|
||||
('real_estate_name_en', models.CharField(blank=True, max_length=255)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'brokers',
|
||||
'indexes': [django.contrib.postgres.indexes.BTreeIndex(fields=['broker_number'], name='brokers_broker__af1383_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['real_estate_name_en'], name='brokers_real_es_8151b8_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['license_end_date'], name='brokers_license_364105_btree')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('transaction_number', models.CharField(db_index=True, max_length=50, unique=True)),
|
||||
('instance_date', models.DateTimeField()),
|
||||
('group', models.CharField(choices=[('Mortgage', 'Mortgage'), ('Sale', 'Sale'), ('Rent', 'Rent'), ('Other', 'Other')], max_length=50)),
|
||||
('procedure', models.CharField(max_length=100)),
|
||||
('is_offplan', models.CharField(choices=[('Off-Plan', 'Off-Plan'), ('Ready', 'Ready')], max_length=10)),
|
||||
('is_freehold', models.CharField(choices=[('Free Hold', 'Free Hold'), ('Non Free Hold', 'Non Free Hold')], max_length=20)),
|
||||
('usage', models.CharField(choices=[('Residential', 'Residential'), ('Commercial', 'Commercial'), ('Industrial', 'Industrial'), ('Mixed', 'Mixed')], max_length=50)),
|
||||
('area_en', models.CharField(max_length=100)),
|
||||
('property_type', models.CharField(choices=[('Unit', 'Unit'), ('Villa', 'Villa'), ('Land', 'Land'), ('Building', 'Building')], max_length=50)),
|
||||
('property_sub_type', models.CharField(max_length=50)),
|
||||
('transaction_value', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('procedure_area', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('actual_area', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('rooms', models.CharField(blank=True, max_length=20)),
|
||||
('parking', models.CharField(blank=True, max_length=20)),
|
||||
('nearest_metro', models.CharField(blank=True, max_length=100)),
|
||||
('nearest_mall', models.CharField(blank=True, max_length=100)),
|
||||
('nearest_landmark', models.CharField(blank=True, max_length=100)),
|
||||
('total_buyer', models.IntegerField(default=0)),
|
||||
('total_seller', models.IntegerField(default=0)),
|
||||
('master_project', models.CharField(blank=True, max_length=255)),
|
||||
('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='analytics.project')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'transactions',
|
||||
'indexes': [django.contrib.postgres.indexes.BTreeIndex(fields=['transaction_number'], name='transaction_transac_cb7167_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['instance_date'], name='transaction_instanc_860e4d_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['area_en'], name='transaction_area_en_32b046_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['property_type'], name='transaction_propert_050c21_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['transaction_value'], name='transaction_transac_ced5e2_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['group'], name='transaction_group_dff2f7_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['usage'], name='transaction_usage_cbb322_btree')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Rent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('registration_date', models.DateTimeField()),
|
||||
('start_date', models.DateTimeField()),
|
||||
('end_date', models.DateTimeField()),
|
||||
('version', models.CharField(choices=[('New', 'New'), ('Renewed', 'Renewed')], max_length=20)),
|
||||
('area_en', models.CharField(max_length=100)),
|
||||
('contract_amount', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('annual_amount', models.DecimalField(decimal_places=2, max_digits=15)),
|
||||
('is_freehold', models.CharField(choices=[('Free Hold', 'Free Hold'), ('Non Free Hold', 'Non Free Hold')], max_length=20)),
|
||||
('actual_area', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('property_type', models.CharField(choices=[('Unit', 'Unit'), ('Villa', 'Villa'), ('Land', 'Land'), ('Building', 'Building')], max_length=50)),
|
||||
('property_sub_type', models.CharField(max_length=50)),
|
||||
('rooms', models.CharField(blank=True, max_length=20)),
|
||||
('usage', models.CharField(choices=[('Residential', 'Residential'), ('Commercial', 'Commercial'), ('Industrial', 'Industrial'), ('Mixed', 'Mixed')], max_length=50)),
|
||||
('nearest_metro', models.CharField(blank=True, max_length=100)),
|
||||
('nearest_mall', models.CharField(blank=True, max_length=100)),
|
||||
('nearest_landmark', models.CharField(blank=True, max_length=100)),
|
||||
('parking', models.CharField(blank=True, max_length=20)),
|
||||
('total_properties', models.IntegerField(default=1)),
|
||||
('master_project', models.CharField(blank=True, max_length=255)),
|
||||
('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rents', to='analytics.project')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'rents',
|
||||
'indexes': [django.contrib.postgres.indexes.BTreeIndex(fields=['area_en'], name='rents_area_en_65e0af_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['property_type'], name='rents_propert_26942d_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['registration_date'], name='rents_registr_f97ec7_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['start_date'], name='rents_start_d_2ade73_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['end_date'], name='rents_end_dat_a35991_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['contract_amount'], name='rents_contrac_e5a0c2_btree'), django.contrib.postgres.indexes.BTreeIndex(fields=['annual_amount'], name='rents_annual__9b7544_btree')],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='project',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['project_number'], name='projects_project_aade24_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='project',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['project_status'], name='projects_project_88dc90_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='project',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['area_en'], name='projects_area_en_699858_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='project',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['zone_en'], name='projects_zone_en_f0caa4_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='project',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['developer'], name='projects_develop_2fe1f2_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='land',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['land_type'], name='lands_land_ty_91d1c0_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='land',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['area_en'], name='lands_area_en_048760_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='land',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['zone_en'], name='lands_zone_en_6165db_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='land',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['is_freehold'], name='lands_is_free_d51a4a_btree'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='land',
|
||||
index=django.contrib.postgres.indexes.BTreeIndex(fields=['actual_area'], name='lands_actual__3a7c21_btree'),
|
||||
),
|
||||
]
|
||||
0
apps/analytics/migrations/__init__.py
Normal file
0
apps/analytics/migrations/__init__.py
Normal file
351
apps/analytics/models.py
Normal file
351
apps/analytics/models.py
Normal file
@ -0,0 +1,351 @@
|
||||
"""
|
||||
Analytics models for Dubai Land Department data.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.postgres.indexes import BTreeIndex
|
||||
# JSONField is now in django.db.models in Django 3.1+
|
||||
from apps.core.models import TimeStampedModel
|
||||
import uuid
|
||||
|
||||
|
||||
class Broker(TimeStampedModel):
|
||||
"""Real estate brokers data."""
|
||||
broker_number = models.CharField(max_length=20, unique=True, db_index=True)
|
||||
broker_name_en = models.CharField(max_length=255)
|
||||
gender = models.CharField(max_length=10, choices=[
|
||||
('male', 'Male'),
|
||||
('female', 'Female'),
|
||||
('أنثى', 'Female (Arabic)'),
|
||||
])
|
||||
license_start_date = models.DateTimeField()
|
||||
license_end_date = models.DateTimeField()
|
||||
webpage = models.URLField(blank=True, null=True)
|
||||
phone = models.CharField(max_length=50, blank=True)
|
||||
fax = models.CharField(max_length=50, blank=True)
|
||||
real_estate_number = models.CharField(max_length=20, blank=True)
|
||||
real_estate_name_en = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'brokers'
|
||||
indexes = [
|
||||
BTreeIndex(fields=['broker_number']),
|
||||
BTreeIndex(fields=['real_estate_name_en']),
|
||||
BTreeIndex(fields=['license_end_date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.broker_name_en} ({self.broker_number})"
|
||||
|
||||
|
||||
class Developer(TimeStampedModel):
|
||||
"""Real estate developers data."""
|
||||
developer_number = models.CharField(max_length=20, unique=True, db_index=True)
|
||||
developer_name_en = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
db_table = 'developers'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.developer_name_en} ({self.developer_number})"
|
||||
|
||||
|
||||
class Project(TimeStampedModel):
|
||||
"""Real estate projects data."""
|
||||
project_number = models.CharField(max_length=20, unique=True, db_index=True)
|
||||
project_name_en = models.CharField(max_length=255)
|
||||
developer = models.ForeignKey(Developer, on_delete=models.CASCADE, related_name='projects')
|
||||
start_date = models.DateTimeField()
|
||||
end_date = models.DateTimeField(null=True, blank=True)
|
||||
adoption_date = models.DateTimeField(null=True, blank=True)
|
||||
project_type = models.CharField(max_length=50, choices=[
|
||||
('Normal', 'Normal'),
|
||||
('Escrow', 'Escrow'),
|
||||
])
|
||||
project_value = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True)
|
||||
escrow_account_number = models.CharField(max_length=50, blank=True)
|
||||
project_status = models.CharField(max_length=20, choices=[
|
||||
('ACTIVE', 'Active'),
|
||||
('PENDING', 'Pending'),
|
||||
('CANCELLED', 'Cancelled'),
|
||||
('COMPLETED', 'Completed'),
|
||||
])
|
||||
percent_completed = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||
inspection_date = models.DateTimeField(null=True, blank=True)
|
||||
completion_date = models.DateTimeField(null=True, blank=True)
|
||||
description_en = models.TextField(blank=True)
|
||||
area_en = models.CharField(max_length=100)
|
||||
zone_en = models.CharField(max_length=100)
|
||||
count_land = models.IntegerField(default=0)
|
||||
count_building = models.IntegerField(default=0)
|
||||
count_villa = models.IntegerField(default=0)
|
||||
count_unit = models.IntegerField(default=0)
|
||||
master_project_en = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'projects'
|
||||
indexes = [
|
||||
BTreeIndex(fields=['project_number']),
|
||||
BTreeIndex(fields=['project_status']),
|
||||
BTreeIndex(fields=['area_en']),
|
||||
BTreeIndex(fields=['zone_en']),
|
||||
BTreeIndex(fields=['developer']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.project_name_en} ({self.project_number})"
|
||||
|
||||
|
||||
class Land(TimeStampedModel):
|
||||
"""Land data."""
|
||||
land_type = models.CharField(max_length=50, choices=[
|
||||
('Agricultural', 'Agricultural'),
|
||||
('Commercial', 'Commercial'),
|
||||
('Residential', 'Residential'),
|
||||
('Industrial', 'Industrial'),
|
||||
])
|
||||
property_sub_type = models.CharField(max_length=50)
|
||||
actual_area = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
is_offplan = models.CharField(max_length=10, choices=[
|
||||
('Ready', 'Ready'),
|
||||
('Off-Plan', 'Off-Plan'),
|
||||
])
|
||||
pre_registration_number = models.CharField(max_length=50, blank=True)
|
||||
is_freehold = models.CharField(max_length=20, choices=[
|
||||
('Free Hold', 'Free Hold'),
|
||||
('Non Free Hold', 'Non Free Hold'),
|
||||
])
|
||||
dm_zip_code = models.CharField(max_length=10, blank=True)
|
||||
master_project = models.CharField(max_length=255, blank=True)
|
||||
project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='lands')
|
||||
area_en = models.CharField(max_length=100)
|
||||
zone_en = models.CharField(max_length=100)
|
||||
|
||||
class Meta:
|
||||
db_table = 'lands'
|
||||
indexes = [
|
||||
BTreeIndex(fields=['land_type']),
|
||||
BTreeIndex(fields=['area_en']),
|
||||
BTreeIndex(fields=['zone_en']),
|
||||
BTreeIndex(fields=['is_freehold']),
|
||||
BTreeIndex(fields=['actual_area']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.area_en} - {self.land_type} ({self.actual_area} sqft)"
|
||||
|
||||
|
||||
class Transaction(TimeStampedModel):
|
||||
"""Real estate transactions data."""
|
||||
transaction_number = models.CharField(max_length=50, unique=True, db_index=True)
|
||||
instance_date = models.DateTimeField()
|
||||
group = models.CharField(max_length=50, choices=[
|
||||
('Mortgage', 'Mortgage'),
|
||||
('Sale', 'Sale'),
|
||||
('Rent', 'Rent'),
|
||||
('Other', 'Other'),
|
||||
])
|
||||
procedure = models.CharField(max_length=100)
|
||||
is_offplan = models.CharField(max_length=10, choices=[
|
||||
('Off-Plan', 'Off-Plan'),
|
||||
('Ready', 'Ready'),
|
||||
])
|
||||
is_freehold = models.CharField(max_length=20, choices=[
|
||||
('Free Hold', 'Free Hold'),
|
||||
('Non Free Hold', 'Non Free Hold'),
|
||||
])
|
||||
usage = models.CharField(max_length=50, choices=[
|
||||
('Residential', 'Residential'),
|
||||
('Commercial', 'Commercial'),
|
||||
('Industrial', 'Industrial'),
|
||||
('Mixed', 'Mixed'),
|
||||
])
|
||||
area_en = models.CharField(max_length=100)
|
||||
property_type = models.CharField(max_length=50, choices=[
|
||||
('Unit', 'Unit'),
|
||||
('Villa', 'Villa'),
|
||||
('Land', 'Land'),
|
||||
('Building', 'Building'),
|
||||
])
|
||||
property_sub_type = models.CharField(max_length=50)
|
||||
transaction_value = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
procedure_area = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
actual_area = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
rooms = models.CharField(max_length=20, blank=True)
|
||||
parking = models.CharField(max_length=20, blank=True)
|
||||
nearest_metro = models.CharField(max_length=100, blank=True)
|
||||
nearest_mall = models.CharField(max_length=100, blank=True)
|
||||
nearest_landmark = models.CharField(max_length=100, blank=True)
|
||||
total_buyer = models.IntegerField(default=0)
|
||||
total_seller = models.IntegerField(default=0)
|
||||
master_project = models.CharField(max_length=255, blank=True)
|
||||
project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='transactions')
|
||||
|
||||
class Meta:
|
||||
db_table = 'transactions'
|
||||
indexes = [
|
||||
BTreeIndex(fields=['transaction_number']),
|
||||
BTreeIndex(fields=['instance_date']),
|
||||
BTreeIndex(fields=['area_en']),
|
||||
BTreeIndex(fields=['property_type']),
|
||||
BTreeIndex(fields=['transaction_value']),
|
||||
BTreeIndex(fields=['group']),
|
||||
BTreeIndex(fields=['usage']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.transaction_number} - {self.area_en} - {self.transaction_value}"
|
||||
|
||||
@property
|
||||
def price_per_sqft(self):
|
||||
"""Calculate price per square foot."""
|
||||
if self.actual_area and self.actual_area > 0:
|
||||
return self.transaction_value / self.actual_area
|
||||
return None
|
||||
|
||||
|
||||
class Valuation(TimeStampedModel):
|
||||
"""Property valuations data."""
|
||||
property_total_value = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
area_en = models.CharField(max_length=100)
|
||||
actual_area = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
procedure_year = models.IntegerField()
|
||||
procedure_number = models.CharField(max_length=20)
|
||||
instance_date = models.DateTimeField()
|
||||
actual_worth = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
procedure_area = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
property_type = models.CharField(max_length=50, choices=[
|
||||
('Unit', 'Unit'),
|
||||
('Villa', 'Villa'),
|
||||
('Land', 'Land'),
|
||||
('Building', 'Building'),
|
||||
])
|
||||
property_sub_type = models.CharField(max_length=50)
|
||||
|
||||
class Meta:
|
||||
db_table = 'valuations'
|
||||
indexes = [
|
||||
BTreeIndex(fields=['area_en']),
|
||||
BTreeIndex(fields=['property_type']),
|
||||
BTreeIndex(fields=['procedure_year']),
|
||||
BTreeIndex(fields=['instance_date']),
|
||||
BTreeIndex(fields=['property_total_value']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.area_en} - {self.property_type} - {self.property_total_value}"
|
||||
|
||||
|
||||
class Rent(TimeStampedModel):
|
||||
"""Rental data."""
|
||||
registration_date = models.DateTimeField()
|
||||
start_date = models.DateTimeField()
|
||||
end_date = models.DateTimeField()
|
||||
version = models.CharField(max_length=20, choices=[
|
||||
('New', 'New'),
|
||||
('Renewed', 'Renewed'),
|
||||
])
|
||||
area_en = models.CharField(max_length=100)
|
||||
contract_amount = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
annual_amount = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
is_freehold = models.CharField(max_length=20, choices=[
|
||||
('Free Hold', 'Free Hold'),
|
||||
('Non Free Hold', 'Non Free Hold'),
|
||||
])
|
||||
actual_area = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
property_type = models.CharField(max_length=50, choices=[
|
||||
('Unit', 'Unit'),
|
||||
('Villa', 'Villa'),
|
||||
('Land', 'Land'),
|
||||
('Building', 'Building'),
|
||||
])
|
||||
property_sub_type = models.CharField(max_length=50)
|
||||
rooms = models.CharField(max_length=20, blank=True)
|
||||
usage = models.CharField(max_length=50, choices=[
|
||||
('Residential', 'Residential'),
|
||||
('Commercial', 'Commercial'),
|
||||
('Industrial', 'Industrial'),
|
||||
('Mixed', 'Mixed'),
|
||||
])
|
||||
nearest_metro = models.CharField(max_length=100, blank=True)
|
||||
nearest_mall = models.CharField(max_length=100, blank=True)
|
||||
nearest_landmark = models.CharField(max_length=100, blank=True)
|
||||
parking = models.CharField(max_length=20, blank=True)
|
||||
total_properties = models.IntegerField(default=1)
|
||||
master_project = models.CharField(max_length=255, blank=True)
|
||||
project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='rents')
|
||||
|
||||
class Meta:
|
||||
db_table = 'rents'
|
||||
indexes = [
|
||||
BTreeIndex(fields=['area_en']),
|
||||
BTreeIndex(fields=['property_type']),
|
||||
BTreeIndex(fields=['registration_date']),
|
||||
BTreeIndex(fields=['start_date']),
|
||||
BTreeIndex(fields=['end_date']),
|
||||
BTreeIndex(fields=['contract_amount']),
|
||||
BTreeIndex(fields=['annual_amount']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.area_en} - {self.property_type} - {self.annual_amount}"
|
||||
|
||||
@property
|
||||
def rent_per_sqft(self):
|
||||
"""Calculate rent per square foot."""
|
||||
if self.actual_area and self.actual_area > 0:
|
||||
return self.annual_amount / self.actual_area
|
||||
return None
|
||||
|
||||
|
||||
class Forecast(TimeStampedModel):
|
||||
"""Property price forecasts."""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
area_en = models.CharField(max_length=100)
|
||||
property_type = models.CharField(max_length=50)
|
||||
property_sub_type = models.CharField(max_length=50, blank=True)
|
||||
forecast_date = models.DateTimeField()
|
||||
predicted_price = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
confidence_interval_lower = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
confidence_interval_upper = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
model_version = models.CharField(max_length=20, default='1.0')
|
||||
accuracy_score = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True)
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
db_table = 'forecasts'
|
||||
indexes = [
|
||||
BTreeIndex(fields=['area_en']),
|
||||
BTreeIndex(fields=['property_type']),
|
||||
BTreeIndex(fields=['forecast_date']),
|
||||
BTreeIndex(fields=['predicted_price']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.area_en} - {self.property_type} - {self.forecast_date}"
|
||||
|
||||
|
||||
class MarketTrend(TimeStampedModel):
|
||||
"""Market trend analysis data."""
|
||||
area_en = models.CharField(max_length=100)
|
||||
property_type = models.CharField(max_length=50)
|
||||
period_start = models.DateTimeField()
|
||||
period_end = models.DateTimeField()
|
||||
average_price = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
median_price = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
price_change_percent = models.DecimalField(max_digits=5, decimal_places=2)
|
||||
transaction_count = models.IntegerField()
|
||||
volume = models.DecimalField(max_digits=15, decimal_places=2)
|
||||
price_per_sqft = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'market_trends'
|
||||
indexes = [
|
||||
BTreeIndex(fields=['area_en']),
|
||||
BTreeIndex(fields=['property_type']),
|
||||
BTreeIndex(fields=['period_start']),
|
||||
BTreeIndex(fields=['period_end']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.area_en} - {self.property_type} - {self.period_start} to {self.period_end}"
|
||||
|
||||
138
apps/analytics/serializers.py
Normal file
138
apps/analytics/serializers.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""
|
||||
Serializers for analytics models.
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from .models import (
|
||||
Broker, Developer, Project, Land, Transaction, Valuation, Rent,
|
||||
Forecast, MarketTrend
|
||||
)
|
||||
|
||||
|
||||
class BrokerSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Broker model."""
|
||||
class Meta:
|
||||
model = Broker
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class DeveloperSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Developer model."""
|
||||
class Meta:
|
||||
model = Developer
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Project model."""
|
||||
developer_name = serializers.CharField(source='developer.developer_name_en', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class LandSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Land model."""
|
||||
project_name = serializers.CharField(source='project.project_name_en', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Land
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class TransactionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Transaction model."""
|
||||
project_name = serializers.CharField(source='project.project_name_en', read_only=True)
|
||||
price_per_sqft = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class ValuationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Valuation model."""
|
||||
class Meta:
|
||||
model = Valuation
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class RentSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Rent model."""
|
||||
project_name = serializers.CharField(source='project.project_name_en', read_only=True)
|
||||
rent_per_sqft = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = Rent
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class ForecastSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Forecast model."""
|
||||
class Meta:
|
||||
model = Forecast
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class MarketTrendSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for MarketTrend model."""
|
||||
class Meta:
|
||||
model = MarketTrend
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class TransactionSummarySerializer(serializers.Serializer):
|
||||
"""Serializer for transaction summary statistics."""
|
||||
total_transactions = serializers.IntegerField()
|
||||
total_value = serializers.DecimalField(max_digits=15, decimal_places=2)
|
||||
average_price = serializers.DecimalField(max_digits=15, decimal_places=2)
|
||||
median_price = serializers.DecimalField(max_digits=15, decimal_places=2)
|
||||
average_price_per_sqft = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||
price_range_min = serializers.DecimalField(max_digits=15, decimal_places=2)
|
||||
price_range_max = serializers.DecimalField(max_digits=15, decimal_places=2)
|
||||
|
||||
|
||||
class AreaStatsSerializer(serializers.Serializer):
|
||||
"""Serializer for area statistics."""
|
||||
area = serializers.CharField()
|
||||
transaction_count = serializers.IntegerField()
|
||||
average_price = serializers.DecimalField(max_digits=15, decimal_places=2)
|
||||
average_price_per_sqft = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||
price_trend = serializers.CharField()
|
||||
|
||||
|
||||
class PropertyTypeStatsSerializer(serializers.Serializer):
|
||||
"""Serializer for property type statistics."""
|
||||
property_type = serializers.CharField()
|
||||
transaction_count = serializers.IntegerField()
|
||||
average_price = serializers.DecimalField(max_digits=15, decimal_places=2)
|
||||
average_price_per_sqft = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||
market_share = serializers.DecimalField(max_digits=5, decimal_places=2)
|
||||
|
||||
|
||||
class TimeSeriesDataSerializer(serializers.Serializer):
|
||||
"""Serializer for time series data."""
|
||||
date = serializers.DateTimeField()
|
||||
value = serializers.DecimalField(max_digits=15, decimal_places=2)
|
||||
count = serializers.IntegerField()
|
||||
|
||||
|
||||
class ForecastRequestSerializer(serializers.Serializer):
|
||||
"""Serializer for forecast requests."""
|
||||
area_en = serializers.CharField(max_length=100)
|
||||
property_type = serializers.CharField(max_length=50)
|
||||
property_sub_type = serializers.CharField(max_length=50, required=False, allow_blank=True)
|
||||
forecast_periods = serializers.IntegerField(default=12, min_value=1, max_value=60)
|
||||
confidence_level = serializers.DecimalField(default=0.95, max_digits=3, decimal_places=2)
|
||||
|
||||
|
||||
class MarketAnalysisSerializer(serializers.Serializer):
|
||||
"""Serializer for market analysis results."""
|
||||
area = serializers.CharField()
|
||||
property_type = serializers.CharField()
|
||||
analysis_period = serializers.CharField()
|
||||
key_metrics = serializers.DictField()
|
||||
trends = serializers.ListField()
|
||||
recommendations = serializers.ListField()
|
||||
forecast_accuracy = serializers.DecimalField(max_digits=5, decimal_places=2, allow_null=True)
|
||||
|
||||
387
apps/analytics/services.py
Normal file
387
apps/analytics/services.py
Normal file
@ -0,0 +1,387 @@
|
||||
"""
|
||||
Analytics services for data processing and forecasting.
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
from django.db.models import Avg, Count, Min, Max, Sum
|
||||
from django.utils import timezone
|
||||
from .models import Transaction, Forecast, MarketTrend
|
||||
from sklearn.linear_model import LinearRegression
|
||||
from sklearn.preprocessing import PolynomialFeatures
|
||||
from sklearn.metrics import mean_absolute_error, mean_squared_error
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
"""Service for analytics calculations and market analysis."""
|
||||
|
||||
def get_market_analysis(self, area, property_type):
|
||||
"""Get comprehensive market analysis for an area and property type."""
|
||||
try:
|
||||
# Get transactions for the area and property type
|
||||
transactions = Transaction.objects.filter(
|
||||
area_en__icontains=area,
|
||||
property_type=property_type
|
||||
).order_by('instance_date')
|
||||
|
||||
if not transactions.exists():
|
||||
return {
|
||||
'area': area,
|
||||
'property_type': property_type,
|
||||
'analysis_period': 'No data available',
|
||||
'key_metrics': {},
|
||||
'trends': [],
|
||||
'recommendations': ['Insufficient data for analysis'],
|
||||
'forecast_accuracy': None
|
||||
}
|
||||
|
||||
# Calculate key metrics
|
||||
key_metrics = self._calculate_key_metrics(transactions)
|
||||
|
||||
# Analyze trends
|
||||
trends = self._analyze_trends(transactions)
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = self._generate_recommendations(key_metrics, trends)
|
||||
|
||||
# Calculate forecast accuracy if forecasts exist
|
||||
forecast_accuracy = self._calculate_forecast_accuracy(area, property_type)
|
||||
|
||||
return {
|
||||
'area': area,
|
||||
'property_type': property_type,
|
||||
'analysis_period': f"Last {transactions.count()} transactions",
|
||||
'key_metrics': key_metrics,
|
||||
'trends': trends,
|
||||
'recommendations': recommendations,
|
||||
'forecast_accuracy': forecast_accuracy
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error in market analysis: {e}')
|
||||
raise
|
||||
|
||||
def _calculate_key_metrics(self, transactions):
|
||||
"""Calculate key market metrics."""
|
||||
total_value = sum(t.transaction_value for t in transactions)
|
||||
total_area = sum(t.actual_area for t in transactions if t.actual_area)
|
||||
|
||||
prices = [t.transaction_value for t in transactions]
|
||||
areas = [t.actual_area for t in transactions if t.actual_area]
|
||||
|
||||
return {
|
||||
'total_transactions': len(transactions),
|
||||
'total_value': total_value,
|
||||
'average_price': np.mean(prices),
|
||||
'median_price': np.median(prices),
|
||||
'min_price': min(prices),
|
||||
'max_price': max(prices),
|
||||
'average_price_per_sqft': total_value / total_area if total_area > 0 else 0,
|
||||
'price_volatility': np.std(prices) / np.mean(prices) if np.mean(prices) > 0 else 0,
|
||||
'average_area': np.mean(areas) if areas else 0,
|
||||
}
|
||||
|
||||
def _analyze_trends(self, transactions):
|
||||
"""Analyze market trends."""
|
||||
trends = []
|
||||
|
||||
# Price trend over time
|
||||
if len(transactions) >= 10:
|
||||
# Split into two periods
|
||||
mid_point = len(transactions) // 2
|
||||
recent_transactions = transactions[mid_point:]
|
||||
older_transactions = transactions[:mid_point]
|
||||
|
||||
recent_avg = np.mean([t.transaction_value for t in recent_transactions])
|
||||
older_avg = np.mean([t.transaction_value for t in older_transactions])
|
||||
|
||||
price_change = ((recent_avg - older_avg) / older_avg * 100) if older_avg > 0 else 0
|
||||
|
||||
if price_change > 10:
|
||||
trends.append(f"Strong price growth: {price_change:.1f}% increase")
|
||||
elif price_change > 5:
|
||||
trends.append(f"Moderate price growth: {price_change:.1f}% increase")
|
||||
elif price_change < -10:
|
||||
trends.append(f"Significant price decline: {price_change:.1f}% decrease")
|
||||
elif price_change < -5:
|
||||
trends.append(f"Moderate price decline: {price_change:.1f}% decrease")
|
||||
else:
|
||||
trends.append(f"Stable prices: {price_change:.1f}% change")
|
||||
|
||||
# Volume trend
|
||||
if len(transactions) >= 6:
|
||||
# Check last 3 months vs previous 3 months
|
||||
three_months_ago = timezone.now() - timedelta(days=90)
|
||||
six_months_ago = timezone.now() - timedelta(days=180)
|
||||
|
||||
recent_count = transactions.filter(instance_date__gte=three_months_ago).count()
|
||||
previous_count = transactions.filter(
|
||||
instance_date__gte=six_months_ago,
|
||||
instance_date__lt=three_months_ago
|
||||
).count()
|
||||
|
||||
if previous_count > 0:
|
||||
volume_change = ((recent_count - previous_count) / previous_count * 100)
|
||||
if volume_change > 20:
|
||||
trends.append(f"High transaction volume: {volume_change:.1f}% increase")
|
||||
elif volume_change < -20:
|
||||
trends.append(f"Low transaction volume: {volume_change:.1f}% decrease")
|
||||
else:
|
||||
trends.append(f"Stable transaction volume: {volume_change:.1f}% change")
|
||||
|
||||
return trends
|
||||
|
||||
def _generate_recommendations(self, key_metrics, trends):
|
||||
"""Generate market recommendations based on metrics and trends."""
|
||||
recommendations = []
|
||||
|
||||
# Price recommendations
|
||||
avg_price = key_metrics.get('average_price', 0)
|
||||
volatility = key_metrics.get('price_volatility', 0)
|
||||
|
||||
if volatility > 0.3:
|
||||
recommendations.append("High price volatility - consider market timing carefully")
|
||||
elif volatility < 0.1:
|
||||
recommendations.append("Low price volatility - stable market conditions")
|
||||
|
||||
# Volume recommendations
|
||||
total_transactions = key_metrics.get('total_transactions', 0)
|
||||
if total_transactions < 10:
|
||||
recommendations.append("Limited transaction data - consider broader area analysis")
|
||||
elif total_transactions > 100:
|
||||
recommendations.append("High transaction volume - good market liquidity")
|
||||
|
||||
# Trend-based recommendations
|
||||
for trend in trends:
|
||||
if "Strong price growth" in trend:
|
||||
recommendations.append("Consider investing before prices rise further")
|
||||
elif "Significant price decline" in trend:
|
||||
recommendations.append("Potential buying opportunity - prices may be undervalued")
|
||||
elif "High transaction volume" in trend:
|
||||
recommendations.append("Active market - good time for transactions")
|
||||
elif "Low transaction volume" in trend:
|
||||
recommendations.append("Quiet market - consider waiting for better conditions")
|
||||
|
||||
return recommendations
|
||||
|
||||
def _calculate_forecast_accuracy(self, area, property_type):
|
||||
"""Calculate forecast accuracy for the area and property type."""
|
||||
try:
|
||||
# Get recent forecasts
|
||||
recent_forecasts = Forecast.objects.filter(
|
||||
area_en__icontains=area,
|
||||
property_type=property_type,
|
||||
forecast_date__gte=timezone.now() - timedelta(days=30)
|
||||
)
|
||||
|
||||
if not recent_forecasts.exists():
|
||||
return None
|
||||
|
||||
# Get actual prices for the forecast period
|
||||
actual_prices = []
|
||||
predicted_prices = []
|
||||
|
||||
for forecast in recent_forecasts:
|
||||
actual_transactions = Transaction.objects.filter(
|
||||
area_en__icontains=area,
|
||||
property_type=property_type,
|
||||
instance_date__gte=forecast.forecast_date,
|
||||
instance_date__lt=forecast.forecast_date + timedelta(days=30)
|
||||
)
|
||||
|
||||
if actual_transactions.exists():
|
||||
actual_avg = np.mean([t.transaction_value for t in actual_transactions])
|
||||
actual_prices.append(actual_avg)
|
||||
predicted_prices.append(float(forecast.predicted_price))
|
||||
|
||||
if len(actual_prices) < 3:
|
||||
return None
|
||||
|
||||
# Calculate accuracy metrics
|
||||
mae = mean_absolute_error(actual_prices, predicted_prices)
|
||||
mse = mean_squared_error(actual_prices, predicted_prices)
|
||||
rmse = np.sqrt(mse)
|
||||
|
||||
# Calculate percentage accuracy
|
||||
avg_actual = np.mean(actual_prices)
|
||||
accuracy = max(0, 100 - (mae / avg_actual * 100)) if avg_actual > 0 else 0
|
||||
|
||||
return round(accuracy, 2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error calculating forecast accuracy: {e}')
|
||||
return None
|
||||
|
||||
|
||||
class ForecastingService:
|
||||
"""Service for property price forecasting."""
|
||||
|
||||
def generate_forecast(self, area_en, property_type, property_sub_type='',
|
||||
forecast_periods=12, confidence_level=0.95):
|
||||
"""Generate property price forecast."""
|
||||
try:
|
||||
# Get historical data
|
||||
transactions = Transaction.objects.filter(
|
||||
area_en__icontains=area_en,
|
||||
property_type=property_type
|
||||
).order_by('instance_date')
|
||||
|
||||
if property_sub_type:
|
||||
transactions = transactions.filter(property_sub_type=property_sub_type)
|
||||
|
||||
if len(transactions) < 10:
|
||||
raise ValueError("Insufficient data for forecasting")
|
||||
|
||||
# Prepare data for forecasting
|
||||
df = self._prepare_forecast_data(transactions)
|
||||
|
||||
# Generate forecast
|
||||
forecast_data = self._generate_time_series_forecast(
|
||||
df, forecast_periods, confidence_level
|
||||
)
|
||||
|
||||
# Save forecast to database
|
||||
self._save_forecast(area_en, property_type, property_sub_type, forecast_data)
|
||||
|
||||
return forecast_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating forecast: {e}')
|
||||
raise
|
||||
|
||||
def _prepare_forecast_data(self, transactions):
|
||||
"""Prepare transaction data for forecasting."""
|
||||
data = []
|
||||
for t in transactions:
|
||||
data.append({
|
||||
'date': t.instance_date,
|
||||
'price': float(t.transaction_value),
|
||||
'area': float(t.actual_area) if t.actual_area else 0,
|
||||
'price_per_sqft': float(t.transaction_value / t.actual_area) if t.actual_area and t.actual_area > 0 else 0
|
||||
})
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df['date'] = pd.to_datetime(df['date'])
|
||||
df = df.set_index('date')
|
||||
df = df.resample('M').mean().dropna() # Monthly aggregation
|
||||
|
||||
return df
|
||||
|
||||
def _generate_time_series_forecast(self, df, periods, confidence_level):
|
||||
"""Generate time series forecast using linear regression."""
|
||||
try:
|
||||
# Create time features
|
||||
df['time_index'] = range(len(df))
|
||||
df['price_per_sqft'] = df['price'] / df['area'].replace(0, 1) # Avoid division by zero
|
||||
|
||||
# Use price per sqft for forecasting
|
||||
X = df[['time_index']].values
|
||||
y = df['price_per_sqft'].values
|
||||
|
||||
# Fit linear regression model
|
||||
model = LinearRegression()
|
||||
model.fit(X, y)
|
||||
|
||||
# Generate future time indices
|
||||
last_time = df['time_index'].iloc[-1]
|
||||
future_indices = np.arange(last_time + 1, last_time + periods + 1).reshape(-1, 1)
|
||||
|
||||
# Make predictions
|
||||
predictions = model.predict(future_indices)
|
||||
|
||||
# Calculate confidence intervals
|
||||
residuals = y - model.predict(X)
|
||||
std_error = np.std(residuals)
|
||||
|
||||
# Z-score for confidence level
|
||||
if confidence_level == 0.95:
|
||||
z_score = 1.96
|
||||
elif confidence_level == 0.90:
|
||||
z_score = 1.645
|
||||
else:
|
||||
z_score = 1.96
|
||||
|
||||
confidence_interval = z_score * std_error
|
||||
|
||||
# Generate forecast dates
|
||||
last_date = df.index[-1]
|
||||
forecast_dates = pd.date_range(
|
||||
start=last_date + pd.DateOffset(months=1),
|
||||
periods=periods,
|
||||
freq='M'
|
||||
)
|
||||
|
||||
# Prepare results
|
||||
forecast_data = {
|
||||
'area': df.index[0].strftime('%Y-%m-%d') + ' to ' + df.index[-1].strftime('%Y-%m-%d'),
|
||||
'forecast_periods': periods,
|
||||
'confidence_level': confidence_level,
|
||||
'model_accuracy': self._calculate_model_accuracy(model, X, y),
|
||||
'forecasts': []
|
||||
}
|
||||
|
||||
for i, (date, pred) in enumerate(zip(forecast_dates, predictions)):
|
||||
# Convert back to total price (assuming average area)
|
||||
avg_area = df['area'].mean()
|
||||
total_price = pred * avg_area
|
||||
|
||||
forecast_data['forecasts'].append({
|
||||
'date': date.strftime('%Y-%m-%d'),
|
||||
'predicted_price': round(total_price, 2),
|
||||
'predicted_price_per_sqft': round(pred, 2),
|
||||
'confidence_lower': round(total_price - confidence_interval * avg_area, 2),
|
||||
'confidence_upper': round(total_price + confidence_interval * avg_area, 2),
|
||||
'confidence_interval': round(confidence_interval * avg_area, 2)
|
||||
})
|
||||
|
||||
return forecast_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error in time series forecast: {e}')
|
||||
raise
|
||||
|
||||
def _calculate_model_accuracy(self, model, X, y):
|
||||
"""Calculate model accuracy metrics."""
|
||||
predictions = model.predict(X)
|
||||
mae = mean_absolute_error(y, predictions)
|
||||
mse = mean_squared_error(y, predictions)
|
||||
rmse = np.sqrt(mse)
|
||||
|
||||
# R-squared
|
||||
ss_res = np.sum((y - predictions) ** 2)
|
||||
ss_tot = np.sum((y - np.mean(y)) ** 2)
|
||||
r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
|
||||
|
||||
return {
|
||||
'mae': round(mae, 2),
|
||||
'mse': round(mse, 2),
|
||||
'rmse': round(rmse, 2),
|
||||
'r_squared': round(r_squared, 4)
|
||||
}
|
||||
|
||||
def _save_forecast(self, area_en, property_type, property_sub_type, forecast_data):
|
||||
"""Save forecast data to database."""
|
||||
try:
|
||||
for forecast_item in forecast_data['forecasts']:
|
||||
Forecast.objects.create(
|
||||
area_en=area_en,
|
||||
property_type=property_type,
|
||||
property_sub_type=property_sub_type,
|
||||
forecast_date=datetime.strptime(forecast_item['date'], '%Y-%m-%d'),
|
||||
predicted_price=forecast_item['predicted_price'],
|
||||
confidence_interval_lower=forecast_item['confidence_lower'],
|
||||
confidence_interval_upper=forecast_item['confidence_upper'],
|
||||
model_version='1.0',
|
||||
accuracy_score=forecast_data['model_accuracy']['r_squared'],
|
||||
metadata={
|
||||
'confidence_level': forecast_data['confidence_level'],
|
||||
'model_accuracy': forecast_data['model_accuracy']
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving forecast: {e}')
|
||||
# Don't raise exception here as forecast generation can still succeed
|
||||
|
||||
25
apps/analytics/urls.py
Normal file
25
apps/analytics/urls.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""
|
||||
URL patterns for analytics app.
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Data endpoints
|
||||
path('transactions/', views.TransactionListView.as_view(), name='transaction_list'),
|
||||
path('projects/', views.ProjectListView.as_view(), name='project_list'),
|
||||
path('brokers/', views.BrokerListView.as_view(), name='broker_list'),
|
||||
|
||||
# Analytics endpoints
|
||||
path('summary/', views.transaction_summary, name='transaction_summary'),
|
||||
path('area-stats/', views.area_statistics, name='area_statistics'),
|
||||
path('property-type-stats/', views.property_type_statistics, name='property_type_statistics'),
|
||||
path('time-series/', views.time_series_data, name='time_series_data'),
|
||||
path('time-series-data/', views.get_time_series_data, name='get_time_series_data'),
|
||||
path('area-stats-data/', views.get_area_stats, name='get_area_stats'),
|
||||
path('market-analysis/', views.market_analysis, name='market_analysis'),
|
||||
|
||||
# Forecasting endpoints
|
||||
path('forecast/', views.generate_forecast, name='generate_forecast'),
|
||||
]
|
||||
|
||||
516
apps/analytics/views.py
Normal file
516
apps/analytics/views.py
Normal file
@ -0,0 +1,516 @@
|
||||
"""
|
||||
Views for analytics and data analysis.
|
||||
"""
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Avg, Count, Min, Max, Sum, Q
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, timedelta
|
||||
from .models import (
|
||||
Broker, Developer, Project, Land, Transaction, Valuation, Rent,
|
||||
Forecast, MarketTrend
|
||||
)
|
||||
from .serializers import (
|
||||
BrokerSerializer, DeveloperSerializer, ProjectSerializer, LandSerializer,
|
||||
TransactionSerializer, ValuationSerializer, RentSerializer, ForecastSerializer,
|
||||
MarketTrendSerializer, TransactionSummarySerializer, AreaStatsSerializer,
|
||||
PropertyTypeStatsSerializer, TimeSeriesDataSerializer, ForecastRequestSerializer,
|
||||
MarketAnalysisSerializer
|
||||
)
|
||||
from .services import AnalyticsService, ForecastingService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransactionListView(generics.ListAPIView):
|
||||
"""List transactions with filtering and pagination."""
|
||||
serializer_class = TransactionSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['area_en', 'property_type', 'property_sub_type', 'group', 'usage']
|
||||
search_fields = ['area_en', 'property_type', 'nearest_metro', 'nearest_mall']
|
||||
ordering_fields = ['instance_date', 'transaction_value', 'actual_area']
|
||||
ordering = ['-instance_date']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Transaction.objects.all()
|
||||
|
||||
# Date range filtering
|
||||
start_date = self.request.query_params.get('start_date')
|
||||
end_date = self.request.query_params.get('end_date')
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(instance_date__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(instance_date__lte=end_date)
|
||||
|
||||
# Price range filtering
|
||||
min_price = self.request.query_params.get('min_price')
|
||||
max_price = self.request.query_params.get('max_price')
|
||||
|
||||
if min_price:
|
||||
queryset = queryset.filter(transaction_value__gte=min_price)
|
||||
if max_price:
|
||||
queryset = queryset.filter(transaction_value__lte=max_price)
|
||||
|
||||
# Area filtering
|
||||
area = self.request.query_params.get('area')
|
||||
if area:
|
||||
queryset = queryset.filter(area_en__icontains=area)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class ProjectListView(generics.ListAPIView):
|
||||
"""List projects with filtering."""
|
||||
serializer_class = ProjectSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['project_status', 'area_en', 'zone_en', 'developer']
|
||||
search_fields = ['project_name_en', 'area_en', 'zone_en']
|
||||
ordering_fields = ['start_date', 'project_value', 'percent_completed']
|
||||
ordering = ['-start_date']
|
||||
|
||||
def get_queryset(self):
|
||||
return Project.objects.select_related('developer').all()
|
||||
|
||||
|
||||
class BrokerListView(generics.ListAPIView):
|
||||
"""List brokers with filtering."""
|
||||
serializer_class = BrokerSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['real_estate_name_en']
|
||||
search_fields = ['broker_name_en', 'real_estate_name_en']
|
||||
ordering_fields = ['broker_name_en', 'license_end_date']
|
||||
ordering = ['broker_name_en']
|
||||
|
||||
def get_queryset(self):
|
||||
return Broker.objects.all()
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def transaction_summary(request):
|
||||
"""Get transaction summary statistics."""
|
||||
try:
|
||||
# Get query parameters
|
||||
area = request.query_params.get('area')
|
||||
property_type = request.query_params.get('property_type')
|
||||
start_date = request.query_params.get('start_date')
|
||||
end_date = request.query_params.get('end_date')
|
||||
|
||||
# Build queryset
|
||||
queryset = Transaction.objects.all()
|
||||
|
||||
if area:
|
||||
queryset = queryset.filter(area_en__icontains=area)
|
||||
if property_type:
|
||||
queryset = queryset.filter(property_type=property_type)
|
||||
if start_date:
|
||||
queryset = queryset.filter(instance_date__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(instance_date__lte=end_date)
|
||||
|
||||
# Calculate statistics
|
||||
stats = queryset.aggregate(
|
||||
total_transactions=Count('id'),
|
||||
total_value=Sum('transaction_value'),
|
||||
average_price=Avg('transaction_value'),
|
||||
median_price=Avg('transaction_value'), # This would need a custom calculation
|
||||
price_range_min=Min('transaction_value'),
|
||||
price_range_max=Max('transaction_value'),
|
||||
)
|
||||
|
||||
# Calculate average price per sqft
|
||||
total_area = queryset.aggregate(total_area=Sum('actual_area'))['total_area']
|
||||
if total_area and total_area > 0:
|
||||
stats['average_price_per_sqft'] = stats['total_value'] / total_area
|
||||
else:
|
||||
stats['average_price_per_sqft'] = 0
|
||||
|
||||
serializer = TransactionSummarySerializer(stats)
|
||||
return Response(serializer.data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting transaction summary: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to get transaction summary'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def area_statistics(request):
|
||||
"""Get statistics by area."""
|
||||
try:
|
||||
# Get query parameters
|
||||
property_type = request.query_params.get('property_type')
|
||||
start_date = request.query_params.get('start_date')
|
||||
end_date = request.query_params.get('end_date')
|
||||
limit = int(request.query_params.get('limit', 20))
|
||||
|
||||
# Build queryset
|
||||
queryset = Transaction.objects.all()
|
||||
|
||||
if property_type:
|
||||
queryset = queryset.filter(property_type=property_type)
|
||||
if start_date:
|
||||
queryset = queryset.filter(instance_date__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(instance_date__lte=end_date)
|
||||
|
||||
# Group by area and calculate statistics
|
||||
area_stats = queryset.values('area_en').annotate(
|
||||
transaction_count=Count('id'),
|
||||
average_price=Avg('transaction_value'),
|
||||
total_value=Sum('transaction_value'),
|
||||
total_area=Sum('actual_area'),
|
||||
).order_by('-transaction_count')[:limit]
|
||||
|
||||
# Calculate price per sqft and add trend analysis
|
||||
results = []
|
||||
for stat in area_stats:
|
||||
if stat['total_area'] and stat['total_area'] > 0:
|
||||
price_per_sqft = stat['total_value'] / stat['total_area']
|
||||
else:
|
||||
price_per_sqft = 0
|
||||
|
||||
# Simple trend calculation (comparing last 6 months vs previous 6 months)
|
||||
six_months_ago = timezone.now() - timedelta(days=180)
|
||||
twelve_months_ago = timezone.now() - timedelta(days=365)
|
||||
|
||||
recent_avg = queryset.filter(
|
||||
area_en=stat['area_en'],
|
||||
instance_date__gte=six_months_ago
|
||||
).aggregate(avg=Avg('transaction_value'))['avg'] or 0
|
||||
|
||||
previous_avg = queryset.filter(
|
||||
area_en=stat['area_en'],
|
||||
instance_date__gte=twelve_months_ago,
|
||||
instance_date__lt=six_months_ago
|
||||
).aggregate(avg=Avg('transaction_value'))['avg'] or 0
|
||||
|
||||
if previous_avg > 0:
|
||||
trend = ((recent_avg - previous_avg) / previous_avg) * 100
|
||||
if trend > 5:
|
||||
trend_text = 'Rising'
|
||||
elif trend < -5:
|
||||
trend_text = 'Falling'
|
||||
else:
|
||||
trend_text = 'Stable'
|
||||
else:
|
||||
trend_text = 'Unknown'
|
||||
|
||||
results.append({
|
||||
'area': stat['area_en'],
|
||||
'transaction_count': stat['transaction_count'],
|
||||
'average_price': stat['average_price'],
|
||||
'average_price_per_sqft': price_per_sqft,
|
||||
'price_trend': trend_text,
|
||||
})
|
||||
|
||||
serializer = AreaStatsSerializer(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting area statistics: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to get area statistics'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def property_type_statistics(request):
|
||||
"""Get statistics by property type."""
|
||||
try:
|
||||
# Get query parameters
|
||||
area = request.query_params.get('area')
|
||||
start_date = request.query_params.get('start_date')
|
||||
end_date = request.query_params.get('end_date')
|
||||
|
||||
# Build queryset
|
||||
queryset = Transaction.objects.all()
|
||||
|
||||
if area:
|
||||
queryset = queryset.filter(area_en__icontains=area)
|
||||
if start_date:
|
||||
queryset = queryset.filter(instance_date__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(instance_date__lte=end_date)
|
||||
|
||||
# Get total transactions for market share calculation
|
||||
total_transactions = queryset.count()
|
||||
|
||||
# Group by property type and calculate statistics
|
||||
property_stats = queryset.values('property_type').annotate(
|
||||
transaction_count=Count('id'),
|
||||
average_price=Avg('transaction_value'),
|
||||
total_value=Sum('transaction_value'),
|
||||
total_area=Sum('actual_area'),
|
||||
).order_by('-transaction_count')
|
||||
|
||||
results = []
|
||||
for stat in property_stats:
|
||||
if stat['total_area'] and stat['total_area'] > 0:
|
||||
price_per_sqft = stat['total_value'] / stat['total_area']
|
||||
else:
|
||||
price_per_sqft = 0
|
||||
|
||||
market_share = (stat['transaction_count'] / total_transactions * 100) if total_transactions > 0 else 0
|
||||
|
||||
results.append({
|
||||
'property_type': stat['property_type'],
|
||||
'transaction_count': stat['transaction_count'],
|
||||
'average_price': stat['average_price'],
|
||||
'average_price_per_sqft': price_per_sqft,
|
||||
'market_share': market_share,
|
||||
})
|
||||
|
||||
serializer = PropertyTypeStatsSerializer(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting property type statistics: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to get property type statistics'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def time_series_data(request):
|
||||
"""Get time series data for charts."""
|
||||
try:
|
||||
# Get query parameters
|
||||
area = request.query_params.get('area')
|
||||
property_type = request.query_params.get('property_type')
|
||||
start_date = request.query_params.get('start_date')
|
||||
end_date = request.query_params.get('end_date')
|
||||
group_by = request.query_params.get('group_by', 'month') # day, week, month, quarter, year
|
||||
|
||||
# Build queryset
|
||||
queryset = Transaction.objects.all()
|
||||
|
||||
if area:
|
||||
queryset = queryset.filter(area_en__icontains=area)
|
||||
if property_type:
|
||||
queryset = queryset.filter(property_type=property_type)
|
||||
if start_date:
|
||||
queryset = queryset.filter(instance_date__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(instance_date__lte=end_date)
|
||||
|
||||
# Group by time period
|
||||
if group_by == 'day':
|
||||
queryset = queryset.extra(select={'period': "DATE(instance_date)"})
|
||||
elif group_by == 'week':
|
||||
queryset = queryset.extra(select={'period': "DATE_TRUNC('week', instance_date)"})
|
||||
elif group_by == 'month':
|
||||
queryset = queryset.extra(select={'period': "DATE_TRUNC('month', instance_date)"})
|
||||
elif group_by == 'quarter':
|
||||
queryset = queryset.extra(select={'period': "DATE_TRUNC('quarter', instance_date)"})
|
||||
elif group_by == 'year':
|
||||
queryset = queryset.extra(select={'period': "DATE_TRUNC('year', instance_date)"})
|
||||
|
||||
# Aggregate by period
|
||||
time_series = queryset.values('period').annotate(
|
||||
value=Avg('transaction_value'),
|
||||
count=Count('id'),
|
||||
).order_by('period')
|
||||
|
||||
results = []
|
||||
for item in time_series:
|
||||
results.append({
|
||||
'date': item['period'],
|
||||
'value': item['value'],
|
||||
'count': item['count'],
|
||||
})
|
||||
|
||||
serializer = TimeSeriesDataSerializer(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting time series data: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to get time series data'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def generate_forecast(request):
|
||||
"""Generate property price forecast."""
|
||||
try:
|
||||
serializer = ForecastRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Check if user has forecast permissions
|
||||
user = request.user
|
||||
limits = user.get_usage_limits()
|
||||
if not limits.get('forecast_requests_per_month', 0):
|
||||
return Response(
|
||||
{'error': 'Forecast requests not available for your subscription'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Generate forecast using forecasting service
|
||||
forecasting_service = ForecastingService()
|
||||
forecast_data = forecasting_service.generate_forecast(
|
||||
area_en=serializer.validated_data['area_en'],
|
||||
property_type=serializer.validated_data['property_type'],
|
||||
property_sub_type=serializer.validated_data.get('property_sub_type', ''),
|
||||
forecast_periods=serializer.validated_data['forecast_periods'],
|
||||
confidence_level=float(serializer.validated_data['confidence_level'])
|
||||
)
|
||||
|
||||
return Response(forecast_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating forecast: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to generate forecast'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def market_analysis(request):
|
||||
"""Get comprehensive market analysis."""
|
||||
try:
|
||||
# Get query parameters
|
||||
area = request.query_params.get('area')
|
||||
property_type = request.query_params.get('property_type')
|
||||
|
||||
if not area or not property_type:
|
||||
return Response(
|
||||
{'error': 'Area and property_type parameters are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Use analytics service for comprehensive analysis
|
||||
analytics_service = AnalyticsService()
|
||||
analysis = analytics_service.get_market_analysis(area, property_type)
|
||||
|
||||
serializer = MarketAnalysisSerializer(analysis)
|
||||
return Response(serializer.data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting market analysis: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to get market analysis'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_time_series_data(request):
|
||||
"""Get time series data for charts."""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
||||
start_date = request.query_params.get('start_date')
|
||||
end_date = request.query_params.get('end_date')
|
||||
group_by = request.query_params.get('group_by', 'day')
|
||||
|
||||
if not start_date:
|
||||
start_date = (timezone.now() - timedelta(days=30)).date()
|
||||
if not end_date:
|
||||
end_date = timezone.now().date()
|
||||
|
||||
# Create sample data for demonstration
|
||||
|
||||
data = []
|
||||
current_date = datetime.strptime(str(start_date), '%Y-%m-%d')
|
||||
end_date_obj = datetime.strptime(str(end_date), '%Y-%m-%d')
|
||||
|
||||
while current_date <= end_date_obj:
|
||||
# Generate realistic transaction data
|
||||
transactions = random.randint(0, 15)
|
||||
value = random.randint(500000, 5000000)
|
||||
|
||||
data.append({
|
||||
'date': current_date.strftime('%Y-%m-%d'),
|
||||
'transactions': transactions,
|
||||
'value': value,
|
||||
'average_value': value // max(transactions, 1)
|
||||
})
|
||||
|
||||
if group_by == 'day':
|
||||
current_date += timedelta(days=1)
|
||||
elif group_by == 'week':
|
||||
current_date += timedelta(weeks=1)
|
||||
else: # month
|
||||
current_date += timedelta(days=30)
|
||||
|
||||
return Response(data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting time series data: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Failed to get time series data'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_area_stats(request):
|
||||
"""Get area statistics for charts."""
|
||||
try:
|
||||
start_date = request.query_params.get('start_date')
|
||||
end_date = request.query_params.get('end_date')
|
||||
limit = int(request.query_params.get('limit', 10))
|
||||
|
||||
if not start_date:
|
||||
start_date = (timezone.now() - timedelta(days=30)).date()
|
||||
if not end_date:
|
||||
end_date = timezone.now().date()
|
||||
|
||||
# Get real area data from transactions
|
||||
queryset = Transaction.objects.filter(
|
||||
instance_date__date__range=[start_date, end_date]
|
||||
).exclude(area_en__isnull=True).exclude(area_en='')
|
||||
|
||||
area_stats = queryset.values('area_en').annotate(
|
||||
transaction_count=Count('id'),
|
||||
total_value=Sum('transaction_value'),
|
||||
average_value=Avg('transaction_value')
|
||||
).order_by('-transaction_count')[:limit]
|
||||
|
||||
# If no real data, return sample data
|
||||
if not area_stats:
|
||||
import random
|
||||
sample_areas = [
|
||||
'JUMEIRAH VILLAGE CIRCLE', 'DUBAI MARINA', 'DOWNTOWN DUBAI',
|
||||
'BUSINESS BAY', 'JUMEIRAH LAKES TOWERS', 'DUBAI HILLS',
|
||||
'ARABIAN RANCHES', 'DUBAI SPORTS CITY', 'JUMEIRAH BEACH RESIDENCE',
|
||||
'DUBAI LAND'
|
||||
]
|
||||
|
||||
area_stats = []
|
||||
for i, area in enumerate(sample_areas[:limit]):
|
||||
area_stats.append({
|
||||
'area_en': area,
|
||||
'transaction_count': random.randint(5, 50),
|
||||
'total_value': random.randint(1000000, 10000000),
|
||||
'average_value': random.randint(500000, 2000000)
|
||||
})
|
||||
|
||||
return Response(area_stats)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting area stats: {str(e)}")
|
||||
return Response(
|
||||
{'error': 'Failed to get area stats'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
2
apps/billing/__init__.py
Normal file
2
apps/billing/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Billing app
|
||||
|
||||
8
apps/billing/apps.py
Normal file
8
apps/billing/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.billing'
|
||||
verbose_name = 'Billing'
|
||||
|
||||
11
apps/billing/urls.py
Normal file
11
apps/billing/urls.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""
|
||||
URL patterns for billing app.
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('usage/', views.usage_stats, name='usage_stats'),
|
||||
path('subscription/', views.subscription_info, name='subscription_info'),
|
||||
]
|
||||
|
||||
42
apps/billing/views.py
Normal file
42
apps/billing/views.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""
|
||||
Views for billing and subscription management.
|
||||
"""
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def usage_stats(request):
|
||||
"""Get user usage statistics."""
|
||||
user = request.user
|
||||
limits = user.get_usage_limits()
|
||||
|
||||
# This would be implemented with actual usage tracking
|
||||
current_usage = {
|
||||
'api_calls_this_month': 0,
|
||||
'reports_this_month': 0,
|
||||
'forecast_requests_this_month': 0,
|
||||
}
|
||||
|
||||
return Response({
|
||||
'subscription_type': user.subscription_type,
|
||||
'limits': limits,
|
||||
'current_usage': current_usage,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def subscription_info(request):
|
||||
"""Get subscription information."""
|
||||
user = request.user
|
||||
limits = user.get_usage_limits()
|
||||
|
||||
return Response({
|
||||
'subscription_type': user.subscription_type,
|
||||
'limits': limits,
|
||||
'is_api_enabled': user.is_api_enabled,
|
||||
})
|
||||
|
||||
2
apps/core/__init__.py
Normal file
2
apps/core/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Core app
|
||||
|
||||
8
apps/core/apps.py
Normal file
8
apps/core/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.core'
|
||||
verbose_name = 'Core'
|
||||
|
||||
59
apps/core/authentication.py
Normal file
59
apps/core/authentication.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""
|
||||
Custom authentication classes for API key authentication.
|
||||
"""
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
import hashlib
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class APIKeyAuthentication(BaseAuthentication):
|
||||
"""
|
||||
Custom API key authentication for the platform.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
api_key = self.get_api_key(request)
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
user_id = cache.get(f'api_key_{api_key}')
|
||||
if user_id:
|
||||
try:
|
||||
user = User.objects.get(id=user_id, is_api_enabled=True)
|
||||
return (user, None)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Check database
|
||||
try:
|
||||
user = User.objects.get(api_key=api_key, is_api_enabled=True)
|
||||
# Cache the result for 1 hour
|
||||
cache.set(f'api_key_{api_key}', str(user.id), 3600)
|
||||
return (user, None)
|
||||
except User.DoesNotExist:
|
||||
raise AuthenticationFailed('Invalid API key')
|
||||
|
||||
def get_api_key(self, request):
|
||||
"""
|
||||
Get API key from request headers.
|
||||
"""
|
||||
# Check X-API-Key header first
|
||||
api_key = request.META.get('HTTP_X_API_KEY')
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# Check Authorization header as fallback
|
||||
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
||||
if auth_header.startswith('ApiKey '):
|
||||
return auth_header.split(' ')[1]
|
||||
|
||||
return None
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return 'ApiKey realm="api"'
|
||||
|
||||
128
apps/core/exceptions.py
Normal file
128
apps/core/exceptions.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""
|
||||
Custom exception handlers for the API.
|
||||
"""
|
||||
from rest_framework.views import exception_handler
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def custom_exception_handler(exc, context):
|
||||
"""
|
||||
Custom exception handler for API responses.
|
||||
"""
|
||||
# Call REST framework's default exception handler first
|
||||
response = exception_handler(exc, context)
|
||||
|
||||
if response is not None:
|
||||
custom_response_data = {
|
||||
'error': True,
|
||||
'message': 'An error occurred',
|
||||
'details': response.data,
|
||||
'status_code': response.status_code,
|
||||
}
|
||||
|
||||
# Handle specific error types
|
||||
if isinstance(exc, ValidationError):
|
||||
custom_response_data['message'] = 'Validation error'
|
||||
custom_response_data['details'] = exc.message_dict if hasattr(exc, 'message_dict') else str(exc)
|
||||
|
||||
elif isinstance(exc, IntegrityError):
|
||||
custom_response_data['message'] = 'Data integrity error'
|
||||
custom_response_data['details'] = 'The operation could not be completed due to data constraints'
|
||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
elif response.status_code == status.HTTP_401_UNAUTHORIZED:
|
||||
custom_response_data['message'] = 'Authentication required'
|
||||
custom_response_data['details'] = 'Please provide valid authentication credentials'
|
||||
|
||||
elif response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
custom_response_data['message'] = 'Access denied'
|
||||
custom_response_data['details'] = 'You do not have permission to perform this action'
|
||||
|
||||
elif response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
custom_response_data['message'] = 'Resource not found'
|
||||
custom_response_data['details'] = 'The requested resource could not be found'
|
||||
|
||||
elif response.status_code == status.HTTP_429_TOO_MANY_REQUESTS:
|
||||
custom_response_data['message'] = 'Rate limit exceeded'
|
||||
custom_response_data['details'] = 'Too many requests. Please try again later.'
|
||||
|
||||
elif response.status_code >= 500:
|
||||
custom_response_data['message'] = 'Internal server error'
|
||||
custom_response_data['details'] = 'An unexpected error occurred. Please try again later.'
|
||||
# Log server errors
|
||||
logger.error(f"Server error: {exc}", exc_info=True)
|
||||
|
||||
response.data = custom_response_data
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class APIException(Exception):
|
||||
"""
|
||||
Base exception for API errors.
|
||||
"""
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
message = 'An error occurred'
|
||||
|
||||
def __init__(self, message=None, status_code=None, details=None):
|
||||
if message:
|
||||
self.message = message
|
||||
if status_code:
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ValidationException(APIException):
|
||||
"""
|
||||
Exception for validation errors.
|
||||
"""
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
message = 'Validation error'
|
||||
|
||||
|
||||
class AuthenticationException(APIException):
|
||||
"""
|
||||
Exception for authentication errors.
|
||||
"""
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
message = 'Authentication required'
|
||||
|
||||
|
||||
class PermissionException(APIException):
|
||||
"""
|
||||
Exception for permission errors.
|
||||
"""
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
message = 'Access denied'
|
||||
|
||||
|
||||
class NotFoundException(APIException):
|
||||
"""
|
||||
Exception for not found errors.
|
||||
"""
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
message = 'Resource not found'
|
||||
|
||||
|
||||
class RateLimitException(APIException):
|
||||
"""
|
||||
Exception for rate limit errors.
|
||||
"""
|
||||
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
||||
message = 'Rate limit exceeded'
|
||||
|
||||
|
||||
class BusinessLogicException(APIException):
|
||||
"""
|
||||
Exception for business logic errors.
|
||||
"""
|
||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
message = 'Business logic error'
|
||||
|
||||
204
apps/core/middleware.py
Normal file
204
apps/core/middleware.py
Normal file
@ -0,0 +1,204 @@
|
||||
"""
|
||||
Custom middleware for API rate limiting and logging.
|
||||
"""
|
||||
import time
|
||||
import json
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.core.cache import cache
|
||||
from django.http import JsonResponse
|
||||
from django.conf import settings
|
||||
from .models import APIUsage, AuditLog
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class APIRateLimitMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Rate limiting middleware for API endpoints.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
# Only apply to API endpoints
|
||||
if not request.path.startswith('/api/'):
|
||||
return None
|
||||
|
||||
# Skip rate limiting for admin users
|
||||
if request.user.is_authenticated and request.user.is_staff:
|
||||
return None
|
||||
|
||||
# Get user (from JWT or API key)
|
||||
user = getattr(request, 'user', None)
|
||||
if not user or not user.is_authenticated:
|
||||
return None
|
||||
|
||||
# Get rate limits for user's subscription
|
||||
subscription_type = getattr(user, 'subscription_type', 'free')
|
||||
rate_limits = self.get_rate_limits(subscription_type)
|
||||
|
||||
if not rate_limits:
|
||||
return None
|
||||
|
||||
# Check rate limits
|
||||
if not self.check_rate_limit(user, request, rate_limits):
|
||||
return JsonResponse({
|
||||
'error': 'Rate limit exceeded',
|
||||
'message': 'Too many requests. Please try again later.',
|
||||
'retry_after': 60
|
||||
}, status=429)
|
||||
|
||||
return None
|
||||
|
||||
def get_rate_limits(self, subscription_type):
|
||||
"""Get rate limits for subscription type."""
|
||||
try:
|
||||
from .models import APIRateLimit
|
||||
rate_limit = APIRateLimit.objects.get(subscription_type=subscription_type)
|
||||
return {
|
||||
'per_minute': rate_limit.requests_per_minute,
|
||||
'per_hour': rate_limit.requests_per_hour,
|
||||
'per_day': rate_limit.requests_per_day,
|
||||
}
|
||||
except:
|
||||
# Default limits if not configured
|
||||
return {
|
||||
'per_minute': 60,
|
||||
'per_hour': 1000,
|
||||
'per_day': 10000,
|
||||
}
|
||||
|
||||
def check_rate_limit(self, user, request, limits):
|
||||
"""Check if request is within rate limits."""
|
||||
now = int(time.time())
|
||||
user_id = str(user.id)
|
||||
|
||||
# Check per-minute limit
|
||||
minute_key = f'rate_limit:{user_id}:{now // 60}'
|
||||
minute_count = cache.get(minute_key, 0)
|
||||
if minute_count >= limits['per_minute']:
|
||||
return False
|
||||
cache.set(minute_key, minute_count + 1, 60)
|
||||
|
||||
# Check per-hour limit
|
||||
hour_key = f'rate_limit:{user_id}:{now // 3600}'
|
||||
hour_count = cache.get(hour_key, 0)
|
||||
if hour_count >= limits['per_hour']:
|
||||
return False
|
||||
cache.set(hour_key, hour_count + 1, 3600)
|
||||
|
||||
# Check per-day limit
|
||||
day_key = f'rate_limit:{user_id}:{now // 86400}'
|
||||
day_count = cache.get(day_key, 0)
|
||||
if day_count >= limits['per_day']:
|
||||
return False
|
||||
cache.set(day_key, day_count + 1, 86400)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class APILoggingMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Logging middleware for API requests.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
request.start_time = time.time()
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
# Only log API endpoints
|
||||
if not request.path.startswith('/api/'):
|
||||
return response
|
||||
|
||||
# Skip logging for certain endpoints
|
||||
skip_paths = ['/api/schema/', '/api/docs/', '/api/redoc/']
|
||||
if any(request.path.startswith(path) for path in skip_paths):
|
||||
return response
|
||||
|
||||
# Calculate response time
|
||||
if hasattr(request, 'start_time'):
|
||||
response_time = int((time.time() - request.start_time) * 1000)
|
||||
else:
|
||||
response_time = 0
|
||||
|
||||
# Get user
|
||||
user = getattr(request, 'user', None)
|
||||
if not user or not user.is_authenticated:
|
||||
return response
|
||||
|
||||
# Log API usage
|
||||
try:
|
||||
APIUsage.objects.create(
|
||||
user=user,
|
||||
endpoint=request.path,
|
||||
method=request.method,
|
||||
status_code=response.status_code,
|
||||
response_time_ms=response_time,
|
||||
ip_address=self.get_client_ip(request),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500]
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't break the request
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to log API usage: {e}")
|
||||
|
||||
return response
|
||||
|
||||
def get_client_ip(self, request):
|
||||
"""Get client IP address."""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
class AuditLoggingMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Audit logging middleware for security and compliance.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
# Only log authenticated requests
|
||||
if not hasattr(request, 'user') or not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
# Track sensitive actions
|
||||
sensitive_actions = [
|
||||
'POST', 'PUT', 'PATCH', 'DELETE'
|
||||
]
|
||||
|
||||
if request.method in sensitive_actions:
|
||||
try:
|
||||
AuditLog.objects.create(
|
||||
user=request.user,
|
||||
action=f'{request.method} {request.path}',
|
||||
resource_type='api_request',
|
||||
details={
|
||||
'method': request.method,
|
||||
'path': request.path,
|
||||
'query_params': dict(request.GET),
|
||||
'content_type': request.content_type,
|
||||
},
|
||||
ip_address=self.get_client_ip(request),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500]
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't break the request
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to create audit log: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_client_ip(self, request):
|
||||
"""Get client IP address."""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
85
apps/core/migrations/0001_initial.py
Normal file
85
apps/core/migrations/0001_initial.py
Normal file
@ -0,0 +1,85 @@
|
||||
# Generated by Django 4.2.24 on 2025-09-16 18:48
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='APIRateLimit',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('subscription_type', models.CharField(choices=[('free', 'Free'), ('paid', 'Paid'), ('premium', 'Premium')], max_length=20, unique=True)),
|
||||
('requests_per_minute', models.IntegerField(default=60)),
|
||||
('requests_per_hour', models.IntegerField(default=1000)),
|
||||
('requests_per_day', models.IntegerField(default=10000)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'api_rate_limits',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SystemConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('key', models.CharField(max_length=100, unique=True)),
|
||||
('value', models.TextField()),
|
||||
('description', models.TextField(blank=True)),
|
||||
('is_encrypted', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'system_configuration',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AuditLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('action', models.CharField(max_length=100)),
|
||||
('resource_type', models.CharField(max_length=50)),
|
||||
('resource_id', models.CharField(blank=True, max_length=100)),
|
||||
('details', models.JSONField(default=dict)),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'audit_logs',
|
||||
'indexes': [models.Index(fields=['user', 'created_at'], name='audit_logs_user_id_fbfd51_idx'), models.Index(fields=['action', 'created_at'], name='audit_logs_action_391715_idx'), models.Index(fields=['resource_type', 'resource_id'], name='audit_logs_resourc_bda8a6_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='APIUsage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('endpoint', models.CharField(max_length=255)),
|
||||
('method', models.CharField(max_length=10)),
|
||||
('status_code', models.IntegerField()),
|
||||
('response_time_ms', models.IntegerField()),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_usage', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'api_usage',
|
||||
'indexes': [models.Index(fields=['user', 'created_at'], name='api_usage_user_id_b7cc9a_idx'), models.Index(fields=['endpoint', 'created_at'], name='api_usage_endpoin_6aa3d0_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/core/migrations/__init__.py
Normal file
0
apps/core/migrations/__init__.py
Normal file
99
apps/core/models.py
Normal file
99
apps/core/models.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""
|
||||
Core models for the Dubai Analytics Platform.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.utils import timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
"""Abstract base class with self-updating created and modified fields."""
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
# User model is defined in apps.users.models
|
||||
|
||||
|
||||
class APIUsage(TimeStampedModel):
|
||||
"""Track API usage for billing and rate limiting."""
|
||||
user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='api_usage')
|
||||
endpoint = models.CharField(max_length=255)
|
||||
method = models.CharField(max_length=10)
|
||||
status_code = models.IntegerField()
|
||||
response_time_ms = models.IntegerField()
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'api_usage'
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'created_at']),
|
||||
models.Index(fields=['endpoint', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.endpoint} - {self.created_at}"
|
||||
|
||||
|
||||
class APIRateLimit(TimeStampedModel):
|
||||
"""Rate limiting configuration per user tier."""
|
||||
subscription_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('free', 'Free'),
|
||||
('paid', 'Paid'),
|
||||
('premium', 'Premium'),
|
||||
],
|
||||
unique=True
|
||||
)
|
||||
requests_per_minute = models.IntegerField(default=60)
|
||||
requests_per_hour = models.IntegerField(default=1000)
|
||||
requests_per_day = models.IntegerField(default=10000)
|
||||
|
||||
class Meta:
|
||||
db_table = 'api_rate_limits'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.subscription_type} - {self.requests_per_minute}/min"
|
||||
|
||||
|
||||
class SystemConfiguration(TimeStampedModel):
|
||||
"""System-wide configuration settings."""
|
||||
key = models.CharField(max_length=100, unique=True)
|
||||
value = models.TextField()
|
||||
description = models.TextField(blank=True)
|
||||
is_encrypted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
db_table = 'system_configuration'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.key}: {self.value[:50]}..."
|
||||
|
||||
|
||||
class AuditLog(TimeStampedModel):
|
||||
"""Audit logging for security and compliance."""
|
||||
user = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, blank=True)
|
||||
action = models.CharField(max_length=100)
|
||||
resource_type = models.CharField(max_length=50)
|
||||
resource_id = models.CharField(max_length=100, blank=True)
|
||||
details = models.JSONField(default=dict)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'audit_logs'
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'created_at']),
|
||||
models.Index(fields=['action', 'created_at']),
|
||||
models.Index(fields=['resource_type', 'resource_id']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email if self.user else 'System'} - {self.action} - {self.created_at}"
|
||||
|
||||
46
apps/core/pagination.py
Normal file
46
apps/core/pagination.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""
|
||||
Custom pagination classes for the API.
|
||||
"""
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
"""
|
||||
Standard pagination for API responses.
|
||||
"""
|
||||
page_size = 50
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 1000
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
return Response({
|
||||
'count': self.page.paginator.count,
|
||||
'next': self.get_next_link(),
|
||||
'previous': self.get_previous_link(),
|
||||
'results': data,
|
||||
'page': self.page.number,
|
||||
'pages': self.page.paginator.num_pages,
|
||||
'page_size': self.page_size,
|
||||
})
|
||||
|
||||
|
||||
class LargeResultsSetPagination(PageNumberPagination):
|
||||
"""
|
||||
Pagination for large datasets (e.g., transactions).
|
||||
"""
|
||||
page_size = 100
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 5000
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
return Response({
|
||||
'count': self.page.paginator.count,
|
||||
'next': self.get_next_link(),
|
||||
'previous': self.get_previous_link(),
|
||||
'results': data,
|
||||
'page': self.page.number,
|
||||
'pages': self.page.paginator.num_pages,
|
||||
'page_size': self.page_size,
|
||||
})
|
||||
|
||||
13
apps/core/urls.py
Normal file
13
apps/core/urls.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""
|
||||
URL patterns for core app.
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('health/', views.health_check, name='health_check'),
|
||||
path('info/', views.api_info, name='api_info'),
|
||||
path('user/', views.user_info, name='user_info'),
|
||||
path('usage/', views.usage_stats, name='usage_stats'),
|
||||
]
|
||||
|
||||
112
apps/core/views.py
Normal file
112
apps/core/views.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""
|
||||
Core views for the Dubai Analytics Platform.
|
||||
"""
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.vary import vary_on_headers
|
||||
from django.core.cache import cache
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
def health_check(request):
|
||||
"""
|
||||
Health check endpoint for monitoring.
|
||||
"""
|
||||
return Response({
|
||||
'status': 'healthy',
|
||||
'service': 'Dubai Analytics API',
|
||||
'version': '1.0.0',
|
||||
'timestamp': cache.get('health_check_timestamp', 'unknown')
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
def api_info(request):
|
||||
"""
|
||||
API information endpoint.
|
||||
"""
|
||||
return Response({
|
||||
'name': 'Dubai Analytics API',
|
||||
'version': '1.0.0',
|
||||
'description': 'Multi-tenant SaaS platform for Dubai Land Department data analytics',
|
||||
'documentation': '/api/docs/',
|
||||
'endpoints': {
|
||||
'auth': '/api/v1/auth/',
|
||||
'analytics': '/api/v1/analytics/',
|
||||
'reports': '/api/v1/reports/',
|
||||
'integrations': '/api/v1/integrations/',
|
||||
'billing': '/api/v1/billing/',
|
||||
'monitoring': '/api/v1/monitoring/',
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def user_info(request):
|
||||
"""
|
||||
Get current user information.
|
||||
"""
|
||||
user = request.user
|
||||
return Response({
|
||||
'id': str(user.id),
|
||||
'email': user.email,
|
||||
'username': user.username,
|
||||
'subscription_type': user.subscription_type,
|
||||
'is_api_enabled': user.is_api_enabled,
|
||||
'company_name': user.company_name,
|
||||
'is_verified': user.is_verified,
|
||||
'date_joined': user.date_joined,
|
||||
'last_login': user.last_login,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
def usage_stats(request):
|
||||
"""
|
||||
Get user's API usage statistics.
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
# Get usage limits
|
||||
limits = user.get_usage_limits()
|
||||
|
||||
# Get current usage (this would need to be implemented with actual usage tracking)
|
||||
# For now, return placeholder data
|
||||
current_usage = {
|
||||
'api_calls_this_month': 0,
|
||||
'reports_this_month': 0,
|
||||
'forecast_requests_this_month': 0,
|
||||
}
|
||||
|
||||
return Response({
|
||||
'subscription_type': user.subscription_type,
|
||||
'limits': limits,
|
||||
'current_usage': current_usage,
|
||||
'remaining': {
|
||||
'api_calls': limits.get('api_calls_per_month', 0) - current_usage['api_calls_this_month'],
|
||||
'reports': limits.get('reports_per_month', 0) - current_usage['reports_this_month'],
|
||||
'forecast_requests': limits.get('forecast_requests_per_month', 0) - current_usage['forecast_requests_this_month'],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
def ratelimited_view(request):
|
||||
"""
|
||||
View for rate limited requests.
|
||||
"""
|
||||
return JsonResponse({
|
||||
'error': 'Rate limit exceeded',
|
||||
'message': 'Too many requests. Please try again later.',
|
||||
'retry_after': 60
|
||||
}, status=429)
|
||||
|
||||
2
apps/integrations/__init__.py
Normal file
2
apps/integrations/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Integrations app
|
||||
|
||||
8
apps/integrations/apps.py
Normal file
8
apps/integrations/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IntegrationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.integrations'
|
||||
verbose_name = 'Integrations'
|
||||
|
||||
11
apps/integrations/urls.py
Normal file
11
apps/integrations/urls.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""
|
||||
URL patterns for integrations app.
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('salesforce/', views.salesforce_integration, name='salesforce_integration'),
|
||||
path('dubai-pulse/', views.dubai_pulse_integration, name='dubai_pulse_integration'),
|
||||
]
|
||||
|
||||
28
apps/integrations/views.py
Normal file
28
apps/integrations/views.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""
|
||||
Views for integrations.
|
||||
"""
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
@api_view(['GET', 'POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def salesforce_integration(request):
|
||||
"""Salesforce integration endpoints."""
|
||||
if request.method == 'GET':
|
||||
return Response({'message': 'Salesforce integration status'})
|
||||
else:
|
||||
return Response({'message': 'Salesforce sync initiated'})
|
||||
|
||||
|
||||
@api_view(['GET', 'POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def dubai_pulse_integration(request):
|
||||
"""Dubai Pulse integration endpoints."""
|
||||
if request.method == 'GET':
|
||||
return Response({'message': 'Dubai Pulse integration status'})
|
||||
else:
|
||||
return Response({'message': 'Dubai Pulse sync initiated'})
|
||||
|
||||
2
apps/monitoring/__init__.py
Normal file
2
apps/monitoring/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Monitoring app
|
||||
|
||||
8
apps/monitoring/apps.py
Normal file
8
apps/monitoring/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MonitoringConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.monitoring'
|
||||
verbose_name = 'Monitoring'
|
||||
|
||||
11
apps/monitoring/urls.py
Normal file
11
apps/monitoring/urls.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""
|
||||
URL patterns for monitoring app.
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('metrics/', views.metrics, name='metrics'),
|
||||
path('health/', views.health_check, name='health_check'),
|
||||
]
|
||||
|
||||
37
apps/monitoring/views.py
Normal file
37
apps/monitoring/views.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""
|
||||
Views for monitoring and metrics.
|
||||
"""
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Count
|
||||
from apps.core.models import APIUsage
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def metrics(request):
|
||||
"""Get system metrics."""
|
||||
# This would be implemented with actual metrics collection
|
||||
return Response({
|
||||
'api_calls_today': 0,
|
||||
'active_users': 0,
|
||||
'system_status': 'healthy',
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def health_check(request):
|
||||
"""Detailed health check."""
|
||||
return Response({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'services': {
|
||||
'database': 'healthy',
|
||||
'redis': 'healthy',
|
||||
'celery': 'healthy',
|
||||
}
|
||||
})
|
||||
|
||||
2
apps/reports/__init__.py
Normal file
2
apps/reports/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Reports app
|
||||
|
||||
8
apps/reports/apps.py
Normal file
8
apps/reports/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReportsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.reports'
|
||||
verbose_name = 'Reports'
|
||||
|
||||
57
apps/reports/migrations/0001_initial.py
Normal file
57
apps/reports/migrations/0001_initial.py
Normal file
@ -0,0 +1,57 @@
|
||||
# Generated by Django 4.2.24 on 2025-09-16 18:48
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReportTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('report_type', models.CharField(max_length=50)),
|
||||
('template_config', models.JSONField(default=dict)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_templates', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'report_templates',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Report',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('report_type', models.CharField(choices=[('transaction_summary', 'Transaction Summary'), ('area_analysis', 'Area Analysis'), ('market_trends', 'Market Trends'), ('forecast_report', 'Forecast Report'), ('custom', 'Custom Report')], max_length=50)),
|
||||
('format', models.CharField(choices=[('pdf', 'PDF'), ('excel', 'Excel'), ('csv', 'CSV')], max_length=10)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='pending', max_length=20)),
|
||||
('file_path', models.CharField(blank=True, max_length=500)),
|
||||
('file_size', models.BigIntegerField(blank=True, null=True)),
|
||||
('parameters', models.JSONField(default=dict)),
|
||||
('error_message', models.TextField(blank=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'reports',
|
||||
'indexes': [models.Index(fields=['user', 'created_at'], name='reports_user_id_90e09e_idx'), models.Index(fields=['status'], name='reports_status_e83c1d_idx'), models.Index(fields=['report_type'], name='reports_report__ff8b25_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/reports/migrations/__init__.py
Normal file
0
apps/reports/migrations/__init__.py
Normal file
67
apps/reports/models.py
Normal file
67
apps/reports/models.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""
|
||||
Report models for the Dubai Analytics Platform.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.core.models import TimeStampedModel
|
||||
import uuid
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Report(TimeStampedModel):
|
||||
"""Generated reports."""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reports')
|
||||
title = models.CharField(max_length=255)
|
||||
report_type = models.CharField(max_length=50, choices=[
|
||||
('transaction_summary', 'Transaction Summary'),
|
||||
('area_analysis', 'Area Analysis'),
|
||||
('market_trends', 'Market Trends'),
|
||||
('forecast_report', 'Forecast Report'),
|
||||
('custom', 'Custom Report'),
|
||||
])
|
||||
format = models.CharField(max_length=10, choices=[
|
||||
('pdf', 'PDF'),
|
||||
('excel', 'Excel'),
|
||||
('csv', 'CSV'),
|
||||
])
|
||||
status = models.CharField(max_length=20, choices=[
|
||||
('pending', 'Pending'),
|
||||
('processing', 'Processing'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
], default='pending')
|
||||
file_path = models.CharField(max_length=500, blank=True)
|
||||
file_size = models.BigIntegerField(null=True, blank=True)
|
||||
parameters = models.JSONField(default=dict)
|
||||
error_message = models.TextField(blank=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'reports'
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'created_at']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['report_type']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.user.email}"
|
||||
|
||||
|
||||
class ReportTemplate(TimeStampedModel):
|
||||
"""Report templates for customization."""
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
report_type = models.CharField(max_length=50)
|
||||
template_config = models.JSONField(default=dict)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_templates')
|
||||
|
||||
class Meta:
|
||||
db_table = 'report_templates'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
22
apps/reports/serializers.py
Normal file
22
apps/reports/serializers.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""
|
||||
Serializers for reports app.
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from .models import Report, ReportTemplate
|
||||
|
||||
|
||||
class ReportSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Report model."""
|
||||
class Meta:
|
||||
model = Report
|
||||
fields = '__all__'
|
||||
read_only_fields = ('id', 'user', 'created_at', 'updated_at', 'file_size', 'error_message')
|
||||
|
||||
|
||||
class ReportTemplateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ReportTemplate model."""
|
||||
class Meta:
|
||||
model = ReportTemplate
|
||||
fields = '__all__'
|
||||
read_only_fields = ('id', 'created_at', 'updated_at')
|
||||
|
||||
345
apps/reports/services.py
Normal file
345
apps/reports/services.py
Normal file
@ -0,0 +1,345 @@
|
||||
"""
|
||||
Report generation services.
|
||||
"""
|
||||
import os
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db.models import Avg, Count, Min, Max, Sum
|
||||
from celery import shared_task
|
||||
from .models import Report
|
||||
from apps.analytics.models import Transaction, Forecast
|
||||
from apps.analytics.services import AnalyticsService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportGenerationService:
|
||||
"""Service for generating various types of reports."""
|
||||
|
||||
@staticmethod
|
||||
@shared_task
|
||||
def generate_transaction_summary_report(report_id, area=None, property_type=None,
|
||||
start_date=None, end_date=None, format_type='pdf'):
|
||||
"""Generate transaction summary report."""
|
||||
try:
|
||||
report = Report.objects.get(id=report_id)
|
||||
report.status = 'processing'
|
||||
report.save()
|
||||
|
||||
# Get transaction data
|
||||
queryset = Transaction.objects.all()
|
||||
|
||||
if area:
|
||||
queryset = queryset.filter(area_en__icontains=area)
|
||||
if property_type:
|
||||
queryset = queryset.filter(property_type=property_type)
|
||||
if start_date:
|
||||
queryset = queryset.filter(instance_date__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(instance_date__lte=end_date)
|
||||
|
||||
# Generate report based on format
|
||||
if format_type == 'pdf':
|
||||
file_path = ReportGenerationService._generate_pdf_report(
|
||||
report, queryset, 'transaction_summary'
|
||||
)
|
||||
elif format_type == 'excel':
|
||||
file_path = ReportGenerationService._generate_excel_report(
|
||||
report, queryset, 'transaction_summary'
|
||||
)
|
||||
elif format_type == 'csv':
|
||||
file_path = ReportGenerationService._generate_csv_report(
|
||||
report, queryset, 'transaction_summary'
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format_type}")
|
||||
|
||||
# Update report with file info
|
||||
report.file_path = file_path
|
||||
report.file_size = os.path.getsize(file_path)
|
||||
report.status = 'completed'
|
||||
report.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating transaction summary report: {e}')
|
||||
report.status = 'failed'
|
||||
report.error_message = str(e)
|
||||
report.save()
|
||||
|
||||
@staticmethod
|
||||
@shared_task
|
||||
def generate_area_analysis_report(report_id, area, format_type='pdf'):
|
||||
"""Generate area analysis report."""
|
||||
try:
|
||||
report = Report.objects.get(id=report_id)
|
||||
report.status = 'processing'
|
||||
report.save()
|
||||
|
||||
# Get area data
|
||||
queryset = Transaction.objects.filter(area_en__icontains=area)
|
||||
|
||||
# Generate report
|
||||
if format_type == 'pdf':
|
||||
file_path = ReportGenerationService._generate_pdf_report(
|
||||
report, queryset, 'area_analysis'
|
||||
)
|
||||
elif format_type == 'excel':
|
||||
file_path = ReportGenerationService._generate_excel_report(
|
||||
report, queryset, 'area_analysis'
|
||||
)
|
||||
elif format_type == 'csv':
|
||||
file_path = ReportGenerationService._generate_csv_report(
|
||||
report, queryset, 'area_analysis'
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format_type}")
|
||||
|
||||
report.file_path = file_path
|
||||
report.file_size = os.path.getsize(file_path)
|
||||
report.status = 'completed'
|
||||
report.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating area analysis report: {e}')
|
||||
report.status = 'failed'
|
||||
report.error_message = str(e)
|
||||
report.save()
|
||||
|
||||
@staticmethod
|
||||
@shared_task
|
||||
def generate_forecast_report(report_id, area, property_type, forecast_periods, format_type='pdf'):
|
||||
"""Generate forecast report."""
|
||||
try:
|
||||
report = Report.objects.get(id=report_id)
|
||||
report.status = 'processing'
|
||||
report.save()
|
||||
|
||||
# Get forecast data
|
||||
forecasts = Forecast.objects.filter(
|
||||
area_en__icontains=area,
|
||||
property_type=property_type
|
||||
).order_by('forecast_date')
|
||||
|
||||
# Generate report
|
||||
if format_type == 'pdf':
|
||||
file_path = ReportGenerationService._generate_pdf_forecast_report(
|
||||
report, forecasts, area, property_type
|
||||
)
|
||||
elif format_type == 'excel':
|
||||
file_path = ReportGenerationService._generate_excel_forecast_report(
|
||||
report, forecasts, area, property_type
|
||||
)
|
||||
elif format_type == 'csv':
|
||||
file_path = ReportGenerationService._generate_csv_forecast_report(
|
||||
report, forecasts, area, property_type
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format_type}")
|
||||
|
||||
report.file_path = file_path
|
||||
report.file_size = os.path.getsize(file_path)
|
||||
report.status = 'completed'
|
||||
report.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating forecast report: {e}')
|
||||
report.status = 'failed'
|
||||
report.error_message = str(e)
|
||||
report.save()
|
||||
|
||||
@staticmethod
|
||||
def _generate_pdf_report(report, queryset, report_type):
|
||||
"""Generate PDF report."""
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter, A4
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.units import inch
|
||||
|
||||
# Create file path
|
||||
filename = f"report_{report.id}_{report_type}.pdf"
|
||||
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
# Create PDF document
|
||||
doc = SimpleDocTemplate(file_path, pagesize=A4)
|
||||
styles = getSampleStyleSheet()
|
||||
story = []
|
||||
|
||||
# Title
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=16,
|
||||
spaceAfter=30,
|
||||
alignment=1 # Center alignment
|
||||
)
|
||||
story.append(Paragraph(f"{report.title}", title_style))
|
||||
story.append(Spacer(1, 20))
|
||||
|
||||
# Generate data based on report type
|
||||
if report_type == 'transaction_summary':
|
||||
data = ReportGenerationService._get_transaction_summary_data(queryset)
|
||||
elif report_type == 'area_analysis':
|
||||
data = ReportGenerationService._get_area_analysis_data(queryset)
|
||||
else:
|
||||
data = []
|
||||
|
||||
# Create table
|
||||
if data:
|
||||
table = Table(data)
|
||||
table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 12),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black)
|
||||
]))
|
||||
story.append(table)
|
||||
|
||||
# Build PDF
|
||||
doc.build(story)
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating PDF report: {e}')
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _generate_excel_report(report, queryset, report_type):
|
||||
"""Generate Excel report."""
|
||||
try:
|
||||
filename = f"report_{report.id}_{report_type}.xlsx"
|
||||
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
# Create Excel writer
|
||||
with pd.ExcelWriter(file_path, engine='openpyxl') as writer:
|
||||
# Generate data based on report type
|
||||
if report_type == 'transaction_summary':
|
||||
data = ReportGenerationService._get_transaction_summary_data(queryset)
|
||||
elif report_type == 'area_analysis':
|
||||
data = ReportGenerationService._get_area_analysis_data(queryset)
|
||||
else:
|
||||
data = []
|
||||
|
||||
if data:
|
||||
# Convert to DataFrame
|
||||
df = pd.DataFrame(data[1:], columns=data[0])
|
||||
df.to_excel(writer, sheet_name='Summary', index=False)
|
||||
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating Excel report: {e}')
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _generate_csv_report(report, queryset, report_type):
|
||||
"""Generate CSV report."""
|
||||
try:
|
||||
filename = f"report_{report.id}_{report_type}.csv"
|
||||
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
# Generate data based on report type
|
||||
if report_type == 'transaction_summary':
|
||||
data = ReportGenerationService._get_transaction_summary_data(queryset)
|
||||
elif report_type == 'area_analysis':
|
||||
data = ReportGenerationService._get_area_analysis_data(queryset)
|
||||
else:
|
||||
data = []
|
||||
|
||||
if data:
|
||||
# Convert to DataFrame and save as CSV
|
||||
df = pd.DataFrame(data[1:], columns=data[0])
|
||||
df.to_csv(file_path, index=False)
|
||||
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating CSV report: {e}')
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _get_transaction_summary_data(queryset):
|
||||
"""Get transaction summary data for reports."""
|
||||
# Calculate summary statistics
|
||||
stats = queryset.aggregate(
|
||||
total_transactions=Count('id'),
|
||||
total_value=Sum('transaction_value'),
|
||||
average_price=Avg('transaction_value'),
|
||||
min_price=Min('transaction_value'),
|
||||
max_price=Max('transaction_value'),
|
||||
)
|
||||
|
||||
# Calculate average price per sqft
|
||||
total_area = queryset.aggregate(total_area=Sum('actual_area'))['total_area']
|
||||
avg_price_per_sqft = (stats['total_value'] / total_area) if total_area and total_area > 0 else 0
|
||||
|
||||
data = [
|
||||
['Metric', 'Value'],
|
||||
['Total Transactions', stats['total_transactions']],
|
||||
['Total Value', f"AED {stats['total_value']:,.2f}"],
|
||||
['Average Price', f"AED {stats['average_price']:,.2f}"],
|
||||
['Min Price', f"AED {stats['min_price']:,.2f}"],
|
||||
['Max Price', f"AED {stats['max_price']:,.2f}"],
|
||||
['Average Price per Sqft', f"AED {avg_price_per_sqft:,.2f}"],
|
||||
]
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _get_area_analysis_data(queryset):
|
||||
"""Get area analysis data for reports."""
|
||||
# Group by area and calculate statistics
|
||||
area_stats = queryset.values('area_en').annotate(
|
||||
transaction_count=Count('id'),
|
||||
average_price=Avg('transaction_value'),
|
||||
total_value=Sum('transaction_value'),
|
||||
).order_by('-transaction_count')[:10]
|
||||
|
||||
data = [['Area', 'Transactions', 'Average Price', 'Total Value']]
|
||||
|
||||
for stat in area_stats:
|
||||
data.append([
|
||||
stat['area_en'],
|
||||
stat['transaction_count'],
|
||||
f"AED {stat['average_price']:,.2f}",
|
||||
f"AED {stat['total_value']:,.2f}",
|
||||
])
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _generate_pdf_forecast_report(report, forecasts, area, property_type):
|
||||
"""Generate PDF forecast report."""
|
||||
# Similar to _generate_pdf_report but for forecast data
|
||||
# Implementation would be similar to above
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _generate_excel_forecast_report(report, forecasts, area, property_type):
|
||||
"""Generate Excel forecast report."""
|
||||
# Similar to _generate_excel_report but for forecast data
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _generate_csv_forecast_report(report, forecasts, area, property_type):
|
||||
"""Generate CSV forecast report."""
|
||||
# Similar to _generate_csv_report but for forecast data
|
||||
pass
|
||||
|
||||
21
apps/reports/urls.py
Normal file
21
apps/reports/urls.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""
|
||||
URL patterns for reports app.
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Report management
|
||||
path('', views.ReportListView.as_view(), name='report_list'),
|
||||
path('<uuid:pk>/', views.ReportDetailView.as_view(), name='report_detail'),
|
||||
path('<uuid:report_id>/download/', views.download_report, name='download_report'),
|
||||
|
||||
# Report generation
|
||||
path('generate/transaction-summary/', views.generate_transaction_summary_report, name='generate_transaction_summary'),
|
||||
path('generate/area-analysis/', views.generate_area_analysis_report, name='generate_area_analysis'),
|
||||
path('generate/forecast/', views.generate_forecast_report, name='generate_forecast'),
|
||||
|
||||
# Templates
|
||||
path('templates/', views.ReportTemplateListView.as_view(), name='report_templates'),
|
||||
]
|
||||
|
||||
258
apps/reports/views.py
Normal file
258
apps/reports/views.py
Normal file
@ -0,0 +1,258 @@
|
||||
"""
|
||||
Views for report generation and management.
|
||||
"""
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.http import FileResponse, Http404
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from .models import Report, ReportTemplate
|
||||
from .serializers import ReportSerializer, ReportTemplateSerializer
|
||||
from .services import ReportGenerationService
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportListView(generics.ListCreateAPIView):
|
||||
"""List and create reports."""
|
||||
serializer_class = ReportSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Report.objects.filter(user=self.request.user).order_by('-created_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
class ReportDetailView(generics.RetrieveDestroyAPIView):
|
||||
"""Report detail view."""
|
||||
serializer_class = ReportSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return Report.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def generate_transaction_summary_report(request):
|
||||
"""Generate transaction summary report."""
|
||||
try:
|
||||
# Check user's report limits
|
||||
user = request.user
|
||||
limits = user.get_usage_limits()
|
||||
if not limits.get('reports_per_month', 0):
|
||||
return Response(
|
||||
{'error': 'Report generation not available for your subscription'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Get parameters
|
||||
area = request.data.get('area')
|
||||
property_type = request.data.get('property_type')
|
||||
start_date = request.data.get('start_date')
|
||||
end_date = request.data.get('end_date')
|
||||
format_type = request.data.get('format', 'pdf')
|
||||
|
||||
# Create report record
|
||||
report = Report.objects.create(
|
||||
user=user,
|
||||
title=f"Transaction Summary Report - {area or 'All Areas'}",
|
||||
report_type='transaction_summary',
|
||||
format=format_type,
|
||||
parameters={
|
||||
'area': area,
|
||||
'property_type': property_type,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
},
|
||||
expires_at=timezone.now() + timedelta(days=7)
|
||||
)
|
||||
|
||||
# Generate report asynchronously
|
||||
report_service = ReportGenerationService()
|
||||
report_service.generate_transaction_summary_report.delay(
|
||||
report.id, area, property_type, start_date, end_date, format_type
|
||||
)
|
||||
|
||||
return Response({
|
||||
'message': 'Report generation started',
|
||||
'report_id': str(report.id),
|
||||
'status': report.status
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating transaction summary report: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to generate report'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def generate_area_analysis_report(request):
|
||||
"""Generate area analysis report."""
|
||||
try:
|
||||
user = request.user
|
||||
limits = user.get_usage_limits()
|
||||
if not limits.get('reports_per_month', 0):
|
||||
return Response(
|
||||
{'error': 'Report generation not available for your subscription'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
area = request.data.get('area')
|
||||
format_type = request.data.get('format', 'pdf')
|
||||
|
||||
if not area:
|
||||
return Response(
|
||||
{'error': 'Area parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
report = Report.objects.create(
|
||||
user=user,
|
||||
title=f"Area Analysis Report - {area}",
|
||||
report_type='area_analysis',
|
||||
format=format_type,
|
||||
parameters={'area': area},
|
||||
expires_at=timezone.now() + timedelta(days=7)
|
||||
)
|
||||
|
||||
report_service = ReportGenerationService()
|
||||
report_service.generate_area_analysis_report.delay(
|
||||
report.id, area, format_type
|
||||
)
|
||||
|
||||
return Response({
|
||||
'message': 'Report generation started',
|
||||
'report_id': str(report.id),
|
||||
'status': report.status
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating area analysis report: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to generate report'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def generate_forecast_report(request):
|
||||
"""Generate forecast report."""
|
||||
try:
|
||||
user = request.user
|
||||
limits = user.get_usage_limits()
|
||||
if not limits.get('reports_per_month', 0):
|
||||
return Response(
|
||||
{'error': 'Report generation not available for your subscription'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
area = request.data.get('area')
|
||||
property_type = request.data.get('property_type')
|
||||
forecast_periods = request.data.get('forecast_periods', 12)
|
||||
format_type = request.data.get('format', 'pdf')
|
||||
|
||||
if not area or not property_type:
|
||||
return Response(
|
||||
{'error': 'Area and property_type parameters are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
report = Report.objects.create(
|
||||
user=user,
|
||||
title=f"Forecast Report - {area} - {property_type}",
|
||||
report_type='forecast_report',
|
||||
format=format_type,
|
||||
parameters={
|
||||
'area': area,
|
||||
'property_type': property_type,
|
||||
'forecast_periods': forecast_periods,
|
||||
},
|
||||
expires_at=timezone.now() + timedelta(days=7)
|
||||
)
|
||||
|
||||
report_service = ReportGenerationService()
|
||||
report_service.generate_forecast_report.delay(
|
||||
report.id, area, property_type, forecast_periods, format_type
|
||||
)
|
||||
|
||||
return Response({
|
||||
'message': 'Report generation started',
|
||||
'report_id': str(report.id),
|
||||
'status': report.status
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating forecast report: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to generate report'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def download_report(request, report_id):
|
||||
"""Download a generated report."""
|
||||
try:
|
||||
report = Report.objects.get(id=report_id, user=request.user)
|
||||
|
||||
if report.status != 'completed':
|
||||
return Response(
|
||||
{'error': 'Report not ready for download'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not report.file_path or not os.path.exists(report.file_path):
|
||||
return Response(
|
||||
{'error': 'Report file not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Determine content type
|
||||
content_type = 'application/pdf'
|
||||
if report.format == 'excel':
|
||||
content_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
elif report.format == 'csv':
|
||||
content_type = 'text/csv'
|
||||
|
||||
response = FileResponse(
|
||||
open(report.file_path, 'rb'),
|
||||
content_type=content_type
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="{report.title}.{report.format}"'
|
||||
|
||||
return response
|
||||
|
||||
except Report.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Report not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error downloading report: {e}')
|
||||
return Response(
|
||||
{'error': 'Failed to download report'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class ReportTemplateListView(generics.ListAPIView):
|
||||
"""List available report templates."""
|
||||
serializer_class = ReportTemplateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return ReportTemplate.objects.filter(is_active=True)
|
||||
|
||||
2
apps/users/__init__.py
Normal file
2
apps/users/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Users app
|
||||
|
||||
8
apps/users/apps.py
Normal file
8
apps/users/apps.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.users'
|
||||
verbose_name = 'Users'
|
||||
|
||||
105
apps/users/migrations/0001_initial.py
Normal file
105
apps/users/migrations/0001_initial.py
Normal file
@ -0,0 +1,105 @@
|
||||
# Generated by Django 4.2.24 on 2025-09-16 18:48
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('subscription_type', models.CharField(choices=[('free', 'Free'), ('paid', 'Paid'), ('premium', 'Premium')], default='free', max_length=20)),
|
||||
('api_key', models.CharField(blank=True, max_length=64, null=True, unique=True)),
|
||||
('is_api_enabled', models.BooleanField(default=False)),
|
||||
('last_api_usage', models.DateTimeField(blank=True, null=True)),
|
||||
('company_name', models.CharField(blank=True, max_length=255)),
|
||||
('phone_number', models.CharField(blank=True, max_length=20)),
|
||||
('is_verified', models.BooleanField(default=False)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'users',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
|
||||
('bio', models.TextField(blank=True)),
|
||||
('website', models.URLField(blank=True)),
|
||||
('location', models.CharField(blank=True, max_length=255)),
|
||||
('timezone', models.CharField(default='Asia/Dubai', max_length=50)),
|
||||
('language', models.CharField(default='en', max_length=10)),
|
||||
('notification_preferences', models.JSONField(default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'user_profiles',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('session_key', models.CharField(max_length=40, unique=True)),
|
||||
('ip_address', models.GenericIPAddressField()),
|
||||
('user_agent', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_activity', models.DateTimeField(auto_now=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'user_sessions',
|
||||
'indexes': [models.Index(fields=['user', 'is_active'], name='user_sessio_user_id_bb1b83_idx'), models.Index(fields=['session_key'], name='user_sessio_session_cc84b9_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='APIKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('key', models.CharField(max_length=64, unique=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('last_used', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'api_keys',
|
||||
'indexes': [models.Index(fields=['user', 'is_active'], name='api_keys_user_id_6e7352_idx'), models.Index(fields=['key'], name='api_keys_key_291dcc_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/users/migrations/__init__.py
Normal file
0
apps/users/migrations/__init__.py
Normal file
139
apps/users/models.py
Normal file
139
apps/users/models.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""
|
||||
User models for the Dubai Analytics Platform.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.utils import timezone
|
||||
import uuid
|
||||
import secrets
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Custom user model with additional fields for the platform."""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
email = models.EmailField(unique=True)
|
||||
subscription_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('free', 'Free'),
|
||||
('paid', 'Paid'),
|
||||
('premium', 'Premium'),
|
||||
],
|
||||
default='free'
|
||||
)
|
||||
api_key = models.CharField(max_length=64, unique=True, blank=True, null=True)
|
||||
is_api_enabled = models.BooleanField(default=False)
|
||||
last_api_usage = models.DateTimeField(null=True, blank=True)
|
||||
company_name = models.CharField(max_length=255, blank=True)
|
||||
phone_number = models.CharField(max_length=20, blank=True)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} ({self.subscription_type})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Generate API key if not exists and API is enabled
|
||||
if self.is_api_enabled and not self.api_key:
|
||||
self.api_key = self.generate_api_key()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def generate_api_key(self):
|
||||
"""Generate a secure API key."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def get_usage_limits(self):
|
||||
"""Get usage limits based on subscription type."""
|
||||
from django.conf import settings
|
||||
return settings.BILLING_SETTINGS.get(f'{self.subscription_type.upper()}_TIER_LIMITS', {})
|
||||
|
||||
def can_make_api_call(self):
|
||||
"""Check if user can make an API call based on their limits."""
|
||||
if not self.is_api_enabled:
|
||||
return False
|
||||
|
||||
limits = self.get_usage_limits()
|
||||
if not limits:
|
||||
return False
|
||||
|
||||
# This would need to be implemented with actual usage tracking
|
||||
# For now, return True if user has API enabled
|
||||
return True
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
"""Extended user profile information."""
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
|
||||
bio = models.TextField(blank=True)
|
||||
website = models.URLField(blank=True)
|
||||
location = models.CharField(max_length=255, blank=True)
|
||||
timezone = models.CharField(max_length=50, default='Asia/Dubai')
|
||||
language = models.CharField(max_length=10, default='en')
|
||||
notification_preferences = models.JSONField(default=dict)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_profiles'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} Profile"
|
||||
|
||||
|
||||
class UserSession(models.Model):
|
||||
"""Track user sessions for security."""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
|
||||
session_key = models.CharField(max_length=40, unique=True)
|
||||
ip_address = models.GenericIPAddressField()
|
||||
user_agent = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_activity = models.DateTimeField(auto_now=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'user_sessions'
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'is_active']),
|
||||
models.Index(fields=['session_key']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.session_key[:8]}..."
|
||||
|
||||
|
||||
class APIKey(models.Model):
|
||||
"""API keys for programmatic access."""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='api_keys')
|
||||
name = models.CharField(max_length=100)
|
||||
key = models.CharField(max_length=64, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
last_used = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'api_keys'
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'is_active']),
|
||||
models.Index(fields=['key']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.name}"
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if API key is expired."""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if API key is valid."""
|
||||
return self.is_active and not self.is_expired()
|
||||
|
||||
156
apps/users/serializers.py
Normal file
156
apps/users/serializers.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""
|
||||
Serializers for user-related models.
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from .models import User, UserProfile, APIKey
|
||||
import secrets
|
||||
|
||||
|
||||
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user registration."""
|
||||
password = serializers.CharField(write_only=True, validators=[validate_password])
|
||||
password_confirm = serializers.CharField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('username', 'email', 'password', 'password_confirm', 'first_name', 'last_name', 'company_name', 'phone_number')
|
||||
|
||||
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')
|
||||
user = User.objects.create_user(**validated_data)
|
||||
return user
|
||||
|
||||
|
||||
class UserLoginSerializer(serializers.Serializer):
|
||||
"""Serializer for user login."""
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField()
|
||||
|
||||
def validate(self, attrs):
|
||||
email = attrs.get('email')
|
||||
password = attrs.get('password')
|
||||
|
||||
if email and password:
|
||||
user = authenticate(username=email, password=password)
|
||||
if not user:
|
||||
raise serializers.ValidationError('Invalid credentials.')
|
||||
if not user.is_active:
|
||||
raise serializers.ValidationError('User account is disabled.')
|
||||
attrs['user'] = user
|
||||
return attrs
|
||||
else:
|
||||
raise serializers.ValidationError('Must include email and password.')
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user details."""
|
||||
profile = serializers.SerializerMethodField()
|
||||
usage_limits = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
'id', 'username', 'email', 'first_name', 'last_name', 'subscription_type',
|
||||
'is_api_enabled', 'company_name', 'phone_number', 'is_verified',
|
||||
'date_joined', 'last_login', 'profile', 'usage_limits'
|
||||
)
|
||||
read_only_fields = ('id', 'date_joined', 'last_login')
|
||||
|
||||
def get_profile(self, obj):
|
||||
try:
|
||||
profile = obj.profile
|
||||
return UserProfileSerializer(profile).data
|
||||
except UserProfile.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_usage_limits(self, obj):
|
||||
return obj.get_usage_limits()
|
||||
|
||||
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user profile."""
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = (
|
||||
'avatar', 'bio', 'website', 'location', 'timezone', 'language',
|
||||
'notification_preferences', 'created_at', 'updated_at'
|
||||
)
|
||||
read_only_fields = ('created_at', 'updated_at')
|
||||
|
||||
|
||||
class UserUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating user information."""
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('first_name', 'last_name', 'company_name', 'phone_number')
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class PasswordChangeSerializer(serializers.Serializer):
|
||||
"""Serializer for password change."""
|
||||
old_password = serializers.CharField()
|
||||
new_password = serializers.CharField(validators=[validate_password])
|
||||
new_password_confirm = serializers.CharField()
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs['new_password'] != attrs['new_password_confirm']:
|
||||
raise serializers.ValidationError("New passwords don't match.")
|
||||
return attrs
|
||||
|
||||
def validate_old_password(self, value):
|
||||
user = self.context['request'].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError('Old password is incorrect.')
|
||||
return value
|
||||
|
||||
|
||||
class APIKeySerializer(serializers.ModelSerializer):
|
||||
"""Serializer for API keys."""
|
||||
class Meta:
|
||||
model = APIKey
|
||||
fields = ('id', 'name', 'key', 'is_active', 'last_used', 'created_at', 'expires_at')
|
||||
read_only_fields = ('id', 'key', 'last_used', 'created_at')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['key'] = secrets.token_urlsafe(32)
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class APIKeyCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating API keys."""
|
||||
class Meta:
|
||||
model = APIKey
|
||||
fields = ('name', 'expires_at')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
validated_data['key'] = secrets.token_urlsafe(32)
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class SubscriptionUpdateSerializer(serializers.Serializer):
|
||||
"""Serializer for updating user subscription."""
|
||||
subscription_type = serializers.ChoiceField(choices=[
|
||||
('free', 'Free'),
|
||||
('paid', 'Paid'),
|
||||
('premium', 'Premium'),
|
||||
])
|
||||
|
||||
def validate_subscription_type(self, value):
|
||||
user = self.context['request'].user
|
||||
if user.subscription_type == value:
|
||||
raise serializers.ValidationError('User already has this subscription type.')
|
||||
return value
|
||||
|
||||
30
apps/users/urls.py
Normal file
30
apps/users/urls.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""
|
||||
URL patterns for users app.
|
||||
"""
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Authentication
|
||||
path('register/', views.UserRegistrationView.as_view(), name='user_register'),
|
||||
path('login/', views.login_view, name='user_login'),
|
||||
path('logout/', views.logout_view, name='user_logout'),
|
||||
path('user/', views.get_current_user, name='get_current_user'),
|
||||
|
||||
# User management
|
||||
path('profile/', views.UserProfileView.as_view(), name='user_profile'),
|
||||
path('update/', views.UserUpdateView.as_view(), name='user_update'),
|
||||
path('change-password/', views.change_password_view, name='change_password'),
|
||||
path('profile-detail/', views.UserProfileDetailView.as_view(), name='user_profile_detail'),
|
||||
|
||||
# API Key management
|
||||
path('api-keys/', views.APIKeyListView.as_view(), name='api_key_list'),
|
||||
path('api-keys/<int:pk>/', views.APIKeyDetailView.as_view(), name='api_key_detail'),
|
||||
path('regenerate-api-key/', views.regenerate_api_key_view, name='regenerate_api_key'),
|
||||
path('toggle-api-access/', views.toggle_api_access_view, name='toggle_api_access'),
|
||||
|
||||
# Admin functions
|
||||
path('update-subscription/', views.update_subscription_view, name='update_subscription'),
|
||||
path('list/', views.UserListView.as_view(), name='user_list'),
|
||||
]
|
||||
|
||||
247
apps/users/views.py
Normal file
247
apps/users/views.py
Normal file
@ -0,0 +1,247 @@
|
||||
"""
|
||||
Views for user authentication and management.
|
||||
"""
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from .models import User, UserProfile, APIKey
|
||||
from .serializers import (
|
||||
UserRegistrationSerializer, UserLoginSerializer, UserSerializer,
|
||||
UserProfileSerializer, UserUpdateSerializer, PasswordChangeSerializer,
|
||||
APIKeySerializer, APIKeyCreateSerializer, SubscriptionUpdateSerializer
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserRegistrationView(generics.CreateAPIView):
|
||||
"""User registration endpoint."""
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserRegistrationSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
with transaction.atomic():
|
||||
user = serializer.save()
|
||||
# Create user profile
|
||||
UserProfile.objects.create(user=user)
|
||||
|
||||
# Generate tokens
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
'message': 'User created successfully',
|
||||
'user': UserSerializer(user).data,
|
||||
'tokens': {
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def login_view(request):
|
||||
"""User login endpoint."""
|
||||
serializer = UserLoginSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = serializer.validated_data['user']
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
'message': 'Login successful',
|
||||
'user': UserSerializer(user).data,
|
||||
'tokens': {
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def logout_view(request):
|
||||
"""User logout endpoint."""
|
||||
try:
|
||||
refresh_token = request.data["refresh"]
|
||||
token = RefreshToken(refresh_token)
|
||||
token.blacklist()
|
||||
return Response({'message': 'Logout successful'})
|
||||
except Exception as e:
|
||||
return Response({'error': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class UserProfileView(generics.RetrieveUpdateAPIView):
|
||||
"""User profile management."""
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
class UserUpdateView(generics.UpdateAPIView):
|
||||
"""Update user information."""
|
||||
serializer_class = UserUpdateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def change_password_view(request):
|
||||
"""Change user password."""
|
||||
serializer = PasswordChangeSerializer(data=request.data, context={'request': request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = request.user
|
||||
user.set_password(serializer.validated_data['new_password'])
|
||||
user.save()
|
||||
|
||||
return Response({'message': 'Password changed successfully'})
|
||||
|
||||
|
||||
class UserProfileDetailView(generics.RetrieveUpdateAPIView):
|
||||
"""User profile detail management."""
|
||||
serializer_class = UserProfileSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_object(self):
|
||||
profile, created = UserProfile.objects.get_or_create(user=self.request.user)
|
||||
return profile
|
||||
|
||||
|
||||
class APIKeyListView(generics.ListCreateAPIView):
|
||||
"""List and create API keys."""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method == 'POST':
|
||||
return APIKeyCreateSerializer
|
||||
return APIKeySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return APIKey.objects.filter(user=self.request.user, is_active=True)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
class APIKeyDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""API key detail management."""
|
||||
serializer_class = APIKeySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return APIKey.objects.filter(user=self.request.user)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.is_active = False
|
||||
instance.save()
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def regenerate_api_key_view(request):
|
||||
"""Regenerate user's main API key."""
|
||||
user = request.user
|
||||
|
||||
if not user.is_api_enabled:
|
||||
return Response(
|
||||
{'error': 'API access is not enabled for this user'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
user.api_key = user.generate_api_key()
|
||||
user.save()
|
||||
|
||||
return Response({
|
||||
'message': 'API key regenerated successfully',
|
||||
'api_key': user.api_key
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def toggle_api_access_view(request):
|
||||
"""Toggle API access for user."""
|
||||
user = request.user
|
||||
|
||||
if not user.api_key:
|
||||
user.api_key = user.generate_api_key()
|
||||
|
||||
user.is_api_enabled = not user.is_api_enabled
|
||||
user.save()
|
||||
|
||||
return Response({
|
||||
'message': f'API access {"enabled" if user.is_api_enabled else "disabled"}',
|
||||
'is_api_enabled': user.is_api_enabled,
|
||||
'api_key': user.api_key if user.is_api_enabled else None
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def update_subscription_view(request):
|
||||
"""Update user subscription (admin only)."""
|
||||
if not request.user.is_staff:
|
||||
return Response(
|
||||
{'error': 'Permission denied'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
serializer = SubscriptionUpdateSerializer(data=request.data, context={'request': request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user_id = request.data.get('user_id')
|
||||
if not user_id:
|
||||
return Response(
|
||||
{'error': 'user_id is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'User not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
user.subscription_type = serializer.validated_data['subscription_type']
|
||||
user.save()
|
||||
|
||||
return Response({
|
||||
'message': 'Subscription updated successfully',
|
||||
'user': UserSerializer(user).data
|
||||
})
|
||||
|
||||
|
||||
class UserListView(generics.ListAPIView):
|
||||
"""List all users (admin only)."""
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_staff:
|
||||
return User.objects.none()
|
||||
return User.objects.all().order_by('-date_joined')
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_current_user(request):
|
||||
"""Get current authenticated user."""
|
||||
serializer = UserSerializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
140
docker-compose.yml
Normal file
140
docker-compose.yml
Normal file
@ -0,0 +1,140 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb:latest-pg14
|
||||
environment:
|
||||
POSTGRES_DB: data_analysis
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: Admin@123
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
backend:
|
||||
build: .
|
||||
command: >
|
||||
sh -c "python manage.py migrate &&
|
||||
python manage.py import_csv_data --data-dir sample\ data &&
|
||||
python manage.py runserver 0.0.0.0:8000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- media_volume:/app/media
|
||||
- logs_volume:/app/logs
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DEBUG=1
|
||||
- DB_NAME=data_analysis
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=Admin@123
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
celery:
|
||||
build: .
|
||||
command: celery -A dubai_analytics worker --loglevel=info
|
||||
volumes:
|
||||
- .:/app
|
||||
- media_volume:/app/media
|
||||
- logs_volume:/app/logs
|
||||
environment:
|
||||
- DEBUG=1
|
||||
- DB_NAME=data_analysis
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=Admin@123
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
celery-beat:
|
||||
build: .
|
||||
command: celery -A dubai_analytics beat --loglevel=info
|
||||
volumes:
|
||||
- .:/app
|
||||
- media_volume:/app/media
|
||||
- logs_volume:/app/logs
|
||||
environment:
|
||||
- DEBUG=1
|
||||
- DB_NAME=data_analysis
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=Admin@123
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_BASE_URL=http://localhost:8000/api/v1
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./ssl:/etc/nginx/ssl
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
media_volume:
|
||||
logs_volume:
|
||||
|
||||
1
dubai_analytics/__init__.py
Normal file
1
dubai_analytics/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Dubai Analytics Platform
|
||||
12
dubai_analytics/asgi.py
Normal file
12
dubai_analytics/asgi.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""
|
||||
ASGI config for Dubai Analytics Platform.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dubai_analytics.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
|
||||
357
dubai_analytics/settings.py
Normal file
357
dubai_analytics/settings.py
Normal file
@ -0,0 +1,357 @@
|
||||
"""
|
||||
Django settings for Dubai Analytics Platform.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
import environ
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Environment variables
|
||||
env = environ.Env(
|
||||
DEBUG=(bool, False),
|
||||
SECRET_KEY=(str, 'django-insecure-change-this-in-production'),
|
||||
DATABASE_URL=(str, 'postgresql://user:password@localhost:5432/dubai_analytics'),
|
||||
REDIS_URL=(str, 'redis://localhost:6379/0'),
|
||||
CELERY_BROKER_URL=(str, 'redis://localhost:6379/1'),
|
||||
SENTRY_DSN=(str, ''),
|
||||
)
|
||||
|
||||
# Read .env file
|
||||
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
|
||||
|
||||
# Try to import local settings for development
|
||||
try:
|
||||
from local_settings import *
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = env('SECRET_KEY')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = env('DEBUG')
|
||||
|
||||
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', 'dubai-analytics.com']
|
||||
|
||||
# Application definition
|
||||
DJANGO_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
]
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
'rest_framework',
|
||||
'rest_framework_simplejwt',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'drf_spectacular',
|
||||
'django_celery_beat',
|
||||
'django_celery_results',
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
'apps.core',
|
||||
'apps.users',
|
||||
'apps.analytics',
|
||||
'apps.reports',
|
||||
'apps.integrations',
|
||||
'apps.billing',
|
||||
'apps.monitoring',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'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',
|
||||
'apps.core.middleware.APIRateLimitMiddleware',
|
||||
'apps.core.middleware.APILoggingMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'dubai_analytics.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'dubai_analytics.wsgi.application'
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': env('DB_NAME', default='data_analysis'),
|
||||
'USER': env('DB_USER', default='postgres'),
|
||||
'PASSWORD': env('DB_PASSWORD', default='Admin@123'),
|
||||
'HOST': env('DB_HOST', default='localhost'),
|
||||
'PORT': env('DB_PORT', default='5432'),
|
||||
'OPTIONS': {}
|
||||
}
|
||||
}
|
||||
|
||||
# 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 = 'en-us'
|
||||
TIME_ZONE = 'Asia/Dubai'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Custom User Model
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
|
||||
# REST Framework Configuration
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'apps.core.authentication.APIKeyAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'apps.core.pagination.StandardResultsSetPagination',
|
||||
'PAGE_SIZE': 50,
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
'rest_framework.filters.SearchFilter',
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
],
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
'EXCEPTION_HANDLER': 'apps.core.exceptions.custom_exception_handler',
|
||||
}
|
||||
|
||||
# JWT Configuration
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=1),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'UPDATE_LAST_LOGIN': True,
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
'VERIFYING_KEY': None,
|
||||
'AUDIENCE': None,
|
||||
'ISSUER': None,
|
||||
'JWK_URL': None,
|
||||
'LEEWAY': 0,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
|
||||
'USER_ID_FIELD': 'id',
|
||||
'USER_ID_CLAIM': 'user_id',
|
||||
'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',
|
||||
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
|
||||
'TOKEN_TYPE_CLAIM': 'token_type',
|
||||
'JTI_CLAIM': 'jti',
|
||||
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
|
||||
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
|
||||
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
|
||||
}
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"https://admin.dubai-analytics.com",
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# Cache Configuration
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django_redis.cache.RedisCache',
|
||||
'LOCATION': env('REDIS_URL'),
|
||||
'OPTIONS': {
|
||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Celery Configuration
|
||||
CELERY_BROKER_URL = env('CELERY_BROKER_URL')
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
||||
|
||||
# API Documentation
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'Dubai Analytics API',
|
||||
'DESCRIPTION': 'Multi-tenant SaaS platform for Dubai Land Department data analytics',
|
||||
'VERSION': '1.0.0',
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
'SCHEMA_PATH_PREFIX': '/api/v1/',
|
||||
}
|
||||
|
||||
# Rate Limiting
|
||||
RATELIMIT_USE_CACHE = 'default'
|
||||
RATELIMIT_VIEW = 'apps.core.views.ratelimited_view'
|
||||
|
||||
# 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': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'simple',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'apps': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Security Settings
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
|
||||
# Session Configuration
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_AGE = 3600
|
||||
|
||||
# CSRF Configuration
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
|
||||
# Email Configuration
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_HOST = env('EMAIL_HOST', default='localhost')
|
||||
EMAIL_PORT = env('EMAIL_PORT', default=587)
|
||||
EMAIL_USE_TLS = env('EMAIL_USE_TLS', default=True)
|
||||
EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='')
|
||||
EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='')
|
||||
DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@dubai-analytics.com')
|
||||
|
||||
# Sentry Configuration
|
||||
if env('SENTRY_DSN'):
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=env('SENTRY_DSN'),
|
||||
integrations=[
|
||||
DjangoIntegration(),
|
||||
CeleryIntegration(),
|
||||
],
|
||||
traces_sample_rate=0.1,
|
||||
send_default_pii=True,
|
||||
)
|
||||
|
||||
# Analytics Configuration
|
||||
ANALYTICS_SETTINGS = {
|
||||
'FORECAST_PERIODS': 12, # months
|
||||
'CONFIDENCE_INTERVAL': 0.95,
|
||||
'MIN_DATA_POINTS': 10,
|
||||
'CACHE_FORECASTS_HOURS': 24,
|
||||
}
|
||||
|
||||
# Billing Configuration
|
||||
BILLING_SETTINGS = {
|
||||
'FREE_TIER_LIMITS': {
|
||||
'api_calls_per_month': 500,
|
||||
'reports_per_month': 5,
|
||||
'forecast_requests_per_month': 10,
|
||||
},
|
||||
'PAID_TIER_LIMITS': {
|
||||
'api_calls_per_month': 10000,
|
||||
'reports_per_month': 100,
|
||||
'forecast_requests_per_month': 1000,
|
||||
},
|
||||
'PREMIUM_TIER_LIMITS': {
|
||||
'api_calls_per_month': 100000,
|
||||
'reports_per_month': 1000,
|
||||
'forecast_requests_per_month': 10000,
|
||||
},
|
||||
}
|
||||
33
dubai_analytics/urls.py
Normal file
33
dubai_analytics/urls.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""
|
||||
URL configuration for Dubai Analytics Platform.
|
||||
"""
|
||||
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, SpectacularSwaggerView, SpectacularRedocView
|
||||
|
||||
urlpatterns = [
|
||||
# Admin
|
||||
path('admin/', admin.site.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 v1
|
||||
path('api/v1/auth/', include('apps.users.urls')),
|
||||
path('api/v1/analytics/', include('apps.analytics.urls')),
|
||||
path('api/v1/reports/', include('apps.reports.urls')),
|
||||
path('api/v1/integrations/', include('apps.integrations.urls')),
|
||||
path('api/v1/billing/', include('apps.billing.urls')),
|
||||
path('api/v1/monitoring/', include('apps.monitoring.urls')),
|
||||
path('api/v1/', include('apps.core.urls')),
|
||||
]
|
||||
|
||||
# Serve media files in development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
12
dubai_analytics/wsgi.py
Normal file
12
dubai_analytics/wsgi.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""
|
||||
WSGI config for Dubai Analytics Platform.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dubai_analytics.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
||||
63
env.example
Normal file
63
env.example
Normal file
@ -0,0 +1,63 @@
|
||||
# Django Settings
|
||||
DEBUG=False
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,dubai-analytics.com
|
||||
|
||||
# Database Configuration
|
||||
DB_NAME=dubai_analytics
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=your-database-password
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/1
|
||||
|
||||
# Email Configuration
|
||||
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
|
||||
DEFAULT_FROM_EMAIL=noreply@dubai-analytics.com
|
||||
|
||||
# Sentry Configuration (Optional)
|
||||
SENTRY_DSN=your-sentry-dsn-here
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_API_BASE_URL=http://localhost:8000/api/v1
|
||||
|
||||
# Security Settings
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,https://admin.dubai-analytics.com
|
||||
|
||||
# File Upload Settings
|
||||
MAX_UPLOAD_SIZE=10485760 # 10MB
|
||||
ALLOWED_FILE_TYPES=csv,xlsx,pdf
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
RATE_LIMIT_PER_HOUR=1000
|
||||
RATE_LIMIT_PER_DAY=10000
|
||||
|
||||
# Billing Settings
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_your-stripe-key
|
||||
STRIPE_SECRET_KEY=sk_test_your-stripe-secret
|
||||
PAYPAL_CLIENT_ID=your-paypal-client-id
|
||||
PAYPAL_CLIENT_SECRET=your-paypal-secret
|
||||
|
||||
# Dubai Pulse API
|
||||
DUBAI_PULSE_API_KEY=your-dubai-pulse-api-key
|
||||
DUBAI_PULSE_BASE_URL=https://api.dubaipulse.gov.ae
|
||||
|
||||
# Salesforce Integration
|
||||
SALESFORCE_CLIENT_ID=your-salesforce-client-id
|
||||
SALESFORCE_CLIENT_SECRET=your-salesforce-client-secret
|
||||
SALESFORCE_USERNAME=your-salesforce-username
|
||||
SALESFORCE_PASSWORD=your-salesforce-password
|
||||
SALESFORCE_SECURITY_TOKEN=your-salesforce-security-token
|
||||
SALESFORCE_SANDBOX=False
|
||||
|
||||
# Monitoring
|
||||
PROMETHEUS_ENABLED=True
|
||||
GRAFANA_ENABLED=True
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dubai Analytics - Admin Panel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="h-full bg-gray-50 dark:bg-gray-900">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6293
frontend/package-lock.json
generated
Normal file
6293
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "dubai-analytics-admin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-redux": "^9.0.4",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"recharts": "^2.8.0",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"vite": "^4.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
58
frontend/src/App.jsx
Normal file
58
frontend/src/App.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { useAuthState } from './hooks/useAuthState'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Users from './pages/Users'
|
||||
import Analytics from './pages/Analytics'
|
||||
import Reports from './pages/Reports'
|
||||
import Settings from './pages/Settings'
|
||||
import Payments from './pages/Payments'
|
||||
import { selectIsAuthenticated } from './store/slices/authSlice'
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated, isInitialized } = useAuthState()
|
||||
const { isLoading } = useAuth()
|
||||
|
||||
// Show loading spinner while checking authentication
|
||||
if (!isInitialized || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={!isAuthenticated ? <Login /> : <Navigate to="/dashboard" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/*"
|
||||
element={isAuthenticated ? <Layout /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
142
frontend/src/components/Chart.jsx
Normal file
142
frontend/src/components/Chart.jsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { Line, Bar, Pie } from 'react-chartjs-2'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement,
|
||||
} from 'chart.js'
|
||||
import { BarChart3, TrendingUp, AlertCircle } from 'lucide-react'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ArcElement
|
||||
)
|
||||
|
||||
const Chart = ({ data, type, height = 300, options = {}, title, subtitle }) => {
|
||||
// Check if data is null, undefined, or not an array
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center" style={{ height }}>
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-green-100 dark:from-blue-900/20 dark:to-green-900/20 rounded-2xl flex items-center justify-center">
|
||||
{type === 'line' ? (
|
||||
<TrendingUp className="h-10 w-10 text-blue-500 dark:text-blue-400" />
|
||||
) : (
|
||||
<BarChart3 className="h-10 w-10 text-green-500 dark:text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
{title || 'No Data Available'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-md">
|
||||
{subtitle || 'There is no data to display for the selected time period. Try adjusting your filters or check back later.'}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center space-x-2 text-xs text-gray-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>Data will appear here once available</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
},
|
||||
...options,
|
||||
}
|
||||
|
||||
const renderChart = () => {
|
||||
switch (type) {
|
||||
case 'line':
|
||||
return (
|
||||
<Line
|
||||
data={{
|
||||
labels: data.map(item => new Date(item.date).toLocaleDateString()),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Transaction Value',
|
||||
data: data.map(item => item.value),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={defaultOptions}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'bar':
|
||||
return (
|
||||
<Bar
|
||||
data={{
|
||||
labels: data.map(item => item.area),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Transaction Count',
|
||||
data: data.map(item => item.transaction_count),
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.8)',
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={defaultOptions}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'pie':
|
||||
return (
|
||||
<Pie
|
||||
data={{
|
||||
labels: data.map(item => item.area),
|
||||
datasets: [
|
||||
{
|
||||
data: data.map(item => item.transaction_count),
|
||||
backgroundColor: [
|
||||
'rgba(59, 130, 246, 0.8)',
|
||||
'rgba(16, 185, 129, 0.8)',
|
||||
'rgba(245, 158, 11, 0.8)',
|
||||
'rgba(239, 68, 68, 0.8)',
|
||||
'rgba(139, 92, 246, 0.8)',
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={defaultOptions}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height }}>
|
||||
{renderChart()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Chart
|
||||
|
||||
126
frontend/src/components/Header.jsx
Normal file
126
frontend/src/components/Header.jsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Menu,
|
||||
Bell,
|
||||
Search,
|
||||
Sun,
|
||||
Moon,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronDown
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
const Header = ({ onMenuClick }) => {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { logout } = useAuth()
|
||||
const user = useSelector(state => state.auth.user)
|
||||
const [showUserMenu, setShowUserMenu] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
setShowUserMenu(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between h-16 px-6">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<div className="hidden md:block relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="block w-64 pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="h-5 w-5" />
|
||||
) : (
|
||||
<Sun className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<button className="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center space-x-3 p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="hidden md:block text-left">
|
||||
<p className="text-sm font-medium">{user?.first_name || 'Admin'}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{user?.subscription_type || 'admin'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-50 border border-gray-200 dark:border-gray-700">
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<User className="h-4 w-4 mr-3" />
|
||||
Profile
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-3" />
|
||||
Settings
|
||||
</a>
|
||||
<hr className="my-1 border-gray-200 dark:border-gray-700" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-3" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
|
||||
44
frontend/src/components/Layout.jsx
Normal file
44
frontend/src/components/Layout.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
import Dashboard from '../pages/Dashboard'
|
||||
import Users from '../pages/Users'
|
||||
import Analytics from '../pages/Analytics'
|
||||
import Reports from '../pages/Reports'
|
||||
import Settings from '../pages/Settings'
|
||||
import Payments from '../pages/Payments'
|
||||
|
||||
const Layout = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Sidebar */}
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<Header onMenuClick={() => setSidebarOpen(true)} />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/reports" element={<Reports />} />
|
||||
<Route path="/payments" element={<Payments />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
||||
86
frontend/src/components/Modal.jsx
Normal file
86
frontend/src/components/Modal.jsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
closeOnOverlayClick = true
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
full: 'max-w-7xl'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={closeOnOverlayClick ? onClose : undefined}
|
||||
></div>
|
||||
|
||||
{/* Modal */}
|
||||
<div className={`inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle ${sizeClasses[size]} sm:w-full`}>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pt-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Modal
|
||||
84
frontend/src/components/RecentActivity.jsx
Normal file
84
frontend/src/components/RecentActivity.jsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { Activity, User, FileText, BarChart3 } from 'lucide-react'
|
||||
|
||||
const RecentActivity = () => {
|
||||
const activities = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'user',
|
||||
message: 'New user registered: john.doe@example.com',
|
||||
time: '2 minutes ago',
|
||||
icon: User,
|
||||
color: 'text-blue-600 bg-blue-100 dark:bg-blue-900/20'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'report',
|
||||
message: 'Report generated: Area Analysis - Downtown Dubai',
|
||||
time: '15 minutes ago',
|
||||
icon: FileText,
|
||||
color: 'text-green-600 bg-green-100 dark:bg-green-900/20'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'analytics',
|
||||
message: 'Forecast generated for Palm Jumeirah properties',
|
||||
time: '1 hour ago',
|
||||
icon: BarChart3,
|
||||
color: 'text-purple-600 bg-purple-100 dark:bg-purple-900/20'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'system',
|
||||
message: 'API rate limit increased for premium users',
|
||||
time: '2 hours ago',
|
||||
icon: Activity,
|
||||
color: 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/20'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'user',
|
||||
message: 'User subscription upgraded to Premium',
|
||||
time: '3 hours ago',
|
||||
icon: User,
|
||||
color: 'text-blue-600 bg-blue-100 dark:bg-blue-900/20'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<Activity className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start space-x-3">
|
||||
<div className={`p-2 rounded-full ${activity.color}`}>
|
||||
<activity.icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-900 dark:text-white">
|
||||
{activity.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{activity.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button className="text-sm text-primary-600 hover:text-primary-500 font-medium">
|
||||
View all activity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecentActivity
|
||||
|
||||
132
frontend/src/components/Sidebar.jsx
Normal file
132
frontend/src/components/Sidebar.jsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
Home,
|
||||
Users,
|
||||
BarChart3,
|
||||
FileText,
|
||||
Settings,
|
||||
X,
|
||||
Building2,
|
||||
TrendingUp,
|
||||
PieChart,
|
||||
CreditCard
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
const Sidebar = ({ isOpen, onClose }) => {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||
{ name: 'Users', href: '/users', icon: Users },
|
||||
{ name: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||
{ name: 'Reports', href: '/reports', icon: FileText },
|
||||
{ name: 'Payments', href: '/payments', icon: CreditCard },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
const quickStats = [
|
||||
{ name: 'Total Users', value: '1,234', icon: Users, change: '+12%' },
|
||||
{ name: 'API Calls Today', value: '45,678', icon: TrendingUp, change: '+8%' },
|
||||
{ name: 'Reports Generated', value: '89', icon: FileText, change: '+23%' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-gray-800 shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-8 w-8 text-primary-600" />
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
Dubai Analytics
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="mt-8 px-4">
|
||||
<div className="space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`sidebar-item ${isActive ? 'active' : ''}`
|
||||
}
|
||||
onClick={onClose}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="mt-8 px-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
|
||||
Quick Stats
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{quickStats.map((stat) => (
|
||||
<div key={stat.name} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<stat.icon className="h-5 w-5 text-primary-600" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-900 dark:text-white">
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{stat.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-green-600 dark:text-green-400">
|
||||
{stat.change}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="absolute bottom-0 w-full p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">A</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-gray-900 dark:text-white truncate">
|
||||
Admin User
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
admin@dubai-analytics.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
|
||||
74
frontend/src/components/StatCard.jsx
Normal file
74
frontend/src/components/StatCard.jsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { TrendingUp, TrendingDown, ArrowUpRight, ArrowDownRight, Loader2 } from 'lucide-react'
|
||||
|
||||
const StatCard = ({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
changeType,
|
||||
icon: Icon,
|
||||
color,
|
||||
loading = false,
|
||||
trend,
|
||||
trendIcon: TrendIcon
|
||||
}) => {
|
||||
const colorClasses = {
|
||||
blue: 'text-blue-600 bg-blue-100 dark:bg-blue-900/20',
|
||||
green: 'text-green-600 bg-green-100 dark:bg-green-900/20',
|
||||
purple: 'text-purple-600 bg-purple-100 dark:bg-purple-900/20',
|
||||
yellow: 'text-yellow-600 bg-yellow-100 dark:bg-yellow-900/20',
|
||||
}
|
||||
|
||||
const trendColorClasses = {
|
||||
positive: 'text-green-600',
|
||||
negative: 'text-red-600',
|
||||
neutral: 'text-gray-600'
|
||||
}
|
||||
|
||||
const TrendIconComponent = TrendIcon || (changeType === 'positive' ? ArrowUpRight : ArrowDownRight)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8 hover:shadow-2xl transition-all duration-300 group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
{title}
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
<span className="text-2xl text-gray-400 font-bold">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white group-hover:scale-105 transition-transform duration-200">
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-4 rounded-2xl ${colorClasses[color]} group-hover:scale-110 transition-transform duration-200`}>
|
||||
<Icon className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && (change || trend) && (
|
||||
<div className="mt-6 flex items-center">
|
||||
<TrendIconComponent className={`h-5 w-5 ${
|
||||
changeType === 'positive' ? 'text-green-500' :
|
||||
changeType === 'negative' ? 'text-red-500' : 'text-gray-500'
|
||||
}`} />
|
||||
<span className={`ml-2 text-sm font-bold ${
|
||||
changeType === 'positive' ? 'text-green-600' :
|
||||
changeType === 'negative' ? 'text-red-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{trend || change}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
from last period
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatCard
|
||||
|
||||
244
frontend/src/components/UserModal.jsx
Normal file
244
frontend/src/components/UserModal.jsx
Normal file
@ -0,0 +1,244 @@
|
||||
import { useState } from 'react'
|
||||
import { User, Mail, Phone, Building, Crown, Key, UserCheck, X } from 'lucide-react'
|
||||
import Modal from './Modal'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const UserModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user = null,
|
||||
onSave,
|
||||
mode = 'create' // 'create' or 'edit'
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: user?.first_name || '',
|
||||
last_name: user?.last_name || '',
|
||||
email: user?.email || '',
|
||||
phone_number: user?.phone_number || '',
|
||||
company_name: user?.company_name || '',
|
||||
subscription_type: user?.subscription_type || 'free',
|
||||
is_api_enabled: user?.is_api_enabled || false,
|
||||
is_verified: user?.is_verified || false,
|
||||
})
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await onSave(formData)
|
||||
toast.success(`User ${mode === 'create' ? 'created' : 'updated'} successfully`)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${mode === 'create' ? 'create' : 'update'} user`)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}))
|
||||
}
|
||||
|
||||
const subscriptionTypes = [
|
||||
{ value: 'free', label: 'Free', icon: User, color: 'gray' },
|
||||
{ value: 'paid', label: 'Paid', icon: Crown, color: 'blue' },
|
||||
{ value: 'premium', label: 'Premium', icon: Crown, color: 'purple' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={mode === 'create' ? 'Add New User' : 'Edit User'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
||||
Basic Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
||||
Contact Information
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Email Address</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="input pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Phone Number</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
name="phone_number"
|
||||
value={formData.phone_number}
|
||||
onChange={handleChange}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Company Name</label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
name="company_name"
|
||||
value={formData.company_name}
|
||||
onChange={handleChange}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription & Access */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 dark:text-white mb-4">
|
||||
Subscription & Access
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Subscription Type</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{subscriptionTypes.map((type) => {
|
||||
const Icon = type.icon
|
||||
return (
|
||||
<label
|
||||
key={type.value}
|
||||
className={`relative flex items-center p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
formData.subscription_type === type.value
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="subscription_type"
|
||||
value={type.value}
|
||||
checked={formData.subscription_type === type.value}
|
||||
onChange={handleChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Icon className={`h-5 w-5 mr-2 text-${type.color}-600`} />
|
||||
<span className="text-sm font-medium">{type.label}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_api_enabled"
|
||||
checked={formData.is_api_enabled}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-900 dark:text-white">
|
||||
<Key className="inline h-4 w-4 mr-1" />
|
||||
API Access Enabled
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_verified"
|
||||
checked={formData.is_verified}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-900 dark:text-white">
|
||||
<UserCheck className="inline h-4 w-4 mr-1" />
|
||||
Email Verified
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn btn-secondary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{mode === 'create' ? 'Creating...' : 'Updating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
{mode === 'create' ? 'Create User' : 'Update User'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserModal
|
||||
153
frontend/src/contexts/AuthContext.jsx
Normal file
153
frontend/src/contexts/AuthContext.jsx
Normal file
@ -0,0 +1,153 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { setCredentials, clearCredentials } from '../store/slices/authSlice'
|
||||
import { api } from '../services/api'
|
||||
|
||||
const AuthContext = createContext()
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
|
||||
if (token) {
|
||||
// First, immediately set the token in Redux to prevent redirect
|
||||
dispatch(setCredentials({
|
||||
user: { email: 'Loading...' }, // Temporary user
|
||||
token
|
||||
}))
|
||||
|
||||
try {
|
||||
// Verify token with backend
|
||||
const response = await api.get('/auth/user/')
|
||||
if (response.data) {
|
||||
dispatch(setCredentials({
|
||||
user: response.data,
|
||||
token
|
||||
}))
|
||||
} else {
|
||||
// Invalid response, clear tokens
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
dispatch(clearCredentials())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Token verification failed:', error)
|
||||
|
||||
// If it's a network error or server error, keep the token temporarily
|
||||
if (error.code === 'ERR_NETWORK' || error.response?.status >= 500) {
|
||||
console.warn('Backend unavailable, using cached token')
|
||||
dispatch(setCredentials({
|
||||
user: {
|
||||
email: 'admin@dubai-analytics.com',
|
||||
first_name: 'Admin',
|
||||
last_name: 'User'
|
||||
},
|
||||
token
|
||||
}))
|
||||
} else if (error.response?.status === 401) {
|
||||
// Token is invalid, try to refresh
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const refreshResponse = await api.post('/auth/refresh/', { refresh: refreshToken })
|
||||
const newToken = refreshResponse.data.access
|
||||
localStorage.setItem('accessToken', newToken)
|
||||
|
||||
// Get user data with new token
|
||||
const userResponse = await api.get('/auth/user/')
|
||||
dispatch(setCredentials({
|
||||
user: userResponse.data,
|
||||
token: newToken
|
||||
}))
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear everything
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
dispatch(clearCredentials())
|
||||
}
|
||||
} else {
|
||||
// No refresh token, clear everything
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
dispatch(clearCredentials())
|
||||
}
|
||||
} else {
|
||||
// Other errors, clear tokens
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
dispatch(clearCredentials())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error)
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
dispatch(clearCredentials())
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, [dispatch])
|
||||
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const response = await api.post('/auth/login/', { email, password })
|
||||
const { user, tokens } = response.data
|
||||
|
||||
localStorage.setItem('accessToken', tokens.access)
|
||||
localStorage.setItem('refreshToken', tokens.refresh)
|
||||
|
||||
dispatch(setCredentials({ user, token: tokens.access }))
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || 'Login failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
dispatch(clearCredentials())
|
||||
}
|
||||
|
||||
const value = {
|
||||
isLoading,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
47
frontend/src/contexts/ThemeContext.jsx
Normal file
47
frontend/src/contexts/ThemeContext.jsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
const ThemeContext = createContext()
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
return savedTheme || 'dark'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
toggleTheme,
|
||||
isDark: theme === 'dark'
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
32
frontend/src/hooks/useAuthState.js
Normal file
32
frontend/src/hooks/useAuthState.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSelector, useDispatch } from 'react-redux'
|
||||
import { setCredentials, clearCredentials } from '../store/slices/authSlice'
|
||||
|
||||
export const useAuthState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const isAuthenticated = useSelector(state => state.auth.isAuthenticated)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = () => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
|
||||
if (token) {
|
||||
// Immediately set authentication state to prevent redirect
|
||||
dispatch(setCredentials({
|
||||
user: { email: 'Loading...' },
|
||||
token
|
||||
}))
|
||||
}
|
||||
|
||||
setIsInitialized(true)
|
||||
}
|
||||
|
||||
initializeAuth()
|
||||
}, [dispatch])
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isInitialized
|
||||
}
|
||||
}
|
||||
173
frontend/src/index.css
Normal file
173
frontend/src/index.css
Normal file
@ -0,0 +1,173 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--toast-bg: #ffffff;
|
||||
--toast-color: #1f2937;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--toast-bg: #1f2937;
|
||||
--toast-color: #ffffff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-gray-900 dark:bg-gray-900 dark:text-white;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-white dark:ring-offset-gray-900;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 focus:ring-secondary-500 dark:bg-secondary-800 dark:text-secondary-100 dark:hover:bg-secondary-700;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply h-8 px-3 text-xs;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
@apply h-10 px-4 py-2;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply h-12 px-8 text-base;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-lg border border-gray-200 bg-white text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-white;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:ring-offset-gray-900 dark:placeholder-gray-400;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
@apply flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-white;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
@apply bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-white;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply w-full caption-bottom text-sm;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply [&_tr]:border-b;
|
||||
}
|
||||
|
||||
.table-body {
|
||||
@apply [&_tr:last-child]:border-0;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply border-b border-gray-200 transition-colors hover:bg-gray-50 data-[state=selected]:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800 dark:data-[state=selected]:bg-gray-700;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
@apply h-12 px-4 text-left align-middle font-medium text-gray-500 dark:text-gray-400 [&:has([role=checkbox])]:pr-0;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
@apply p-4 align-middle [&:has([role=checkbox])]:pr-0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Slide in animation */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
46
frontend/src/main.jsx
Normal file
46
frontend/src/main.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import App from './App.jsx'
|
||||
import { store } from './store/store.js'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: 'var(--toast-bg)',
|
||||
color: 'var(--toast-color)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
384
frontend/src/pages/Analytics.jsx
Normal file
384
frontend/src/pages/Analytics.jsx
Normal file
@ -0,0 +1,384 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
MapPin,
|
||||
Building2,
|
||||
Filter,
|
||||
Download,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { analyticsAPI } from '../services/api'
|
||||
import Chart from '../components/Chart'
|
||||
import StatCard from '../components/StatCard'
|
||||
|
||||
const Analytics = () => {
|
||||
const [filters, setFilters] = useState({
|
||||
area: '',
|
||||
property_type: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
})
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery({
|
||||
queryKey: ['transactionSummary', filters],
|
||||
queryFn: () => analyticsAPI.getTransactionSummary(filters),
|
||||
})
|
||||
|
||||
const { data: areaStats, isLoading: areaStatsLoading } = useQuery({
|
||||
queryKey: ['areaStats', filters],
|
||||
queryFn: () => analyticsAPI.getAreaStats(filters),
|
||||
})
|
||||
|
||||
const { data: propertyTypeStats, isLoading: propertyTypeLoading } = useQuery({
|
||||
queryKey: ['propertyTypeStats', filters],
|
||||
queryFn: () => analyticsAPI.getPropertyTypeStats(filters),
|
||||
})
|
||||
|
||||
const { data: timeSeriesData, isLoading: timeSeriesLoading } = useQuery({
|
||||
queryKey: ['timeSeriesData', filters],
|
||||
queryFn: () => analyticsAPI.getTimeSeriesData({
|
||||
...filters,
|
||||
group_by: 'month'
|
||||
}),
|
||||
})
|
||||
|
||||
const { data: marketAnalysis, isLoading: marketAnalysisLoading } = useQuery({
|
||||
queryKey: ['marketAnalysis', filters],
|
||||
queryFn: () => analyticsAPI.getMarketAnalysis(filters),
|
||||
enabled: !!filters.area && !!filters.property_type,
|
||||
})
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
// Filters are automatically applied via React Query
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilters({
|
||||
area: '',
|
||||
property_type: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', name: 'Overview', icon: BarChart3 },
|
||||
{ id: 'areas', name: 'Areas', icon: MapPin },
|
||||
{ id: 'properties', name: 'Properties', icon: Building2 },
|
||||
{ id: 'trends', name: 'Trends', icon: TrendingUp },
|
||||
]
|
||||
|
||||
const summaryStats = summary ? [
|
||||
{
|
||||
title: 'Total Transactions',
|
||||
value: summary.total_transactions?.toLocaleString() || '0',
|
||||
change: '+12%',
|
||||
changeType: 'positive',
|
||||
icon: BarChart3,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
title: 'Total Value',
|
||||
value: `AED ${summary.total_value?.toLocaleString() || '0'}`,
|
||||
change: '+8%',
|
||||
changeType: 'positive',
|
||||
icon: TrendingUp,
|
||||
color: 'green'
|
||||
},
|
||||
{
|
||||
title: 'Average Price',
|
||||
value: `AED ${summary.average_price?.toLocaleString() || '0'}`,
|
||||
change: '+5%',
|
||||
changeType: 'positive',
|
||||
icon: Building2,
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
title: 'Price per Sqft',
|
||||
value: `AED ${summary.average_price_per_sqft?.toFixed(2) || '0'}`,
|
||||
change: '+3%',
|
||||
changeType: 'positive',
|
||||
icon: MapPin,
|
||||
color: 'yellow'
|
||||
}
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Analytics
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Real estate market insights and trends
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="btn btn-secondary">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</button>
|
||||
<button className="btn btn-primary">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="label">Area</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter area..."
|
||||
value={filters.area}
|
||||
onChange={(e) => handleFilterChange('area', e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Property Type</label>
|
||||
<select
|
||||
value={filters.property_type}
|
||||
onChange={(e) => handleFilterChange('property_type', e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="Unit">Unit</option>
|
||||
<option value="Villa">Villa</option>
|
||||
<option value="Land">Land</option>
|
||||
<option value="Building">Building</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.start_date}
|
||||
onChange={(e) => handleFilterChange('start_date', e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.end_date}
|
||||
onChange={(e) => handleFilterChange('end_date', e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<button onClick={handleApplyFilters} className="btn btn-primary">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Apply Filters
|
||||
</button>
|
||||
<button onClick={handleResetFilters} className="btn btn-secondary">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{summaryStats.map((stat, index) => (
|
||||
<StatCard key={index} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Transaction Volume Over Time
|
||||
</h3>
|
||||
<Chart
|
||||
data={timeSeriesData}
|
||||
type="line"
|
||||
height={300}
|
||||
/>
|
||||
</div>
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Top Areas by Transactions
|
||||
</h3>
|
||||
<Chart
|
||||
data={areaStats}
|
||||
type="bar"
|
||||
height={300}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'areas' && (
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Area Performance
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead className="table-header">
|
||||
<tr className="table-row">
|
||||
<th className="table-head">Area</th>
|
||||
<th className="table-head">Transactions</th>
|
||||
<th className="table-head">Average Price</th>
|
||||
<th className="table-head">Price per Sqft</th>
|
||||
<th className="table-head">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="table-body">
|
||||
{areaStats?.map((area, index) => (
|
||||
<tr key={index} className="table-row">
|
||||
<td className="table-cell font-medium">{area.area}</td>
|
||||
<td className="table-cell">{area.transaction_count}</td>
|
||||
<td className="table-cell">AED {area.average_price?.toLocaleString()}</td>
|
||||
<td className="table-cell">AED {area.average_price_per_sqft?.toFixed(2)}</td>
|
||||
<td className="table-cell">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
area.price_trend === 'Rising'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: area.price_trend === 'Falling'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
}`}>
|
||||
{area.price_trend}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'properties' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Property Type Distribution
|
||||
</h3>
|
||||
<Chart
|
||||
data={propertyTypeStats}
|
||||
type="pie"
|
||||
height={300}
|
||||
/>
|
||||
</div>
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Property Type Performance
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{propertyTypeStats?.map((type, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{type.property_type}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{type.transaction_count} transactions
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
AED {type.average_price?.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{type.market_share?.toFixed(1)}% market share
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'trends' && (
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Market Trends Analysis
|
||||
</h3>
|
||||
{marketAnalysis ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-900/20 rounded-lg">
|
||||
<h4 className="font-medium text-slate-900 dark:text-slate-100">Key Metrics</h4>
|
||||
<div className="mt-2 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||
<p>Total Transactions: {marketAnalysis.key_metrics?.total_transactions}</p>
|
||||
<p>Average Price: AED {marketAnalysis.key_metrics?.average_price?.toLocaleString()}</p>
|
||||
<p>Price Volatility: {marketAnalysis.key_metrics?.price_volatility?.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<h4 className="font-medium text-green-900 dark:text-green-100">Trends</h4>
|
||||
<div className="mt-2 space-y-1 text-sm text-green-700 dark:text-green-300">
|
||||
{marketAnalysis.trends?.map((trend, index) => (
|
||||
<p key={index}>• {trend}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<h4 className="font-medium text-purple-900 dark:text-purple-100">Recommendations</h4>
|
||||
<div className="mt-2 space-y-1 text-sm text-purple-700 dark:text-purple-300">
|
||||
{marketAnalysis.recommendations?.map((rec, index) => (
|
||||
<p key={index}>• {rec}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Select an area and property type to view market analysis
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Analytics
|
||||
|
||||
675
frontend/src/pages/Dashboard.jsx
Normal file
675
frontend/src/pages/Dashboard.jsx
Normal file
@ -0,0 +1,675 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Users,
|
||||
TrendingUp,
|
||||
FileText,
|
||||
Activity,
|
||||
Building2,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Eye,
|
||||
Calendar,
|
||||
Filter,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
MapPin,
|
||||
Home,
|
||||
Briefcase,
|
||||
TrendingDown,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock
|
||||
} from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { analyticsAPI, monitoringAPI, reportsAPI } from '../services/api'
|
||||
import StatCard from '../components/StatCard'
|
||||
import Chart from '../components/Chart'
|
||||
import RecentActivity from '../components/RecentActivity'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const Dashboard = () => {
|
||||
const [timeRange, setTimeRange] = useState('90d')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// Fetch dashboard data
|
||||
const { data: metrics, isLoading: metricsLoading, refetch: refetchMetrics } = useQuery({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: monitoringAPI.getMetrics,
|
||||
placeholderData: {
|
||||
total_users: 2,
|
||||
active_sessions: 1,
|
||||
api_calls_today: 0,
|
||||
reports_generated: 0,
|
||||
system_health: 'healthy'
|
||||
},
|
||||
})
|
||||
|
||||
const { data: transactionSummary, isLoading: summaryLoading, refetch: refetchSummary } = useQuery({
|
||||
queryKey: ['transactionSummary', timeRange],
|
||||
queryFn: () => analyticsAPI.getTransactionSummary({
|
||||
start_date: getDateRange(timeRange).start,
|
||||
end_date: getDateRange(timeRange).end
|
||||
}),
|
||||
placeholderData: {
|
||||
total_transactions: 1,
|
||||
total_value: 0,
|
||||
average_value: 0,
|
||||
growth_rate: 0
|
||||
},
|
||||
})
|
||||
|
||||
const { data: timeSeriesData, isLoading: timeSeriesLoading, refetch: refetchTimeSeries } = useQuery({
|
||||
queryKey: ['timeSeriesData', timeRange],
|
||||
queryFn: () => analyticsAPI.getTimeSeriesData({
|
||||
start_date: getDateRange(timeRange).start,
|
||||
end_date: getDateRange(timeRange).end,
|
||||
group_by: 'day'
|
||||
}),
|
||||
placeholderData: [],
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
})
|
||||
|
||||
const { data: areaStats, isLoading: areaStatsLoading, refetch: refetchAreaStats } = useQuery({
|
||||
queryKey: ['areaStats', timeRange],
|
||||
queryFn: () => analyticsAPI.getAreaStats({
|
||||
start_date: getDateRange(timeRange).start,
|
||||
end_date: getDateRange(timeRange).end,
|
||||
limit: 5
|
||||
}),
|
||||
placeholderData: [],
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
})
|
||||
|
||||
// Process chart data
|
||||
const processedTimeSeriesData = timeSeriesData && Array.isArray(timeSeriesData) && timeSeriesData.length > 0 ? {
|
||||
labels: timeSeriesData.map(item => new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Transactions',
|
||||
data: timeSeriesData.map(item => item.transactions),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Value (AED)',
|
||||
data: timeSeriesData.map(item => item.value / 1000000), // Convert to millions
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
} : null
|
||||
|
||||
const processedAreaStatsData = areaStats && Array.isArray(areaStats) && areaStats.length > 0 ? {
|
||||
labels: areaStats.map(item => item.area_en),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Transaction Count',
|
||||
data: areaStats.map(item => item.transaction_count),
|
||||
backgroundColor: [
|
||||
'rgba(59, 130, 246, 0.8)',
|
||||
'rgba(16, 185, 129, 0.8)',
|
||||
'rgba(245, 158, 11, 0.8)',
|
||||
'rgba(239, 68, 68, 0.8)',
|
||||
'rgba(16, 185, 129, 0.8)',
|
||||
],
|
||||
borderColor: [
|
||||
'rgb(59, 130, 246)',
|
||||
'rgb(16, 185, 129)',
|
||||
'rgb(245, 158, 11)',
|
||||
'rgb(239, 68, 68)',
|
||||
'rgb(16, 185, 129)',
|
||||
],
|
||||
borderWidth: 2,
|
||||
}
|
||||
]
|
||||
} : null
|
||||
|
||||
const { data: reports, isLoading: reportsLoading, refetch: refetchReports } = useQuery({
|
||||
queryKey: ['reports'],
|
||||
queryFn: reportsAPI.getReports,
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
function getDateRange(range) {
|
||||
const now = new Date()
|
||||
const start = new Date()
|
||||
|
||||
switch (range) {
|
||||
case '7d':
|
||||
start.setDate(now.getDate() - 7)
|
||||
break
|
||||
case '30d':
|
||||
start.setDate(now.getDate() - 30)
|
||||
break
|
||||
case '90d':
|
||||
start.setDate(now.getDate() - 90)
|
||||
break
|
||||
case '1y':
|
||||
start.setDate(now.getDate() - 365)
|
||||
break
|
||||
case 'all':
|
||||
// Set to a very early date for "all time"
|
||||
start.setFullYear(2020, 0, 1)
|
||||
break
|
||||
default:
|
||||
start.setDate(now.getDate() - 90)
|
||||
}
|
||||
|
||||
return {
|
||||
start: start.toISOString().split('T')[0],
|
||||
end: now.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
refetchMetrics(),
|
||||
refetchSummary(),
|
||||
refetchTimeSeries(),
|
||||
refetchAreaStats(),
|
||||
refetchReports()
|
||||
])
|
||||
toast.success('Dashboard refreshed successfully')
|
||||
} catch (error) {
|
||||
toast.error('Failed to refresh dashboard')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateReport = async (type) => {
|
||||
try {
|
||||
const reportData = {
|
||||
area: '',
|
||||
property_type: '',
|
||||
start_date: getDateRange(timeRange).start,
|
||||
end_date: getDateRange(timeRange).end,
|
||||
format: 'pdf'
|
||||
}
|
||||
|
||||
let response
|
||||
switch (type) {
|
||||
case 'transaction_summary':
|
||||
response = await reportsAPI.generateTransactionSummary(reportData)
|
||||
break
|
||||
case 'area_analysis':
|
||||
response = await reportsAPI.generateAreaAnalysis(reportData)
|
||||
break
|
||||
default:
|
||||
response = await reportsAPI.generateForecast(reportData)
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
toast.success('Report generation started')
|
||||
refetchReports()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate report')
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic stats based on actual data
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Brokers',
|
||||
value: '36,457',
|
||||
change: '+2.3%',
|
||||
changeType: 'positive',
|
||||
icon: Briefcase,
|
||||
color: 'blue',
|
||||
loading: metricsLoading,
|
||||
trend: '+2.3%',
|
||||
trendIcon: ArrowUpRight
|
||||
},
|
||||
{
|
||||
title: 'Active Projects',
|
||||
value: '10',
|
||||
change: '+0%',
|
||||
changeType: 'neutral',
|
||||
icon: Building2,
|
||||
color: 'green',
|
||||
loading: metricsLoading,
|
||||
trend: '0%',
|
||||
trendIcon: TrendingUp
|
||||
},
|
||||
{
|
||||
title: 'Total Users',
|
||||
value: metrics?.total_users?.toLocaleString() || '2',
|
||||
change: '+100%',
|
||||
changeType: 'positive',
|
||||
icon: Users,
|
||||
color: 'purple',
|
||||
loading: metricsLoading,
|
||||
trend: '+100%',
|
||||
trendIcon: ArrowUpRight
|
||||
},
|
||||
{
|
||||
title: 'System Health',
|
||||
value: metrics?.system_health === 'healthy' ? '100%' : '95%',
|
||||
change: '+5%',
|
||||
changeType: 'positive',
|
||||
icon: Activity,
|
||||
color: 'yellow',
|
||||
loading: metricsLoading,
|
||||
trend: '+5%',
|
||||
trendIcon: ArrowUpRight
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-slate-800 via-slate-700 to-slate-600 rounded-2xl p-8 text-white shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 tracking-tight">
|
||||
Dubai Analytics Dashboard
|
||||
</h1>
|
||||
<p className="text-slate-200 text-base font-normal">
|
||||
Real-time insights into Dubai's real estate market
|
||||
</p>
|
||||
<div className="flex items-center mt-4 space-x-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-300" />
|
||||
<span className="text-sm font-medium">System Online</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5 text-slate-300" />
|
||||
<span className="text-sm font-medium">Last updated: {new Date().toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 bg-white/10 backdrop-blur-sm rounded-lg px-4 py-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="bg-transparent text-white border-none outline-none"
|
||||
>
|
||||
<option value="7d" className="text-gray-900">Last 7 days</option>
|
||||
<option value="30d" className="text-gray-900">Last 30 days</option>
|
||||
<option value="90d" className="text-gray-900">Last 90 days</option>
|
||||
<option value="1y" className="text-gray-900">Last year</option>
|
||||
<option value="all" className="text-gray-900">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-all duration-200 rounded-lg px-4 py-2 flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button className="bg-white text-slate-700 hover:bg-slate-50 transition-all duration-200 rounded-lg px-4 py-2 font-medium flex items-center space-x-2 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Quick Actions</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat, index) => (
|
||||
<StatCard key={index} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Transaction Volume Chart */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
Market Activity
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{timeRange === '7d' ? 'Last 7 days' :
|
||||
timeRange === '30d' ? 'Last 30 days' :
|
||||
timeRange === '90d' ? 'Last 90 days' :
|
||||
timeRange === '1y' ? 'Last year' :
|
||||
timeRange === 'all' ? 'All time' : 'Last 90 days'} transaction trends
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => handleGenerateReport('transaction_summary')}
|
||||
className="p-2 text-gray-400 hover:text-slate-600 dark:hover:text-slate-400 transition-colors duration-200"
|
||||
title="Generate Report"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="w-10 h-10 bg-slate-100 dark:bg-slate-900/20 rounded-lg flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-slate-600 dark:text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Chart
|
||||
data={processedTimeSeriesData}
|
||||
type="line"
|
||||
height={350}
|
||||
title="Market Activity"
|
||||
subtitle="No transaction data available for the selected period"
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
weight: '500'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: 'white',
|
||||
bodyColor: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
if (context.datasetIndex === 1) {
|
||||
return `${context.dataset.label}: AED ${context.parsed.y.toFixed(1)}M`
|
||||
}
|
||||
return `${context.dataset.label}: ${context.parsed.y}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toLocaleString()
|
||||
},
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6B7280'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return `AED ${value.toFixed(1)}M`
|
||||
},
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6B7280'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6B7280'
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.4,
|
||||
borderWidth: 3
|
||||
},
|
||||
point: {
|
||||
radius: 6,
|
||||
hoverRadius: 8,
|
||||
borderWidth: 2
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Area Distribution Chart */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
Top Areas
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Property distribution by area
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => handleGenerateReport('area_analysis')}
|
||||
className="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||
title="Generate Report"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
|
||||
<PieChart className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Chart
|
||||
data={processedAreaStatsData}
|
||||
type="bar"
|
||||
height={350}
|
||||
title="Area Distribution"
|
||||
subtitle="No area data available for the selected period"
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
weight: '500'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: 'white',
|
||||
bodyColor: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.dataset.label}: ${context.parsed.y} transactions`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toLocaleString()
|
||||
},
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6B7280'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6B7280',
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
},
|
||||
elements: {
|
||||
bar: {
|
||||
borderRadius: 6,
|
||||
borderSkipped: false
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Recent Activity */}
|
||||
<div className="lg:col-span-2">
|
||||
<RecentActivity />
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & System Status */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-6">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => handleGenerateReport('transaction_summary')}
|
||||
className="w-full bg-gradient-to-r from-slate-600 to-slate-700 hover:from-slate-700 hover:to-slate-800 text-white font-medium py-2 px-3 rounded-lg transition-all duration-200 flex items-center justify-center space-x-2 shadow-lg hover:shadow-xl text-sm"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>Generate Report</span>
|
||||
</button>
|
||||
<button className="w-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium py-2 px-3 rounded-lg transition-all duration-200 flex items-center justify-center space-x-2 text-sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>View Analytics</span>
|
||||
</button>
|
||||
<button className="w-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium py-2 px-3 rounded-lg transition-all duration-200 flex items-center justify-center space-x-2 text-sm">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>Manage Users</span>
|
||||
</button>
|
||||
<button className="w-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium py-2 px-3 rounded-lg transition-all duration-200 flex items-center justify-center space-x-2 text-sm">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span>System Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
System Status
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">API Status</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-green-600 dark:text-green-400">Online</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">Database</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-green-600 dark:text-green-400">Connected</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900/20 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-slate-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">Last Update</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-slate-600 dark:text-slate-400">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">Data Records</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-purple-600 dark:text-purple-400">36,457+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
|
||||
168
frontend/src/pages/Login.jsx
Normal file
168
frontend/src/pages/Login.jsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { Building2, Eye, EyeOff, Loader2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const Login = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { login } = useAuth()
|
||||
const { isDark } = useTheme()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await login(formData.email, formData.password)
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Login successful!')
|
||||
navigate('/dashboard')
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('An error occurred during login')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<Building2 className="h-12 w-12 text-primary-600" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Dubai Analytics
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Sign in to your admin account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="input mt-1"
|
||||
placeholder="admin@dubai-analytics.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="input pr-10"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn btn-primary btn-lg w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Demo credentials */}
|
||||
<div className="mt-6 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Demo Credentials
|
||||
</h3>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<p><strong>Email:</strong> admin@dubai-analytics.com</p>
|
||||
<p><strong>Password:</strong> admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
|
||||
812
frontend/src/pages/Payments.jsx
Normal file
812
frontend/src/pages/Payments.jsx
Normal file
@ -0,0 +1,812 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Calendar,
|
||||
Filter,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { analyticsAPI, usersAPI } from '../services/api'
|
||||
import StatCard from '../components/StatCard'
|
||||
import Chart from '../components/Chart'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const Payments = () => {
|
||||
const [timeRange, setTimeRange] = useState('90d')
|
||||
const [selectedUser, setSelectedUser] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// Fetch users for filtering
|
||||
const { data: usersData, isLoading: usersLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => usersAPI.getUsers(),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
// Extract users array from the response
|
||||
const users = Array.isArray(usersData) ? usersData : (usersData?.results || usersData?.data || [])
|
||||
|
||||
// Mock users data for demonstration when backend is not available
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
subscription_type: 'premium',
|
||||
is_api_enabled: true,
|
||||
is_verified: true,
|
||||
date_joined: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
email: 'user@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
subscription_type: 'free',
|
||||
is_api_enabled: false,
|
||||
is_verified: true,
|
||||
date_joined: '2024-02-01T14:20:00Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
email: 'jane@example.com',
|
||||
first_name: 'Jane',
|
||||
last_name: 'Smith',
|
||||
subscription_type: 'paid',
|
||||
is_api_enabled: true,
|
||||
is_verified: true,
|
||||
date_joined: '2024-02-15T09:15:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
// Use mock data if no real data is available
|
||||
const displayUsers = users && users.length > 0 ? users : mockUsers
|
||||
|
||||
// Mock transaction data for demonstration
|
||||
const mockTransactions = {
|
||||
results: timeRange === 'all' ? [
|
||||
{
|
||||
id: 'TXN-001',
|
||||
user: { first_name: 'Admin', last_name: 'User', email: 'admin@example.com' },
|
||||
transaction_value: 2500000,
|
||||
property_type: 'Villa',
|
||||
area_en: 'Dubai Marina',
|
||||
instance_date: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 'TXN-002',
|
||||
user: { first_name: 'John', last_name: 'Doe', email: 'user@example.com' },
|
||||
transaction_value: 1800000,
|
||||
property_type: 'Unit',
|
||||
area_en: 'Downtown Dubai',
|
||||
instance_date: '2024-01-14T14:20:00Z'
|
||||
},
|
||||
{
|
||||
id: 'TXN-003',
|
||||
user: { first_name: 'Jane', last_name: 'Smith', email: 'jane@example.com' },
|
||||
transaction_value: 3200000,
|
||||
property_type: 'Land',
|
||||
area_en: 'Business Bay',
|
||||
instance_date: '2024-01-13T09:15:00Z'
|
||||
},
|
||||
{
|
||||
id: 'TXN-004',
|
||||
user: { first_name: 'Mike', last_name: 'Johnson', email: 'mike@example.com' },
|
||||
transaction_value: 1500000,
|
||||
property_type: 'Apartment',
|
||||
area_en: 'Jumeirah',
|
||||
instance_date: '2023-12-20T16:45:00Z'
|
||||
},
|
||||
{
|
||||
id: 'TXN-005',
|
||||
user: { first_name: 'Sarah', last_name: 'Wilson', email: 'sarah@example.com' },
|
||||
transaction_value: 2800000,
|
||||
property_type: 'Townhouse',
|
||||
area_en: 'Arabian Ranches',
|
||||
instance_date: '2023-11-10T11:30:00Z'
|
||||
}
|
||||
] : [
|
||||
{
|
||||
id: 'TXN-001',
|
||||
user: { first_name: 'Admin', last_name: 'User', email: 'admin@example.com' },
|
||||
transaction_value: 2500000,
|
||||
property_type: 'Villa',
|
||||
area_en: 'Dubai Marina',
|
||||
instance_date: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 'TXN-002',
|
||||
user: { first_name: 'John', last_name: 'Doe', email: 'user@example.com' },
|
||||
transaction_value: 1800000,
|
||||
property_type: 'Unit',
|
||||
area_en: 'Downtown Dubai',
|
||||
instance_date: '2024-01-14T14:20:00Z'
|
||||
},
|
||||
{
|
||||
id: 'TXN-003',
|
||||
user: { first_name: 'Jane', last_name: 'Smith', email: 'jane@example.com' },
|
||||
transaction_value: 3200000,
|
||||
property_type: 'Land',
|
||||
area_en: 'Business Bay',
|
||||
instance_date: '2024-01-13T09:15:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Fetch payment/transaction data
|
||||
const { data: paymentStatsRaw, isLoading: paymentStatsLoading, refetch: refetchPaymentStats } = useQuery({
|
||||
queryKey: ['paymentStats', timeRange, selectedUser],
|
||||
queryFn: () => {
|
||||
const dateRange = getDateRange(timeRange)
|
||||
return analyticsAPI.getTransactionSummary({
|
||||
start_date: dateRange.start,
|
||||
end_date: dateRange.end,
|
||||
user_id: selectedUser || undefined
|
||||
})
|
||||
},
|
||||
placeholderData: {
|
||||
total_transactions: 0,
|
||||
total_value: 0,
|
||||
average_value: 0,
|
||||
growth_rate: 0
|
||||
},
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
})
|
||||
|
||||
// Mock payment stats data for demonstration
|
||||
const mockPaymentStats = {
|
||||
total_transactions: timeRange === 'all' ? 156 : 32,
|
||||
total_value: timeRange === 'all' ? 87500000 : 18750000,
|
||||
average_value: timeRange === 'all' ? 560897 : 585937,
|
||||
growth_rate: timeRange === 'all' ? 18.2 : 12.5
|
||||
}
|
||||
|
||||
// Use mock data if no real payment stats are available
|
||||
const paymentStats = paymentStatsRaw && paymentStatsRaw.total_transactions > 0
|
||||
? paymentStatsRaw
|
||||
: mockPaymentStats
|
||||
|
||||
const { data: userTransactionsData, isLoading: transactionsLoading, refetch: refetchTransactions } = useQuery({
|
||||
queryKey: ['userTransactions', timeRange, selectedUser],
|
||||
queryFn: () => analyticsAPI.getTransactions({
|
||||
start_date: getDateRange(timeRange).start,
|
||||
end_date: getDateRange(timeRange).end,
|
||||
user_id: selectedUser || undefined,
|
||||
page_size: 50
|
||||
}),
|
||||
placeholderData: { results: [] },
|
||||
})
|
||||
|
||||
// Use mock data if no real transaction data is available
|
||||
const userTransactions = userTransactionsData && userTransactionsData.results && userTransactionsData.results.length > 0
|
||||
? userTransactionsData
|
||||
: mockTransactions
|
||||
|
||||
const { data: timeSeriesDataRaw, isLoading: timeSeriesLoading, refetch: refetchTimeSeries } = useQuery({
|
||||
queryKey: ['paymentTimeSeries', timeRange, selectedUser],
|
||||
queryFn: () => analyticsAPI.getTimeSeriesData({
|
||||
start_date: getDateRange(timeRange).start,
|
||||
end_date: getDateRange(timeRange).end,
|
||||
group_by: 'day',
|
||||
user_id: selectedUser || undefined
|
||||
}),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
// Mock time series data for demonstration
|
||||
const mockTimeSeriesData = timeRange === 'all' ? [
|
||||
{ date: '2023-01-01', value: 1200000, count: 2 },
|
||||
{ date: '2023-04-01', value: 1800000, count: 3 },
|
||||
{ date: '2023-07-01', value: 2200000, count: 4 },
|
||||
{ date: '2023-10-01', value: 1900000, count: 3 },
|
||||
{ date: '2024-01-01', value: 2200000, count: 4 },
|
||||
{ date: '2024-01-08', value: 1800000, count: 3 },
|
||||
{ date: '2024-01-15', value: 2500000, count: 5 },
|
||||
{ date: '2024-01-22', value: 3200000, count: 7 },
|
||||
{ date: '2024-01-29', value: 2100000, count: 4 },
|
||||
{ date: '2024-02-05', value: 2900000, count: 6 },
|
||||
{ date: '2024-02-12', value: 1500000, count: 2 },
|
||||
{ date: '2024-02-19', value: 2800000, count: 5 },
|
||||
{ date: '2024-02-26', value: 3400000, count: 8 },
|
||||
{ date: '2024-03-05', value: 2600000, count: 5 },
|
||||
{ date: '2024-03-12', value: 3100000, count: 6 },
|
||||
{ date: '2024-03-19', value: 1900000, count: 3 },
|
||||
{ date: '2024-03-26', value: 2700000, count: 5 }
|
||||
] : [
|
||||
{ date: '2024-01-01', value: 2200000, count: 4 },
|
||||
{ date: '2024-01-08', value: 1800000, count: 3 },
|
||||
{ date: '2024-01-15', value: 2500000, count: 5 },
|
||||
{ date: '2024-01-22', value: 3200000, count: 7 },
|
||||
{ date: '2024-01-29', value: 2100000, count: 4 },
|
||||
{ date: '2024-02-05', value: 2900000, count: 6 },
|
||||
{ date: '2024-02-12', value: 1500000, count: 2 },
|
||||
{ date: '2024-02-19', value: 2800000, count: 5 },
|
||||
{ date: '2024-02-26', value: 3400000, count: 8 },
|
||||
{ date: '2024-03-05', value: 2600000, count: 5 },
|
||||
{ date: '2024-03-12', value: 3100000, count: 6 },
|
||||
{ date: '2024-03-19', value: 1900000, count: 3 },
|
||||
{ date: '2024-03-26', value: 2700000, count: 5 }
|
||||
]
|
||||
|
||||
// Use mock data if no real time series data is available
|
||||
const timeSeriesData = timeSeriesDataRaw && Array.isArray(timeSeriesDataRaw) && timeSeriesDataRaw.length > 0
|
||||
? timeSeriesDataRaw
|
||||
: mockTimeSeriesData
|
||||
|
||||
function getDateRange(range) {
|
||||
const now = new Date()
|
||||
const start = new Date()
|
||||
|
||||
switch (range) {
|
||||
case '7d':
|
||||
start.setDate(now.getDate() - 7)
|
||||
break
|
||||
case '30d':
|
||||
start.setDate(now.getDate() - 30)
|
||||
break
|
||||
case '90d':
|
||||
start.setDate(now.getDate() - 90)
|
||||
break
|
||||
case '1y':
|
||||
start.setDate(now.getDate() - 365)
|
||||
break
|
||||
case 'all':
|
||||
// Set to a very early date for "all time"
|
||||
start.setFullYear(2020, 0, 1)
|
||||
break
|
||||
default:
|
||||
start.setDate(now.getDate() - 90)
|
||||
}
|
||||
|
||||
return {
|
||||
start: start.toISOString().split('T')[0],
|
||||
end: now.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
refetchPaymentStats(),
|
||||
refetchTransactions(),
|
||||
refetchTimeSeries()
|
||||
])
|
||||
toast.success('Payment data refreshed successfully')
|
||||
} catch (error) {
|
||||
toast.error('Failed to refresh payment data')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Process chart data
|
||||
const processedTimeSeriesData = timeSeriesData && Array.isArray(timeSeriesData) && timeSeriesData.length > 0 ? {
|
||||
labels: timeSeriesData.map(item => new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Transaction Value (AED)',
|
||||
data: timeSeriesData.map(item => item.value / 1000000), // Convert to millions
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Transaction Count',
|
||||
data: timeSeriesData.map(item => item.count),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
} : null
|
||||
|
||||
// Payment statistics
|
||||
const paymentStatsData = [
|
||||
{
|
||||
title: 'Total Revenue',
|
||||
value: `AED ${paymentStats?.total_value?.toLocaleString() || '0'}`,
|
||||
change: '+12.5%',
|
||||
changeType: 'positive',
|
||||
icon: DollarSign,
|
||||
color: 'green',
|
||||
loading: paymentStatsLoading,
|
||||
trend: '+12.5%',
|
||||
trendIcon: ArrowUpRight
|
||||
},
|
||||
{
|
||||
title: 'Total Transactions',
|
||||
value: paymentStats?.total_transactions?.toLocaleString() || '0',
|
||||
change: '+8.2%',
|
||||
changeType: 'positive',
|
||||
icon: CreditCard,
|
||||
color: 'blue',
|
||||
loading: paymentStatsLoading,
|
||||
trend: '+8.2%',
|
||||
trendIcon: ArrowUpRight
|
||||
},
|
||||
{
|
||||
title: 'Average Transaction',
|
||||
value: `AED ${paymentStats?.average_value?.toLocaleString() || '0'}`,
|
||||
change: '+3.1%',
|
||||
changeType: 'positive',
|
||||
icon: TrendingUp,
|
||||
color: 'purple',
|
||||
loading: paymentStatsLoading,
|
||||
trend: '+3.1%',
|
||||
trendIcon: ArrowUpRight
|
||||
},
|
||||
{
|
||||
title: 'Active Users',
|
||||
value: displayUsers?.length?.toLocaleString() || '0',
|
||||
change: '+15.3%',
|
||||
changeType: 'positive',
|
||||
icon: Users,
|
||||
color: 'yellow',
|
||||
loading: usersLoading,
|
||||
trend: '+15.3%',
|
||||
trendIcon: ArrowUpRight
|
||||
}
|
||||
]
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />
|
||||
default:
|
||||
return <AlertCircle className="h-4 w-4 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400'
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-slate-800 via-slate-700 to-slate-600 rounded-2xl p-8 text-white shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 tracking-tight">
|
||||
Payment Analytics
|
||||
</h1>
|
||||
<p className="text-slate-200 text-base font-normal">
|
||||
User-wise transaction insights and payment tracking
|
||||
</p>
|
||||
<div className="flex items-center mt-4 space-x-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-300" />
|
||||
<span className="text-sm font-medium">Payment System Online</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-5 w-5 text-slate-300" />
|
||||
<span className="text-sm font-medium">Last updated: {new Date().toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 bg-white/10 backdrop-blur-sm rounded-lg px-4 py-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="bg-transparent text-white border-none outline-none"
|
||||
>
|
||||
<option value="7d" className="text-gray-900">Last 7 days</option>
|
||||
<option value="30d" className="text-gray-900">Last 30 days</option>
|
||||
<option value="90d" className="text-gray-900">Last 90 days</option>
|
||||
<option value="1y" className="text-gray-900">Last year</option>
|
||||
<option value="all" className="text-gray-900">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-all duration-200 rounded-lg px-4 py-2 flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Filters</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Advanced Filtering</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Select User
|
||||
</label>
|
||||
<select
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
{displayUsers?.map((user) => (
|
||||
<option key={user.id} value={user.id} className="text-gray-900">
|
||||
{user.first_name} {user.last_name} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Time Range
|
||||
</label>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent"
|
||||
>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
<option value="1y">Last year</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="w-full bg-slate-600 hover:bg-slate-700 text-white font-semibold py-2 px-4 rounded-lg transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span>Apply Filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{paymentStatsData.map((stat, index) => (
|
||||
<StatCard key={index} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Payment Trends Chart */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
Payment Trends
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{timeRange === '7d' ? 'Last 7 days' :
|
||||
timeRange === '30d' ? 'Last 30 days' :
|
||||
timeRange === '90d' ? 'Last 90 days' :
|
||||
timeRange === '1y' ? 'Last year' :
|
||||
timeRange === 'all' ? 'All time' : 'Last 90 days'} transaction trends
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
className="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
||||
title="Download Chart"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
|
||||
<TrendingUp className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Chart
|
||||
data={processedTimeSeriesData}
|
||||
type="line"
|
||||
height={350}
|
||||
title="Payment Trends"
|
||||
subtitle="No payment data available for the selected period"
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
weight: '500'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: 'white',
|
||||
bodyColor: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
if (context.datasetIndex === 0) {
|
||||
return `${context.dataset.label}: AED ${context.parsed.y.toFixed(1)}M`
|
||||
}
|
||||
return `${context.dataset.label}: ${context.parsed.y}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return `AED ${value.toFixed(1)}M`
|
||||
},
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6B7280'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value.toLocaleString()
|
||||
},
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6B7280'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6B7280'
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.4,
|
||||
borderWidth: 3
|
||||
},
|
||||
point: {
|
||||
radius: 6,
|
||||
hoverRadius: 8,
|
||||
borderWidth: 2
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Performance Chart */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
User Performance
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Top users by transaction volume
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
className="p-2 text-gray-400 hover:text-slate-600 dark:hover:text-slate-400 transition-colors duration-200"
|
||||
title="Download Chart"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="w-10 h-10 bg-slate-100 dark:bg-slate-900/20 rounded-lg flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-slate-600 dark:text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{displayUsers?.slice(0, 5).map((user, index) => (
|
||||
<div key={user.id} className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-slate-100 dark:bg-slate-900/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-slate-600 dark:text-slate-400">
|
||||
{user.first_name?.[0] || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
AED {Math.floor(Math.random() * 1000000).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{Math.floor(Math.random() * 50)} transactions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Transactions Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Recent Transactions
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Latest payment activities and transaction details
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
className="p-2 text-gray-400 hover:text-slate-600 dark:hover:text-slate-400 transition-colors duration-200"
|
||||
title="Export Data"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="p-2 text-gray-400 hover:text-slate-600 dark:hover:text-slate-400 transition-colors duration-200"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-900 dark:text-white">Transaction ID</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-900 dark:text-white">User</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-900 dark:text-white">Amount</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-900 dark:text-white">Property</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-900 dark:text-white">Date</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-900 dark:text-white">Status</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-900 dark:text-white">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userTransactions?.results?.length > 0 ? (
|
||||
userTransactions.results.map((transaction, index) => (
|
||||
<tr key={transaction.id || index} className="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="py-3 px-4 text-sm font-mono text-gray-600 dark:text-gray-300">
|
||||
#{transaction.id || `TXN-${index + 1}`}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-6 h-6 bg-slate-100 dark:bg-slate-900/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-slate-600 dark:text-slate-400">
|
||||
{transaction.user?.first_name?.[0] || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{transaction.user?.first_name || 'Unknown'} {transaction.user?.last_name || 'User'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
AED {transaction.transaction_value?.toLocaleString() || '0'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{transaction.property_type || 'N/A'} - {transaction.area_en || 'Unknown Area'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{transaction.instance_date ? new Date(transaction.instance_date).toLocaleDateString() : 'N/A'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor('completed')}`}>
|
||||
{getStatusIcon('completed')}
|
||||
<span className="ml-1">Completed</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="p-1 text-gray-400 hover:text-slate-600 dark:hover:text-slate-400 transition-colors duration-200">
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-200">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="7" className="py-8 px-4 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<CreditCard className="h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||
<p>No transactions found for the selected period</p>
|
||||
<p className="text-sm">Data will appear here once transactions are available</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Payments
|
||||
356
frontend/src/pages/Reports.jsx
Normal file
356
frontend/src/pages/Reports.jsx
Normal file
@ -0,0 +1,356 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Plus,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Filter,
|
||||
Calendar
|
||||
} from 'lucide-react'
|
||||
import { reportsAPI } from '../services/api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const Reports = () => {
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false)
|
||||
const [reportType, setReportType] = useState('transaction_summary')
|
||||
const [reportData, setReportData] = useState({
|
||||
area: '',
|
||||
property_type: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
format: 'pdf'
|
||||
})
|
||||
|
||||
const { data: reports, isLoading, refetch } = useQuery({
|
||||
queryKey: ['reports'],
|
||||
queryFn: reportsAPI.getReports,
|
||||
placeholderData: [], // Provide empty array as placeholder
|
||||
})
|
||||
|
||||
// Mock data for demonstration when backend is not available
|
||||
const mockReports = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Monthly Transaction Summary',
|
||||
report_type: 'transaction_summary',
|
||||
format: 'pdf',
|
||||
status: 'completed',
|
||||
created_at: '2024-01-15T10:30:00Z',
|
||||
file_size: 2048
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Dubai Marina Analysis',
|
||||
report_type: 'area_analysis',
|
||||
format: 'excel',
|
||||
status: 'processing',
|
||||
created_at: '2024-01-14T15:20:00Z',
|
||||
file_size: null
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Q1 2024 Forecast',
|
||||
report_type: 'forecast',
|
||||
format: 'pdf',
|
||||
status: 'failed',
|
||||
created_at: '2024-01-13T09:15:00Z',
|
||||
file_size: null
|
||||
}
|
||||
]
|
||||
|
||||
const displayReports = reports && reports.length > 0 ? reports : mockReports
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
try {
|
||||
let response
|
||||
|
||||
switch (reportType) {
|
||||
case 'transaction_summary':
|
||||
response = await reportsAPI.generateTransactionSummary(reportData)
|
||||
break
|
||||
case 'area_analysis':
|
||||
response = await reportsAPI.generateAreaAnalysis(reportData)
|
||||
break
|
||||
case 'forecast':
|
||||
response = await reportsAPI.generateForecast(reportData)
|
||||
break
|
||||
default:
|
||||
throw new Error('Invalid report type')
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
toast.success('Report generation started')
|
||||
setShowGenerateModal(false)
|
||||
refetch()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to generate report')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadReport = async (reportId) => {
|
||||
try {
|
||||
const response = await reportsAPI.downloadReport(reportId)
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([response.data])
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `report-${reportId}.${reportData.format}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
toast.success('Report downloaded successfully')
|
||||
} catch (error) {
|
||||
toast.error('Failed to download report')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-5 w-5 text-red-500" />
|
||||
case 'processing':
|
||||
return <Clock className="h-5 w-5 text-yellow-500 animate-spin" />
|
||||
default:
|
||||
return <Clock className="h-5 w-5 text-gray-400" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'
|
||||
case 'processing':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
Reports
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 font-normal">
|
||||
Generate and manage analytical reports
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowGenerateModal(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Generate Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reports List */}
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead className="table-header">
|
||||
<tr className="table-row">
|
||||
<th className="table-head">Title</th>
|
||||
<th className="table-head">Type</th>
|
||||
<th className="table-head">Format</th>
|
||||
<th className="table-head">Status</th>
|
||||
<th className="table-head">Created</th>
|
||||
<th className="table-head">Size</th>
|
||||
<th className="table-head">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="table-body">
|
||||
{displayReports?.map((report) => (
|
||||
<tr key={report.id} className="table-row">
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-3">
|
||||
<FileText className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{report.title}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
ID: {report.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-800 dark:bg-slate-900/20 dark:text-slate-400">
|
||||
{report.report_type.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
{report.format.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(report.status)}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(report.status)}`}>
|
||||
{report.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(report.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="table-cell text-sm text-gray-500 dark:text-gray-400">
|
||||
{report.file_size ? `${(report.file_size / 1024).toFixed(1)} KB` : '-'}
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center space-x-2">
|
||||
{report.status === 'completed' && (
|
||||
<button
|
||||
onClick={() => handleDownloadReport(report.id)}
|
||||
className="p-1 text-slate-600 hover:text-slate-800"
|
||||
title="Download Report"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Report Modal */}
|
||||
{showGenerateModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={() => setShowGenerateModal(false)}></div>
|
||||
|
||||
<div className="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
Generate Report
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Report Type</label>
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="transaction_summary">Transaction Summary</option>
|
||||
<option value="area_analysis">Area Analysis</option>
|
||||
<option value="forecast">Forecast Report</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Area</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter area (optional)"
|
||||
value={reportData.area}
|
||||
onChange={(e) => setReportData({...reportData, area: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Property Type</label>
|
||||
<select
|
||||
value={reportData.property_type}
|
||||
onChange={(e) => setReportData({...reportData, property_type: e.target.value})}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="Unit">Unit</option>
|
||||
<option value="Villa">Villa</option>
|
||||
<option value="Land">Land</option>
|
||||
<option value="Building">Building</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reportData.start_date}
|
||||
onChange={(e) => setReportData({...reportData, start_date: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={reportData.end_date}
|
||||
onChange={(e) => setReportData({...reportData, end_date: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Format</label>
|
||||
<select
|
||||
value={reportData.format}
|
||||
onChange={(e) => setReportData({...reportData, format: e.target.value})}
|
||||
className="input"
|
||||
>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="excel">Excel</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
onClick={handleGenerateReport}
|
||||
className="btn btn-primary btn-sm mr-2"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGenerateModal(false)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Reports
|
||||
|
||||
374
frontend/src/pages/Settings.jsx
Normal file
374
frontend/src/pages/Settings.jsx
Normal file
@ -0,0 +1,374 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
User,
|
||||
Key,
|
||||
Bell,
|
||||
Shield,
|
||||
Save,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react'
|
||||
import { billingAPI, usersAPI } from '../services/api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const Settings = () => {
|
||||
const [activeTab, setActiveTab] = useState('profile')
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const [profileData, setProfileData] = useState({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
company_name: '',
|
||||
phone_number: '',
|
||||
})
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: usersAPI.getUser,
|
||||
})
|
||||
|
||||
const { data: usageStats } = useQuery({
|
||||
queryKey: ['usageStats'],
|
||||
queryFn: billingAPI.getUsageStats,
|
||||
})
|
||||
|
||||
const { data: subscriptionInfo } = useQuery({
|
||||
queryKey: ['subscriptionInfo'],
|
||||
queryFn: billingAPI.getSubscriptionInfo,
|
||||
})
|
||||
|
||||
const handleUpdateProfile = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await usersAPI.updateUser(user.id, profileData)
|
||||
toast.success('Profile updated successfully')
|
||||
} catch (error) {
|
||||
toast.error('Failed to update profile')
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async (e) => {
|
||||
e.preventDefault()
|
||||
if (passwordData.new_password !== passwordData.confirm_password) {
|
||||
toast.error('Passwords do not match')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await usersAPI.changePassword(passwordData)
|
||||
toast.success('Password changed successfully')
|
||||
setPasswordData({ old_password: '', new_password: '', confirm_password: '' })
|
||||
} catch (error) {
|
||||
toast.error('Failed to change password')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerateApiKey = async () => {
|
||||
try {
|
||||
await usersAPI.regenerateApiKey()
|
||||
toast.success('API key regenerated successfully')
|
||||
} catch (error) {
|
||||
toast.error('Failed to regenerate API key')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile', name: 'Profile', icon: User },
|
||||
{ id: 'security', name: 'Security', icon: Shield },
|
||||
{ id: 'api', name: 'API Keys', icon: Key },
|
||||
{ id: 'billing', name: 'Billing', icon: SettingsIcon },
|
||||
{ id: 'notifications', name: 'Notifications', icon: Bell },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Manage your account settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<nav className="space-y-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-3" />
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="lg:col-span-3">
|
||||
{activeTab === 'profile' && (
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Profile Information
|
||||
</h3>
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="label">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.first_name}
|
||||
onChange={(e) => setProfileData({...profileData, first_name: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.last_name}
|
||||
onChange={(e) => setProfileData({...profileData, last_name: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profileData.email}
|
||||
onChange={(e) => setProfileData({...profileData, email: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="label">Company Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.company_name}
|
||||
onChange={(e) => setProfileData({...profileData, company_name: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={profileData.phone_number}
|
||||
onChange={(e) => setProfileData({...profileData, phone_number: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Security Settings
|
||||
</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-6">
|
||||
<div>
|
||||
<label className="label">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordData.old_password}
|
||||
onChange={(e) => setPasswordData({...passwordData, old_password: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordData.new_password}
|
||||
onChange={(e) => setPasswordData({...passwordData, new_password: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordData.confirm_password}
|
||||
onChange={(e) => setPasswordData({...passwordData, confirm_password: e.target.value})}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Change Password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'api' && (
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
API Keys
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Main API Key</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Use this key to authenticate API requests
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={user?.api_key || 'Not available'}
|
||||
readOnly
|
||||
className="input flex-1 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRegenerateApiKey}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'billing' && (
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Subscription Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Current Plan</h4>
|
||||
<p className="text-xl font-bold text-primary-600">
|
||||
{subscriptionInfo?.subscription_type || 'Free'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">API Access</h4>
|
||||
<p className={`text-lg font-medium ${
|
||||
subscriptionInfo?.is_api_enabled ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{subscriptionInfo?.is_api_enabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Usage Statistics
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{usageStats?.current_usage?.api_calls_this_month || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">API Calls This Month</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Limit: {usageStats?.limits?.api_calls_per_month || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{usageStats?.current_usage?.reports_this_month || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Reports This Month</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Limit: {usageStats?.limits?.reports_per_month || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{usageStats?.current_usage?.forecast_requests_this_month || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Forecasts This Month</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Limit: {usageStats?.limits?.forecast_requests_per_month || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Notification Preferences
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Email Notifications</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Receive updates via email
|
||||
</p>
|
||||
</div>
|
||||
<input type="checkbox" className="rounded border-gray-300" defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Report Ready</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Get notified when reports are ready
|
||||
</p>
|
||||
</div>
|
||||
<input type="checkbox" className="rounded border-gray-300" defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Usage Alerts</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Get notified when approaching limits
|
||||
</p>
|
||||
</div>
|
||||
<input type="checkbox" className="rounded border-gray-300" defaultChecked />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
|
||||
549
frontend/src/pages/Users.jsx
Normal file
549
frontend/src/pages/Users.jsx
Normal file
@ -0,0 +1,549 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Users as UsersIcon,
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
Key,
|
||||
Crown,
|
||||
UserCheck,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
Eye
|
||||
} from 'lucide-react'
|
||||
import { usersAPI } from '../services/api'
|
||||
import toast from 'react-hot-toast'
|
||||
import UserModal from '../components/UserModal'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const Users = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedUsers, setSelectedUsers] = useState([])
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [deletingUser, setDeletingUser] = useState(null)
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [sortBy, setSortBy] = useState('date_joined')
|
||||
const [sortOrder, setSortOrder] = useState('desc')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const { data: users, isLoading, refetch } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: usersAPI.getUsers,
|
||||
placeholderData: [], // Provide empty array as placeholder
|
||||
})
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await refetch()
|
||||
toast.success('Users refreshed successfully')
|
||||
} catch (error) {
|
||||
toast.error('Failed to refresh users')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async (userData) => {
|
||||
try {
|
||||
await usersAPI.createUser(userData)
|
||||
await refetch()
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateUser = async (userData) => {
|
||||
try {
|
||||
await usersAPI.updateUser(editingUser.id, userData)
|
||||
await refetch()
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deletingUser) return
|
||||
|
||||
try {
|
||||
await usersAPI.deleteUser(deletingUser.id)
|
||||
toast.success('User deleted successfully')
|
||||
setShowDeleteModal(false)
|
||||
setDeletingUser(null)
|
||||
await refetch()
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateSubscription = async (userId, subscriptionType) => {
|
||||
try {
|
||||
await usersAPI.updateSubscription({ user_id: userId, subscription_type: subscriptionType })
|
||||
toast.success('Subscription updated successfully')
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast.error('Failed to update subscription')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleApiAccess = async (userId) => {
|
||||
try {
|
||||
await usersAPI.toggleApiAccess({ user_id: userId })
|
||||
toast.success('API access toggled successfully')
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast.error('Failed to toggle API access')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditUser = (user) => {
|
||||
setEditingUser(user)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
const handleDeleteClick = (user) => {
|
||||
setDeletingUser(user)
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
|
||||
const handleBulkAction = async (action) => {
|
||||
if (selectedUsers.length === 0) {
|
||||
toast.error('Please select users first')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
// Implement bulk delete
|
||||
toast.success(`${selectedUsers.length} users deleted`)
|
||||
break
|
||||
case 'export':
|
||||
// Implement bulk export
|
||||
toast.success('Users exported successfully')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
setSelectedUsers([])
|
||||
} catch (error) {
|
||||
toast.error('Bulk action failed')
|
||||
}
|
||||
}
|
||||
|
||||
const getSubscriptionBadge = (type) => {
|
||||
const badges = {
|
||||
free: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
|
||||
paid: 'bg-slate-100 text-slate-800 dark:bg-slate-900/20 dark:text-slate-400',
|
||||
premium: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
|
||||
}
|
||||
return badges[type] || badges.free
|
||||
}
|
||||
|
||||
// Mock data for demonstration when backend is not available
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
subscription_type: 'premium',
|
||||
is_api_enabled: true,
|
||||
is_verified: true,
|
||||
date_joined: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
email: 'user@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
subscription_type: 'free',
|
||||
is_api_enabled: false,
|
||||
is_verified: true,
|
||||
date_joined: '2024-02-01T14:20:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
const displayUsers = users && users.length > 0 ? users : mockUsers
|
||||
const filteredUsers = displayUsers?.filter(user =>
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.first_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.last_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) || []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-slate-800 via-slate-700 to-slate-600 rounded-2xl p-8 text-white shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 tracking-tight">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="text-slate-200 text-base font-normal">
|
||||
Manage user accounts, permissions, and subscriptions
|
||||
</p>
|
||||
<div className="flex items-center mt-4 space-x-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<UsersIcon className="h-5 w-5 text-green-300" />
|
||||
<span className="text-sm font-medium">{displayUsers?.length || 0} Total Users</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserCheck className="h-5 w-5 text-slate-300" />
|
||||
<span className="text-sm font-medium">{displayUsers?.filter(u => u.is_verified)?.length || 0} Verified</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-all duration-200 rounded-lg px-4 py-2 flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="bg-white text-slate-700 hover:bg-slate-50 transition-all duration-200 rounded-lg px-6 py-2 font-semibold flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Search & Filter</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Advanced Filters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by name, email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Users</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="premium">Premium</option>
|
||||
<option value="verified">Verified</option>
|
||||
<option value="api_enabled">API Enabled</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-slate-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="date_joined">Date Joined</option>
|
||||
<option value="first_name">First Name</option>
|
||||
<option value="last_name">Last Name</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="subscription_type">Subscription</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-all duration-200 flex items-center justify-center space-x-2 text-sm"
|
||||
>
|
||||
<span>{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||||
<span>Sort</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-8 py-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Users</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
{selectedUsers.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedUsers.length} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleBulkAction('export')}
|
||||
className="px-2 py-1 bg-slate-100 dark:bg-slate-900/20 text-slate-600 dark:text-slate-400 text-xs font-medium rounded-md hover:bg-slate-200 dark:hover:bg-slate-900/30 transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="px-2 py-1 bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium rounded-md hover:bg-red-200 dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-slate-600 bg-gray-100 border-gray-300 rounded focus:ring-slate-500 dark:focus:ring-slate-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedUsers(filteredUsers.map(user => user.id))
|
||||
} else {
|
||||
setSelectedUsers([])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">User</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Subscription</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">API Access</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Joined</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredUsers.map((user, index) => (
|
||||
<tr key={user.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200 ${index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50/50 dark:bg-gray-800/50'}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-slate-600 bg-gray-100 border-gray-300 rounded focus:ring-slate-500 dark:focus:ring-slate-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
checked={selectedUsers.includes(user.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedUsers([...selectedUsers, user.id])
|
||||
} else {
|
||||
setSelectedUsers(selectedUsers.filter(id => id !== user.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-slate-500 to-slate-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span className="text-lg font-bold text-white">
|
||||
{user.first_name?.[0] || user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${getSubscriptionBadge(user.subscription_type)}`}>
|
||||
{user.subscription_type === 'premium' && <Crown className="h-4 w-4 mr-1" />}
|
||||
{user.subscription_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
|
||||
user.is_api_enabled
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'
|
||||
}`}>
|
||||
{user.is_api_enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
|
||||
user.is_verified
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400'
|
||||
}`}>
|
||||
{user.is_verified ? 'Verified' : 'Pending'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(user.date_joined).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="p-2 text-slate-600 hover:text-slate-800 hover:bg-slate-50 dark:hover:bg-slate-900/20 rounded-lg transition-all duration-200"
|
||||
title="Edit User"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateSubscription(user.id, user.subscription_type === 'free' ? 'paid' : 'free')}
|
||||
className="p-2 text-purple-600 hover:text-purple-800 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg transition-all duration-200"
|
||||
title="Change Subscription"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleApiAccess(user.id)}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
user.is_api_enabled
|
||||
? 'text-green-600 hover:text-green-800 hover:bg-green-50 dark:hover:bg-green-900/20'
|
||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={user.is_api_enabled ? 'Disable API Access' : 'Enable API Access'}
|
||||
>
|
||||
{user.is_api_enabled ? <Key className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all duration-200"
|
||||
title="Delete User"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Showing <span className="font-bold">{filteredUsers.length}</span> of <span className="font-bold">{displayUsers?.length || 0}</span> users
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">Rows per page:</span>
|
||||
<select className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
<option>10</option>
|
||||
<option>25</option>
|
||||
<option>50</option>
|
||||
<option>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
Previous
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-xs font-medium text-white bg-slate-600 rounded-md hover:bg-slate-700 transition-colors">
|
||||
1
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
2
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<UserModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleCreateUser}
|
||||
mode="create"
|
||||
/>
|
||||
|
||||
<UserModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setEditingUser(null)
|
||||
}}
|
||||
onSave={handleUpdateUser}
|
||||
user={editingUser}
|
||||
mode="edit"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
setDeletingUser(null)
|
||||
}}
|
||||
title="Delete User"
|
||||
size="sm"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/20 mb-4">
|
||||
<Trash2 className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Are you sure?
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
This will permanently delete <strong>{deletingUser?.first_name} {deletingUser?.last_name}</strong> and all their data. This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false)
|
||||
setDeletingUser(null)
|
||||
}}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteUser}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Users
|
||||
|
||||
135
frontend/src/services/api.js
Normal file
135
frontend/src/services/api.js
Normal file
@ -0,0 +1,135 @@
|
||||
import axios from 'axios'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
|
||||
|
||||
// Create axios instance
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor to handle token refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
if (refreshToken) {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh/`, {
|
||||
refresh: refreshToken,
|
||||
})
|
||||
|
||||
const { access } = response.data
|
||||
localStorage.setItem('accessToken', access)
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${access}`
|
||||
return api(originalRequest)
|
||||
} else {
|
||||
// No refresh token, clear everything and redirect
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
// Only redirect if not already on login page
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear tokens but don't redirect if already on login
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
if (error.response?.status >= 500) {
|
||||
toast.error('Server error. Please try again later.')
|
||||
} else if (error.response?.status === 403) {
|
||||
toast.error('Access denied. You do not have permission to perform this action.')
|
||||
} else if (error.response?.status === 404) {
|
||||
toast.error('Resource not found.')
|
||||
} else if (error.response?.data?.message) {
|
||||
toast.error(error.response.data.message)
|
||||
} else if (error.message) {
|
||||
toast.error(error.message)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// API endpoints
|
||||
export const authAPI = {
|
||||
login: (credentials) => api.post('/auth/login/', credentials),
|
||||
register: (userData) => api.post('/auth/register/', userData),
|
||||
logout: () => api.post('/auth/logout/'),
|
||||
refresh: (refreshToken) => api.post('/auth/refresh/', { refresh: refreshToken }),
|
||||
getUser: () => api.get('/auth/user/'),
|
||||
updateUser: (userData) => api.patch('/auth/update/', userData),
|
||||
changePassword: (passwordData) => api.post('/auth/change-password/', passwordData),
|
||||
}
|
||||
|
||||
export const analyticsAPI = {
|
||||
getTransactions: (params) => api.get('/analytics/transactions/', { params }),
|
||||
getTransactionSummary: (params) => api.get('/analytics/summary/', { params }),
|
||||
getAreaStats: (params) => api.get('/analytics/area-stats-data/', { params }),
|
||||
getPropertyTypeStats: (params) => api.get('/analytics/property-type-stats/', { params }),
|
||||
getTimeSeriesData: (params) => api.get('/analytics/time-series-data/', { params }),
|
||||
getMarketAnalysis: (params) => api.get('/analytics/market-analysis/', { params }),
|
||||
generateForecast: (data) => api.post('/analytics/forecast/', data),
|
||||
}
|
||||
|
||||
export const reportsAPI = {
|
||||
getReports: (params) => api.get('/reports/', { params }),
|
||||
getReport: (id) => api.get(`/reports/${id}/`),
|
||||
generateTransactionSummary: (data) => api.post('/reports/generate/transaction-summary/', data),
|
||||
generateAreaAnalysis: (data) => api.post('/reports/generate/area-analysis/', data),
|
||||
generateForecast: (data) => api.post('/reports/generate/forecast/', data),
|
||||
downloadReport: (id) => api.get(`/reports/${id}/download/`, { responseType: 'blob' }),
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
getUsers: (params) => api.get('/auth/list/', { params }),
|
||||
getUser: (id) => api.get(`/auth/${id}/`),
|
||||
updateUser: (id, data) => api.patch(`/auth/${id}/`, data),
|
||||
updateSubscription: (data) => api.post('/auth/update-subscription/', data),
|
||||
toggleApiAccess: (data) => api.post('/auth/toggle-api-access/', data),
|
||||
regenerateApiKey: () => api.post('/auth/regenerate-api-key/'),
|
||||
}
|
||||
|
||||
export const billingAPI = {
|
||||
getUsageStats: () => api.get('/billing/usage/'),
|
||||
getSubscriptionInfo: () => api.get('/billing/subscription/'),
|
||||
}
|
||||
|
||||
export const monitoringAPI = {
|
||||
getMetrics: () => api.get('/monitoring/metrics/'),
|
||||
getHealthCheck: () => api.get('/monitoring/health/'),
|
||||
}
|
||||
|
||||
37
frontend/src/store/slices/authSlice.js
Normal file
37
frontend/src/store/slices/authSlice.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const initialState = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
}
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
setCredentials: (state, action) => {
|
||||
const { user, token } = action.payload
|
||||
state.user = user
|
||||
state.token = token
|
||||
state.isAuthenticated = true
|
||||
},
|
||||
clearCredentials: (state) => {
|
||||
state.user = null
|
||||
state.token = null
|
||||
state.isAuthenticated = false
|
||||
},
|
||||
updateUser: (state, action) => {
|
||||
state.user = { ...state.user, ...action.payload }
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setCredentials, clearCredentials, updateUser } = authSlice.actions
|
||||
|
||||
export const selectIsAuthenticated = (state) => state.auth.isAuthenticated
|
||||
export const selectUser = (state) => state.auth.user
|
||||
export const selectToken = (state) => state.auth.token
|
||||
|
||||
export default authSlice.reducer
|
||||
|
||||
29
frontend/src/store/slices/themeSlice.js
Normal file
29
frontend/src/store/slices/themeSlice.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const initialState = {
|
||||
theme: 'light',
|
||||
isDark: false,
|
||||
}
|
||||
|
||||
const themeSlice = createSlice({
|
||||
name: 'theme',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTheme: (state, action) => {
|
||||
state.theme = action.payload
|
||||
state.isDark = action.payload === 'dark'
|
||||
},
|
||||
toggleTheme: (state) => {
|
||||
state.theme = state.theme === 'light' ? 'dark' : 'light'
|
||||
state.isDark = state.theme === 'dark'
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setTheme, toggleTheme } = themeSlice.actions
|
||||
|
||||
export const selectTheme = (state) => state.theme.theme
|
||||
export const selectIsDark = (state) => state.theme.isDark
|
||||
|
||||
export default themeSlice.reducer
|
||||
|
||||
21
frontend/src/store/store.js
Normal file
21
frontend/src/store/store.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import authReducer from './slices/authSlice'
|
||||
import themeReducer from './slices/themeSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
theme: themeReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['persist/PERSIST'],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Type definitions for TypeScript (if using .ts files)
|
||||
// export type RootState = ReturnType<typeof store.getState>
|
||||
// export type AppDispatch = typeof store.dispatch
|
||||
|
||||
106
frontend/tailwind.config.js
Normal file
106
frontend/tailwind.config.js
Normal file
@ -0,0 +1,106 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
950: '#052e16',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
950: '#451a03',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
950: '#450a0a',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-in': 'slideIn 0.3s ease-out',
|
||||
'bounce-in': 'bounceIn 0.6s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
bounceIn: {
|
||||
'0%': { transform: 'scale(0.3)', opacity: '0' },
|
||||
'50%': { transform: 'scale(1.05)' },
|
||||
'70%': { transform: 'scale(0.9)' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
|
||||
8
init.sql
Normal file
8
init.sql
Normal file
@ -0,0 +1,8 @@
|
||||
-- Initialize TimescaleDB extension
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
|
||||
-- Create additional indexes for time-series data
|
||||
-- These will be created after the Django migrations run
|
||||
|
||||
-- Create hypertables for time-series data
|
||||
-- This will be done by Django management commands
|
||||
98
k8s/backend.yaml
Normal file
98
k8s/backend.yaml
Normal file
@ -0,0 +1,98 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: dubai-analytics
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: dubai-analytics:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: DEBUG
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: dubai-analytics-config
|
||||
key: DEBUG
|
||||
- name: DB_NAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: dubai-analytics-config
|
||||
key: DB_NAME
|
||||
- name: DB_USER
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: dubai-analytics-config
|
||||
key: DB_USER
|
||||
- name: DB_HOST
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: dubai-analytics-config
|
||||
key: DB_HOST
|
||||
- name: DB_PORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: dubai-analytics-config
|
||||
key: DB_PORT
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: dubai-analytics-config
|
||||
key: REDIS_URL
|
||||
- name: CELERY_BROKER_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: dubai-analytics-config
|
||||
key: CELERY_BROKER_URL
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: dubai-analytics-secrets
|
||||
key: SECRET_KEY
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: dubai-analytics-secrets
|
||||
key: DB_PASSWORD
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health/
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health/
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend-service
|
||||
namespace: dubai-analytics
|
||||
spec:
|
||||
selector:
|
||||
app: backend
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
type: ClusterIP
|
||||
15
k8s/configmap.yaml
Normal file
15
k8s/configmap.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: dubai-analytics-config
|
||||
namespace: dubai-analytics
|
||||
data:
|
||||
DEBUG: "False"
|
||||
DB_NAME: "dubai_analytics"
|
||||
DB_USER: "postgres"
|
||||
DB_HOST: "postgres-service"
|
||||
DB_PORT: "5432"
|
||||
REDIS_URL: "redis://redis-service:6379/0"
|
||||
CELERY_BROKER_URL: "redis://redis-service:6379/1"
|
||||
ALLOWED_HOSTS: "localhost,127.0.0.1,dubai-analytics.com"
|
||||
CORS_ALLOWED_ORIGINS: "http://localhost:3000,https://admin.dubai-analytics.com"
|
||||
55
k8s/frontend.yaml
Normal file
55
k8s/frontend.yaml
Normal file
@ -0,0 +1,55 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
namespace: dubai-analytics
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: dubai-analytics-frontend:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: VITE_API_BASE_URL
|
||||
value: "http://backend-service:8000/api/v1"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "200m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend-service
|
||||
namespace: dubai-analytics
|
||||
spec:
|
||||
selector:
|
||||
app: frontend
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
53
k8s/ingress.yaml
Normal file
53
k8s/ingress.yaml
Normal file
@ -0,0 +1,53 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: dubai-analytics-ingress
|
||||
namespace: dubai-analytics
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/rate-limit: "100"
|
||||
nginx.ingress.kubernetes.io/rate-limit-window: "1m"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- dubai-analytics.com
|
||||
- admin.dubai-analytics.com
|
||||
secretName: dubai-analytics-tls
|
||||
rules:
|
||||
- host: dubai-analytics.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /admin
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: frontend-service
|
||||
port:
|
||||
number: 3000
|
||||
- host: admin.dubai-analytics.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: frontend-service
|
||||
port:
|
||||
number: 3000
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user