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