revamp and upgrade to modern stack (#562)
revamp and upgrade to modern stack
This commit is contained in:
parent
4ce7a5a2cc
commit
fc05248c28
804 changed files with 42869 additions and 76402 deletions
69
.gitignore
vendored
69
.gitignore
vendored
|
|
@ -1,28 +1,59 @@
|
|||
# Python
|
||||
*.pyc
|
||||
*~
|
||||
*.swo
|
||||
*.swp
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Django
|
||||
db.sqlite3
|
||||
media
|
||||
media-images
|
||||
cache
|
||||
.idea
|
||||
*.log
|
||||
media/
|
||||
media-images/
|
||||
cache/
|
||||
static/CACHE
|
||||
.sass-cache/
|
||||
main.css
|
||||
main.css.map
|
||||
ref.css
|
||||
ref.css.map
|
||||
.coverage
|
||||
crm/dev_settings\.py
|
||||
env
|
||||
.env
|
||||
docs/build
|
||||
staticfiles/
|
||||
celerybeat-schedule
|
||||
local_settings.py
|
||||
media
|
||||
.sass-cache
|
||||
.env
|
||||
server.log
|
||||
.vscode
|
||||
staticfiles
|
||||
.venv
|
||||
dump.rdb
|
||||
|
||||
# Virtual Environment
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
|
||||
# Documentation
|
||||
docs/build/
|
||||
|
||||
# Node.js / Frontend
|
||||
frontend/node_modules/
|
||||
frontend/.svelte-kit/
|
||||
frontend/build/
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/package-lock.json
|
||||
frontend/pnpm-lock.yaml
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
332
CLAUDE.md
332
CLAUDE.md
|
|
@ -1,249 +1,145 @@
|
|||
# CLAUDE.md - AI Assistant Guide for BottleCRM
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Overview
|
||||
BottleCRM is a SaaS CRM platform: Django REST API backend (`backend/`) + SvelteKit frontend (`frontend/`).
|
||||
|
||||
BottleCRM (Django-CRM) is an open-source CRM system built with Django and Django REST Framework. It provides customer relationship management for startups and small businesses.
|
||||
## Quick Reference
|
||||
|
||||
**Status**: Maintenance mode - active development has moved to [MicroPyramid/opensource-startup-crm](https://github.com/MicroPyramid/opensource-startup-crm) (SvelteKit version).
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && source venv/bin/activate && python manage.py runserver
|
||||
cd backend && source venv/bin/activate && celery -A crm worker --loglevel=INFO
|
||||
|
||||
# Frontend
|
||||
cd frontend && pnpm run dev
|
||||
```
|
||||
|
||||
**API Docs**: http://localhost:8000/swagger-ui/
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Django 4.2.1, Django REST Framework 3.14.0
|
||||
- **Database**: PostgreSQL
|
||||
- **Authentication**: JWT (djangorestframework-simplejwt)
|
||||
- **Task Queue**: Celery 5.2.7 with Redis
|
||||
- **CMS**: Wagtail 5.0.1
|
||||
- **API Docs**: drf-spectacular (Swagger/OpenAPI)
|
||||
- **Deployment**: Docker, Gunicorn
|
||||
**Backend**: Django 5.x, DRF, PostgreSQL, Redis/Celery, JWT auth, AWS S3/SES
|
||||
**Frontend**: SvelteKit, Svelte 5 (runes), TailwindCSS, shadcn-svelte, Zod, Axios, Lucide icons
|
||||
|
||||
## Project Structure
|
||||
## Multi-Tenancy & RLS (CRITICAL)
|
||||
|
||||
```
|
||||
crm/ # Django project settings
|
||||
common/ # Shared models, auth, utilities
|
||||
accounts/ # Account/Company management
|
||||
contacts/ # Contact management
|
||||
leads/ # Lead management
|
||||
opportunity/ # Sales pipeline
|
||||
tasks/ # Task management
|
||||
cases/ # Support/issue tracking
|
||||
events/ # Calendar/events
|
||||
teams/ # Team management
|
||||
invoices/ # Invoice management
|
||||
emails/ # Email management
|
||||
cms/ # Wagtail CMS
|
||||
templates/ # HTML templates
|
||||
static/ # Static assets
|
||||
docs/ # Sphinx documentation
|
||||
scripts/ # Deployment scripts
|
||||
Every request operates within an organization context. RLS provides database-level isolation, but **always filter at ORM level too** (defense-in-depth).
|
||||
|
||||
### Required Patterns
|
||||
|
||||
```python
|
||||
# ALWAYS filter by org - never use .all() on org-scoped models
|
||||
queryset = Model.objects.filter(org=request.profile.org)
|
||||
|
||||
# ALWAYS include org in get_object
|
||||
def get_object(self, pk):
|
||||
return get_object_or_404(Lead, id=pk, org=self.request.profile.org)
|
||||
|
||||
# ALWAYS set org when creating
|
||||
obj = Model.objects.create(name="Example", org=request.profile.org)
|
||||
serializer.save(org=request.profile.org)
|
||||
```
|
||||
|
||||
## Key Files
|
||||
### RLS-Protected Tables (24)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `manage.py` | Django CLI entry point |
|
||||
| `crm/settings.py` | Main configuration |
|
||||
| `crm/urls.py` | URL routing |
|
||||
| `crm/celery.py` | Celery task queue setup |
|
||||
| `common/models.py` | User, Org, Profile models |
|
||||
| `common/utils.py` | Shared utilities and constants |
|
||||
| `requirements.txt` | Python dependencies |
|
||||
`lead`, `accounts`, `contacts`, `opportunity`, `case`, `task`, `invoice`, `comment`, `attachments`, `document`, `teams`, `activity`, `tags`, `address`, `solution`, `board`, `board_column`, `board_task`, `board_member`, `apiSettings`, `account_email`, `emailLogs`, `invoice_history`, `security_audit_log`
|
||||
|
||||
## Development Setup
|
||||
### Adding New Org-Scoped Models
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.x
|
||||
- PostgreSQL 9.4+
|
||||
- Redis server
|
||||
1. Inherit from `BaseOrgModel` (includes org field automatically)
|
||||
2. Add table to `ORG_SCOPED_TABLES` in `common/rls/__init__.py`
|
||||
3. Create RLS migration using `get_enable_policy_sql('table_name')`
|
||||
4. Always filter by org in views
|
||||
|
||||
### Quick Start
|
||||
### Celery Tasks
|
||||
|
||||
```python
|
||||
from common.tasks import set_rls_context
|
||||
|
||||
@app.task
|
||||
def my_task(data_id, org_id):
|
||||
set_rls_context(org_id) # REQUIRED before any query
|
||||
obj = MyModel.objects.get(id=data_id)
|
||||
```
|
||||
|
||||
### RLS Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configure environment
|
||||
cp ENV.md .env
|
||||
# Edit .env with your database credentials
|
||||
|
||||
# Run migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Start server
|
||||
python manage.py runserver
|
||||
|
||||
# Start Celery worker (separate terminal)
|
||||
celery -A crm worker --loglevel=INFO
|
||||
python manage.py manage_rls --status # Check status
|
||||
python manage.py manage_rls --verify-user # Verify non-superuser (superusers bypass RLS!)
|
||||
python manage.py manage_rls --test # Test isolation
|
||||
```
|
||||
|
||||
### Docker Setup
|
||||
## Architecture
|
||||
|
||||
```bash
|
||||
docker build -t djcrm:1 -f docker/dockerfile .
|
||||
docker-compose -f docker/docker-compose.yml up
|
||||
### User Model
|
||||
|
||||
User → Profile → Org (one user can have profiles in multiple orgs)
|
||||
- Profile includes: role (ADMIN/USER), sales/marketing access flags
|
||||
- Super Admin: @micropyramid.com email domain
|
||||
|
||||
### BaseModel (`common/base.py`)
|
||||
|
||||
All models inherit from `BaseModel`: UUID PKs, timestamps (`created_at`, `updated_at`), audit (`created_by`, `updated_by`)
|
||||
|
||||
### AssignableMixin
|
||||
|
||||
For models with team/user assignment: Account, Lead, Contact, Opportunity, Case, Task
|
||||
|
||||
### Django Apps
|
||||
|
||||
- `common` - User, Profile, Org, Teams, Tags, Comment, Attachments, Document, Activity
|
||||
- `accounts`, `leads`, `contacts`, `opportunity`, `cases`, `tasks`, `invoices`, `marketing`
|
||||
|
||||
### API Pattern
|
||||
|
||||
```
|
||||
GET/POST /api/<module>/ # List/Create
|
||||
GET/PUT/DELETE /api/<module>/<pk>/ # Detail/Update/Delete
|
||||
GET/POST /api/<module>/comment/<pk>/ # Comments
|
||||
GET/POST /api/<module>/attachment/<pk>/ # Attachments
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
### Celery Tasks (`common/tasks.py`)
|
||||
|
||||
Email tasks: `send_email_to_new_user`, `send_email_user_mentions`, `send_email_user_status`, `send_email_user_delete`, `send_email_to_reset_password`
|
||||
Team tasks: `remove_users`, `update_team_users`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Structure
|
||||
|
||||
- `src/routes/(app)/` - Main CRM (authenticated)
|
||||
- `src/routes/(no-layout)/` - Auth pages
|
||||
- `src/lib/` - Components and utilities
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/hooks.server.js` - Auth, org validation, route protection
|
||||
- `src/lib/stores/auth.js` - Auth state
|
||||
|
||||
### Standards
|
||||
|
||||
- **Svelte 5**: Use runes (`$state`, `$derived`, `$effect`)
|
||||
- **NO TypeScript**: JavaScript with JSDoc annotations only
|
||||
- **Validation**: Zod for forms
|
||||
- **Icons**: Lucide Svelte
|
||||
|
||||
```bash
|
||||
# Run development server
|
||||
python manage.py runserver
|
||||
|
||||
# Run migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Create migrations
|
||||
python manage.py makemigrations
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Run tests with coverage
|
||||
coverage run -m pytest
|
||||
coverage report
|
||||
|
||||
# Start Celery worker
|
||||
celery -A crm worker --loglevel=INFO
|
||||
|
||||
# Format code
|
||||
black .
|
||||
pnpm run check # Type check via JSDoc
|
||||
pnpm run lint # Lint
|
||||
pnpm run format # Format
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- **Framework**: pytest with Django plugin
|
||||
- **Config**: `pytest.ini`
|
||||
- **Coverage**: `.coveragerc`
|
||||
|
||||
### Test Locations
|
||||
- `*/tests_celery_tasks.py` - Celery task tests (per app)
|
||||
- `*/tests.py` - Unit tests
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
pytest # All tests
|
||||
pytest accounts/ # Single app
|
||||
pytest -x # Stop on first failure
|
||||
coverage run -m pytest # With coverage
|
||||
# Backend
|
||||
cd backend && pytest && black . && isort .
|
||||
|
||||
# Frontend
|
||||
cd frontend && pnpm run check && pnpm run lint
|
||||
```
|
||||
|
||||
## API Structure
|
||||
## Related Docs
|
||||
|
||||
### Base URL Patterns
|
||||
- `/api/` - Common endpoints
|
||||
- `/api/accounts/` - Account management
|
||||
- `/api/contacts/` - Contact management
|
||||
- `/api/leads/` - Lead management
|
||||
- `/api/opportunities/` - Sales pipeline
|
||||
- `/api/teams/` - Team management
|
||||
- `/api/tasks/` - Task management
|
||||
- `/api/events/` - Events
|
||||
- `/api/cases/` - Case management
|
||||
|
||||
### API Documentation
|
||||
- `/swagger-ui/` - Swagger UI
|
||||
- `/api/schema/redoc/` - ReDoc
|
||||
- `/schema/` - OpenAPI schema
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Django App Pattern
|
||||
Each app follows this structure:
|
||||
- `models.py` - Database models
|
||||
- `views.py` - API views (class-based)
|
||||
- `serializer.py` - DRF serializers
|
||||
- `tasks.py` - Celery async tasks
|
||||
- `tests_celery_tasks.py` - Task tests
|
||||
|
||||
### Model Pattern
|
||||
Models inherit from `common.base.BaseModel` for timestamp fields:
|
||||
```python
|
||||
from common.base import BaseModel
|
||||
|
||||
class MyModel(BaseModel):
|
||||
# created_at and updated_at are automatic
|
||||
name = models.CharField(max_length=255)
|
||||
```
|
||||
|
||||
### View Pattern
|
||||
Views use DRF class-based views with permissions:
|
||||
```python
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
class MyView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
```
|
||||
|
||||
### Serializer Naming
|
||||
- Read serializers: `ModelSerializer`
|
||||
- Write serializers: `CreateModelSerializer`
|
||||
|
||||
### Code Quality
|
||||
- **Formatter**: Black (configured in `.pre-commit-config.yaml`)
|
||||
- **Pre-commit**: `pre-commit install`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Key variables (see `ENV.md` for full list):
|
||||
|
||||
```
|
||||
SECRET_KEY # Django secret key
|
||||
ENV_TYPE # dev/stage/live
|
||||
DBNAME # PostgreSQL database name
|
||||
DBUSER # Database user
|
||||
DBPASSWORD # Database password
|
||||
DBHOST # Database host
|
||||
CELERY_BROKER_URL # Redis URL for Celery
|
||||
```
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
The app supports multiple organizations via the `Org` model in `common/models.py`. Middleware in `common/middleware/get_company.py` handles organization context.
|
||||
|
||||
## Important Patterns
|
||||
|
||||
### Authentication
|
||||
JWT tokens via `djangorestframework-simplejwt`. Custom auth in `common/external_auth.py`.
|
||||
|
||||
### Async Tasks
|
||||
Celery tasks defined in `*/tasks.py` files. Example:
|
||||
```python
|
||||
from celery import shared_task
|
||||
|
||||
@shared_task
|
||||
def send_email_task(email_data):
|
||||
# Task implementation
|
||||
pass
|
||||
```
|
||||
|
||||
### Tags and Relationships
|
||||
Many-to-many relationships use through models. Tags are stored in `accounts/models.py`.
|
||||
|
||||
## CI/CD
|
||||
|
||||
- **GitHub Actions**: CodeQL security scanning
|
||||
- **Travis CI**: Test execution and coverage
|
||||
- **Jenkins**: Docker build and deployment
|
||||
|
||||
## External Documentation
|
||||
|
||||
- ReadTheDocs: http://django-crm.readthedocs.io
|
||||
- Demo: https://bottlecrm.io/
|
||||
- Sphinx docs: `docs/source/`
|
||||
|
||||
## When Making Changes
|
||||
|
||||
1. **Follow existing patterns** - Check similar files in the same app
|
||||
2. **Add tests** - Create tests in `tests_celery_tasks.py` or `tests.py`
|
||||
3. **Run formatter** - Use `black .` before committing
|
||||
4. **Update migrations** - Run `makemigrations` for model changes
|
||||
5. **Check API docs** - Ensure serializers are documented with drf-spectacular
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Database connection**: Ensure PostgreSQL is running and `.env` is configured
|
||||
- **Celery tasks not running**: Start Redis and Celery worker
|
||||
- **Static files 404**: Run `python manage.py collectstatic`
|
||||
- **Migration conflicts**: Check for unapplied migrations with `python manage.py showmigrations`
|
||||
- `QUICKSTART.md` - Setup guide
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM ubuntu:20.04
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# invalidate cache
|
||||
ARG APP_NAME
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ and feature requests via github.
|
|||
|
||||
### Moving Forward
|
||||
|
||||
To align with the project’s vision and address these challenges, I’ve shifted development to a new repository using **SvelteKit** and **Prisma** for a robust, fast, and feature-rich framework. A Minimum Viable Product (MVP) was released last week at [MicroPyramid/opensource-startup-crm](https://github.com/MicroPyramid/opensource-startup-crm).
|
||||
To align with the project’s vision and address these challenges, I’ve shifted development to a new repository using **SvelteKit** and **Prisma** for a robust, fast, and feature-rich framework. A Minimum Viable Product (MVP) was released last week at [MicroPyramid/Django-CRM](https://github.com/MicroPyramid/Django-CRM).
|
||||
|
||||
#### Key Updates:
|
||||
|
||||
|
|
@ -151,10 +151,10 @@ isort # to sort imports in python
|
|||
|
||||
**Note: This repository is no longer actively maintained.** For the latest development and community support, please visit our new SvelteKit-based CRM project:
|
||||
|
||||
- **New Project**: [MicroPyramid/opensource-startup-crm](https://github.com/MicroPyramid/opensource-startup-crm)
|
||||
- **New Project**: [MicroPyramid/Django-CRM](https://github.com/MicroPyramid/Django-CRM)
|
||||
- Follow [@micropyramid](<https://twitter.com/micropyramid>) on Twitter
|
||||
- For questions about the legacy Django CRM, check [past issues](<https://github.com/MicroPyramid/Django-CRM/issues>)
|
||||
- For new feature requests and active development, visit the [new repository](https://github.com/MicroPyramid/opensource-startup-crm)
|
||||
- For new feature requests and active development, visit the [new repository](https://github.com/MicroPyramid/Django-CRM)
|
||||
- For commercial support [Contact us](https://micropyramid.com/contact-us/)
|
||||
|
||||
## Credits
|
||||
|
|
@ -167,7 +167,7 @@ This project exists thanks to all the people who contributed during its active d
|
|||
|
||||
### Legacy Project Notice
|
||||
|
||||
This Django CRM repository is now in maintenance mode. While we welcome your feedback and appreciate past contributions, active development has moved to our new SvelteKit-based CRM solution at [MicroPyramid/opensource-startup-crm](https://github.com/MicroPyramid/opensource-startup-crm).
|
||||
This Django CRM repository is now in maintenance mode. While we welcome your feedback and appreciate past contributions, active development has moved to our new SvelteKit-based CRM solution at [MicroPyramid/Django-CRM](https://github.com/MicroPyramid/Django-CRM).
|
||||
|
||||
For commercial support [Contact us](https://micropyramid.com/contact-us/)
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,112 +0,0 @@
|
|||
# Generated by Django 4.2.1 on 2023-06-19 13:08
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('leads', '0001_initial'),
|
||||
('teams', '0001_initial'),
|
||||
('accounts', '0001_initial'),
|
||||
('contacts', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('common', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tags',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tags',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountemaillog',
|
||||
name='contact',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contact_email_log', to='contacts.contact'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountemaillog',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountemaillog',
|
||||
name='email',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_log', to='accounts.accountemail'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountemaillog',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountemail',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountemail',
|
||||
name='from_account',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_email', to='accounts.account'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountemail',
|
||||
name='recipients',
|
||||
field=models.ManyToManyField(related_name='recieved_email', to='contacts.contact'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountemail',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='assigned_to',
|
||||
field=models.ManyToManyField(related_name='account_assigned_users', to='common.profile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='contacts',
|
||||
field=models.ManyToManyField(related_name='account_contacts', to='contacts.contact'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='account_created_by', to='common.profile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='lead',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='account_leads', to='leads.lead'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='org',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='account_org', to='common.org'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='tags',
|
||||
field=models.ManyToManyField(blank=True, to='accounts.tags'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='teams',
|
||||
field=models.ManyToManyField(related_name='account_teams', to='teams.teams'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# Generated by Django 4.2.1 on 2023-10-13 08:16
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('accounts', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
import arrow
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from common import utils
|
||||
from common.models import Org, Profile
|
||||
from common.utils import COUNTRIES, INDCHOICES
|
||||
from contacts.models import Contact
|
||||
from teams.models import Teams
|
||||
from common.base import BaseModel
|
||||
|
||||
|
||||
class Tags(BaseModel):
|
||||
name = models.CharField(max_length=20)
|
||||
slug = models.CharField(max_length=20, unique=True, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Tag"
|
||||
verbose_name_plural = "Tags"
|
||||
db_table = "tags"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Account(BaseModel):
|
||||
|
||||
ACCOUNT_STATUS_CHOICE = (("open", "Open"), ("close", "Close"))
|
||||
|
||||
name = models.CharField(pgettext_lazy("Name of Account", "Name"), max_length=64)
|
||||
email = models.EmailField()
|
||||
phone = PhoneNumberField(null=True)
|
||||
industry = models.CharField(
|
||||
_("Industry Type"), max_length=255, choices=INDCHOICES, blank=True, null=True
|
||||
)
|
||||
# billing_address = models.ForeignKey(
|
||||
# Address, related_name='account_billing_address', on_delete=models.CASCADE, blank=True, null=True)
|
||||
# shipping_address = models.ForeignKey(
|
||||
# Address, related_name='account_shipping_address', on_delete=models.CASCADE, blank=True, null=True)
|
||||
billing_address_line = models.CharField(
|
||||
_("Address"), max_length=255, blank=True, null=True
|
||||
)
|
||||
billing_street = models.CharField(_("Street"), max_length=55, blank=True, null=True)
|
||||
billing_city = models.CharField(_("City"), max_length=255, blank=True, null=True)
|
||||
billing_state = models.CharField(_("State"), max_length=255, blank=True, null=True)
|
||||
billing_postcode = models.CharField(
|
||||
_("Post/Zip-code"), max_length=64, blank=True, null=True
|
||||
)
|
||||
billing_country = models.CharField(
|
||||
max_length=3, choices=COUNTRIES, blank=True, null=True
|
||||
)
|
||||
website = models.URLField(_("Website"), blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
# created_by = models.ForeignKey(
|
||||
# Profile, related_name="account_created_by", on_delete=models.SET_NULL, null=True
|
||||
# )
|
||||
is_active = models.BooleanField(default=False)
|
||||
tags = models.ManyToManyField(Tags, blank=True)
|
||||
status = models.CharField(
|
||||
choices=ACCOUNT_STATUS_CHOICE, max_length=64, default="open"
|
||||
)
|
||||
lead = models.ForeignKey(
|
||||
"leads.Lead", related_name="account_leads", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
contact_name = models.CharField(
|
||||
pgettext_lazy("Name of Contact", "Contact Name"), max_length=120
|
||||
)
|
||||
contacts = models.ManyToManyField(
|
||||
"contacts.Contact", related_name="account_contacts"
|
||||
)
|
||||
assigned_to = models.ManyToManyField(Profile, related_name="account_assigned_users")
|
||||
teams = models.ManyToManyField(Teams, related_name="account_teams")
|
||||
org = models.ForeignKey(
|
||||
Org,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="account_org",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Account"
|
||||
verbose_name_plural = "Accounts"
|
||||
db_table = "accounts"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
def get_complete_address(self):
|
||||
"""Concatenates complete address."""
|
||||
address = ""
|
||||
add_to_address = [
|
||||
self.billing_street,
|
||||
self.billing_city,
|
||||
self.billing_state,
|
||||
self.billing_postcode,
|
||||
self.get_billing_country_display(),
|
||||
]
|
||||
address = utils.append_str_to(address, *add_to_address)
|
||||
|
||||
return address
|
||||
|
||||
@property
|
||||
def created_on_arrow(self):
|
||||
return arrow.get(self.created_at).humanize()
|
||||
|
||||
@property
|
||||
def contact_values(self):
|
||||
contacts = list(self.contacts.values_list("id", flat=True))
|
||||
return ",".join(str(contact) for contact in contacts)
|
||||
|
||||
@property
|
||||
def get_team_users(self):
|
||||
team_user_ids = list(self.teams.values_list("users__id", flat=True))
|
||||
return Profile.objects.filter(id__in=team_user_ids)
|
||||
|
||||
@property
|
||||
def get_team_and_assigned_users(self):
|
||||
team_user_ids = list(self.teams.values_list("users__id", flat=True))
|
||||
assigned_user_ids = list(self.assigned_to.values_list("id", flat=True))
|
||||
user_ids = team_user_ids + assigned_user_ids
|
||||
return Profile.objects.filter(id__in=user_ids)
|
||||
|
||||
@property
|
||||
def get_assigned_users_not_in_teams(self):
|
||||
team_user_ids = list(self.teams.values_list("users__id", flat=True))
|
||||
assigned_user_ids = list(self.assigned_to.values_list("id", flat=True))
|
||||
user_ids = set(assigned_user_ids) - set(team_user_ids)
|
||||
return Profile.objects.filter(id__in=list(user_ids))
|
||||
|
||||
|
||||
class AccountEmail(BaseModel):
|
||||
from_account = models.ForeignKey(
|
||||
Account, related_name="sent_email", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
recipients = models.ManyToManyField(Contact, related_name="recieved_email")
|
||||
message_subject = models.TextField(null=True)
|
||||
message_body = models.TextField(null=True)
|
||||
timezone = models.CharField(max_length=100, default="UTC")
|
||||
scheduled_date_time = models.DateTimeField(null=True)
|
||||
scheduled_later = models.BooleanField(default=False)
|
||||
from_email = models.EmailField()
|
||||
rendered_message_body = models.TextField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Account Email"
|
||||
verbose_name_plural = "Account Emails"
|
||||
db_table = "account_email"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.message_subject}"
|
||||
|
||||
class AccountEmailLog(BaseModel):
|
||||
"""this model is used to track if the email is sent or not"""
|
||||
|
||||
email = models.ForeignKey(
|
||||
AccountEmail, related_name="email_log", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
Contact, related_name="contact_email_log", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
is_sent = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "EmailLog"
|
||||
verbose_name_plural = "EmailLogs"
|
||||
db_table = "emailLogs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email.message_subject}"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Environment variables
|
||||
|
||||
SECRET_KEY="mco934$@)NHUYTC%6789"
|
||||
SECRET_KEY=""
|
||||
ENV_TYPE="dev"
|
||||
DOMAIN_NAME=""
|
||||
|
||||
|
|
@ -14,24 +14,25 @@ AWS_SES_REGION_ENDPOINT=""
|
|||
|
||||
|
||||
# DB
|
||||
DBNAME="bottlecrm"
|
||||
DBUSER="postgres"
|
||||
DBPASSWORD="root"
|
||||
DBHOST="localhost"
|
||||
DBNAME=""
|
||||
DBUSER=""
|
||||
DBPASSWORD=""
|
||||
DBHOST=""
|
||||
DBPORT=""
|
||||
|
||||
# Sentry
|
||||
SENTRY_DSN=""
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL=""
|
||||
CELERY_RESULT_BACKEND=""
|
||||
CELERY_BROKER_URL="redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND="redis://localhost:6379/0"
|
||||
|
||||
# Swagger
|
||||
SWAGGER_ROOT_URL=""
|
||||
|
||||
#CACHES
|
||||
MEMCACHELOCATION=""
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Email
|
||||
DEFAULT_FROM_EMAIL=""
|
||||
407
backend/README.md
Normal file
407
backend/README.md
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
# BottleCRM Backend - Django REST API
|
||||
|
||||
The backend for BottleCRM, a multi-tenant CRM platform built with Django REST Framework.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Django 4.2.1** - Web framework
|
||||
- **Django REST Framework 3.14.0** - API toolkit
|
||||
- **PostgreSQL** - Database (psycopg2-binary 2.9.11)
|
||||
- **Celery 5.5.3** - Async task queue
|
||||
- **Redis 4.6.0** - Message broker for Celery
|
||||
- **djangorestframework-simplejwt 5.2.2** - JWT authentication
|
||||
- **drf-spectacular 0.26.2** - OpenAPI/Swagger documentation
|
||||
- **django-ses 3.5.0** - AWS SES email backend
|
||||
- **Sentry SDK 1.24.0** - Error tracking
|
||||
|
||||
## Django Apps
|
||||
|
||||
| App | Description |
|
||||
|-----|-------------|
|
||||
| `common` | User, Organization, Profile, Comments, Attachments, Document models |
|
||||
| `accounts` | Customer account management |
|
||||
| `leads` | Lead tracking and conversion |
|
||||
| `contacts` | Contact management |
|
||||
| `opportunity` | Sales pipeline and deal tracking |
|
||||
| `cases` | Customer support tickets |
|
||||
| `tasks` | Task management |
|
||||
| `invoices` | Invoicing system |
|
||||
| `events` | Calendar events |
|
||||
| `teams` | Team management |
|
||||
| `emails` | Email handling |
|
||||
| `planner` | Planner events |
|
||||
| `boards` | Kanban board system |
|
||||
| `marketing` | Newsletter and contact forms |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.8+**
|
||||
- **PostgreSQL**
|
||||
- **Redis** (for Celery)
|
||||
- **virtualenv**
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Create and activate virtual environment
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. Configure environment variables
|
||||
|
||||
Create a `.env` file in the `backend/` directory:
|
||||
|
||||
```env
|
||||
# Django
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ENV_TYPE=dev
|
||||
|
||||
# Database
|
||||
DBNAME=bottlecrm
|
||||
DBUSER=postgres
|
||||
DBPASSWORD=root
|
||||
DBHOST=localhost
|
||||
DBPORT=5432
|
||||
|
||||
# Email
|
||||
DEFAULT_FROM_EMAIL=noreply@bottlecrm.com
|
||||
ADMIN_EMAIL=admin@bottlecrm.com
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
||||
|
||||
# Domain
|
||||
DOMAIN_NAME=http://localhost:8000
|
||||
SWAGGER_ROOT_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
### 4. Set up database
|
||||
|
||||
```bash
|
||||
# Create PostgreSQL database
|
||||
sudo -u postgres psql
|
||||
CREATE DATABASE bottlecrm WITH OWNER = postgres;
|
||||
ALTER USER postgres WITH PASSWORD 'root';
|
||||
\q
|
||||
|
||||
# Run migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Create superuser (optional)
|
||||
python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 5. Run the development server
|
||||
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8000`
|
||||
|
||||
## Running Celery
|
||||
|
||||
For background tasks (emails, notifications), run the Celery worker:
|
||||
|
||||
```bash
|
||||
celery -A crm worker --loglevel=INFO
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
- **Swagger UI**: http://localhost:8000/swagger-ui/
|
||||
- **ReDoc**: http://localhost:8000/api/schema/redoc/
|
||||
- **Django Admin**: http://localhost:8000/admin/
|
||||
|
||||
## Architecture
|
||||
|
||||
### Multi-Tenancy
|
||||
|
||||
Every request operates within an organization context:
|
||||
|
||||
- **Organization (Org)**: Top-level tenant container
|
||||
- **Users**: Regular members with USER role
|
||||
- **Admins**: Organization administrators with ADMIN role
|
||||
- **Super Admin**: Users with @micropyramid.com email domain have platform-wide access
|
||||
|
||||
### Authentication
|
||||
|
||||
JWT-based authentication:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
- Organization ID is embedded in the JWT token (not sent as header)
|
||||
- Access token lifetime: 1 day
|
||||
- Refresh token lifetime: 365 days
|
||||
|
||||
### Middleware
|
||||
|
||||
The middleware chain provides security:
|
||||
|
||||
1. **`GetProfileAndOrg`** (`common.middleware.get_company`):
|
||||
- Extracts org_id from JWT token claims (not headers - prevents spoofing)
|
||||
- Validates user has active membership in the organization
|
||||
- Sets `request.profile` and `request.org`
|
||||
|
||||
2. **`RequireOrgContext`** (`common.middleware.rls_context`):
|
||||
- Sets PostgreSQL session variable `app.current_org` for RLS
|
||||
- Resets context after each request
|
||||
|
||||
### Row-Level Security (RLS)
|
||||
|
||||
PostgreSQL RLS provides database-level tenant isolation as defense-in-depth.
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. **Middleware sets context**: `SET app.current_org = '<org_id>'`
|
||||
2. **RLS policies filter queries**: Only rows matching `org_id` are visible
|
||||
3. **Fail-safe design**: Empty context returns zero rows (NULLIF pattern)
|
||||
|
||||
#### Protected Tables (24 total)
|
||||
|
||||
| Category | Tables |
|
||||
|----------|--------|
|
||||
| Core Business | `lead`, `accounts`, `contacts`, `opportunity`, `case`, `task`, `invoice` |
|
||||
| Supporting | `comment`, `attachments`, `document`, `teams`, `activity`, `tags`, `address`, `solution` |
|
||||
| Boards | `board`, `board_column`, `board_task`, `board_member` |
|
||||
| Other | `apiSettings`, `account_email`, `emailLogs`, `invoice_history`, `security_audit_log` |
|
||||
|
||||
#### Configuration
|
||||
|
||||
RLS is configured in `common/rls/__init__.py`:
|
||||
|
||||
```python
|
||||
from common.rls import RLS_CONFIG, get_enable_policy_sql
|
||||
|
||||
# List of protected tables
|
||||
tables = RLS_CONFIG['tables']
|
||||
|
||||
# Enable RLS on a table
|
||||
cursor.execute(get_enable_policy_sql('my_table'))
|
||||
```
|
||||
|
||||
#### Management Commands
|
||||
|
||||
```bash
|
||||
# Check RLS status on all tables
|
||||
python manage.py manage_rls --status
|
||||
|
||||
# Verify database user is non-superuser (required for RLS)
|
||||
python manage.py manage_rls --verify-user
|
||||
|
||||
# Test RLS isolation between organizations
|
||||
python manage.py manage_rls --test
|
||||
|
||||
# Enable RLS on all configured tables
|
||||
python manage.py manage_rls --enable
|
||||
|
||||
# Disable RLS (for debugging only)
|
||||
python manage.py manage_rls --disable
|
||||
```
|
||||
|
||||
#### Critical: Database User Setup
|
||||
|
||||
**PostgreSQL superusers bypass ALL RLS policies.** You must use a non-superuser:
|
||||
|
||||
```sql
|
||||
-- Create application user
|
||||
CREATE USER crm_app WITH PASSWORD 'your_secure_password';
|
||||
|
||||
-- Grant permissions
|
||||
GRANT CONNECT ON DATABASE bottlecrm TO crm_app;
|
||||
GRANT USAGE ON SCHEMA public TO crm_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_app;
|
||||
|
||||
-- Future tables inherit permissions
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_app;
|
||||
```
|
||||
|
||||
Update `.env`:
|
||||
```env
|
||||
DBUSER=crm_app
|
||||
DBPASSWORD=your_secure_password
|
||||
```
|
||||
|
||||
#### Celery Tasks & RLS
|
||||
|
||||
Background tasks don't go through middleware, so set RLS context manually:
|
||||
|
||||
```python
|
||||
from common.tasks import set_rls_context
|
||||
|
||||
@app.task
|
||||
def my_background_task(data_id, org_id):
|
||||
set_rls_context(org_id) # Required!
|
||||
obj = MyModel.objects.get(id=data_id)
|
||||
# ... process
|
||||
```
|
||||
|
||||
#### Adding RLS to New Tables
|
||||
|
||||
1. Add table name to `ORG_SCOPED_TABLES` in `common/rls/__init__.py`
|
||||
2. Create migration using `get_enable_policy_sql()`
|
||||
3. Ensure model has `org = models.ForeignKey(Org, ...)`
|
||||
|
||||
### BaseModel Pattern
|
||||
|
||||
All models inherit from `BaseModel` (`common.base.BaseModel`):
|
||||
|
||||
- UUID primary keys (not integer IDs)
|
||||
- Automatic timestamps: `created_at`, `updated_at`
|
||||
- Audit trail: `created_by`, `updated_by`
|
||||
- Organization isolation: `org = models.ForeignKey(Org)`
|
||||
|
||||
### API Endpoint Pattern
|
||||
|
||||
```
|
||||
GET/POST /api/<module>/ # List/Create
|
||||
GET/PUT/DELETE /api/<module>/<pk>/ # Detail/Update/Delete
|
||||
GET/POST /api/<module>/comment/<pk>/ # Comments
|
||||
GET/POST /api/<module>/attachment/<pk>/ # Attachments
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── manage.py
|
||||
├── requirements.txt
|
||||
├── crm/ # Django project settings
|
||||
│ ├── settings.py
|
||||
│ ├── urls.py
|
||||
│ ├── celery.py
|
||||
│ └── wsgi.py
|
||||
├── common/ # Core models and utilities
|
||||
│ ├── models.py # User, Org, Profile, etc.
|
||||
│ ├── base.py # BaseModel
|
||||
│ ├── middleware/
|
||||
│ └── tasks.py # Celery tasks
|
||||
├── accounts/
|
||||
├── leads/
|
||||
├── contacts/
|
||||
├── opportunity/
|
||||
├── cases/
|
||||
├── tasks/
|
||||
├── invoices/
|
||||
├── events/
|
||||
├── teams/
|
||||
├── emails/
|
||||
├── planner/
|
||||
├── boards/
|
||||
├── marketing/
|
||||
├── templates/
|
||||
└── static/
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black .
|
||||
|
||||
# Sort imports
|
||||
isort .
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
```
|
||||
|
||||
### Creating a New App
|
||||
|
||||
1. Create the app:
|
||||
```bash
|
||||
python manage.py startapp myapp
|
||||
```
|
||||
|
||||
2. Add to `INSTALLED_APPS` in `crm/settings.py`
|
||||
|
||||
3. Create models inheriting from `BaseModel`:
|
||||
```python
|
||||
from common.base import BaseModel
|
||||
from common.models import Org
|
||||
|
||||
class MyModel(BaseModel):
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE)
|
||||
# ... other fields
|
||||
```
|
||||
|
||||
4. Always filter queries by organization:
|
||||
```python
|
||||
queryset = MyModel.objects.filter(org=request.profile.org)
|
||||
```
|
||||
|
||||
5. Run migrations:
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SECRET_KEY` | Django secret key |
|
||||
| `ENV_TYPE` | Environment type (`dev` or `prod`) |
|
||||
| `DBNAME` | PostgreSQL database name |
|
||||
| `DBUSER` | PostgreSQL username |
|
||||
| `DBPASSWORD` | PostgreSQL password |
|
||||
| `DBHOST` | PostgreSQL host |
|
||||
| `DBPORT` | PostgreSQL port |
|
||||
| `DEFAULT_FROM_EMAIL` | Default sender email |
|
||||
| `ADMIN_EMAIL` | Admin notification email |
|
||||
| `CELERY_BROKER_URL` | Redis URL for Celery broker |
|
||||
| `CELERY_RESULT_BACKEND` | Redis URL for Celery results |
|
||||
| `DOMAIN_NAME` | Application domain |
|
||||
| `SWAGGER_ROOT_URL` | Swagger documentation root URL |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
```bash
|
||||
# Check PostgreSQL is running
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# Verify database exists
|
||||
sudo -u postgres psql -l
|
||||
```
|
||||
|
||||
### Migration Issues
|
||||
|
||||
```bash
|
||||
# Show migration status
|
||||
python manage.py showmigrations
|
||||
|
||||
# Reset migrations (development only)
|
||||
python manage.py migrate --fake <app> zero
|
||||
```
|
||||
|
||||
### Celery Not Processing Tasks
|
||||
|
||||
```bash
|
||||
# Check Redis is running
|
||||
redis-cli ping
|
||||
|
||||
# Check Celery worker logs
|
||||
celery -A crm worker --loglevel=DEBUG
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](../LICENSE) for details.
|
||||
487
backend/accounts/migrations/0001_initial.py
Normal file
487
backend/accounts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
# Generated by Django 5.2.8 on 2025-11-27 12:17
|
||||
|
||||
import uuid
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Account",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255, verbose_name="Account Name")),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, null=True, verbose_name="Email"
|
||||
),
|
||||
),
|
||||
(
|
||||
"phone",
|
||||
phonenumber_field.modelfields.PhoneNumberField(
|
||||
blank=True,
|
||||
max_length=128,
|
||||
null=True,
|
||||
region=None,
|
||||
verbose_name="Phone",
|
||||
),
|
||||
),
|
||||
(
|
||||
"website",
|
||||
models.URLField(blank=True, null=True, verbose_name="Website"),
|
||||
),
|
||||
(
|
||||
"industry",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("ADVERTISING", "ADVERTISING"),
|
||||
("AGRICULTURE", "AGRICULTURE"),
|
||||
("APPAREL & ACCESSORIES", "APPAREL & ACCESSORIES"),
|
||||
("AUTOMOTIVE", "AUTOMOTIVE"),
|
||||
("BANKING", "BANKING"),
|
||||
("BIOTECHNOLOGY", "BIOTECHNOLOGY"),
|
||||
(
|
||||
"BUILDING MATERIALS & EQUIPMENT",
|
||||
"BUILDING MATERIALS & EQUIPMENT",
|
||||
),
|
||||
("CHEMICAL", "CHEMICAL"),
|
||||
("COMPUTER", "COMPUTER"),
|
||||
("EDUCATION", "EDUCATION"),
|
||||
("ELECTRONICS", "ELECTRONICS"),
|
||||
("ENERGY", "ENERGY"),
|
||||
("ENTERTAINMENT & LEISURE", "ENTERTAINMENT & LEISURE"),
|
||||
("FINANCE", "FINANCE"),
|
||||
("FOOD & BEVERAGE", "FOOD & BEVERAGE"),
|
||||
("GROCERY", "GROCERY"),
|
||||
("HEALTHCARE", "HEALTHCARE"),
|
||||
("INSURANCE", "INSURANCE"),
|
||||
("LEGAL", "LEGAL"),
|
||||
("MANUFACTURING", "MANUFACTURING"),
|
||||
("PUBLISHING", "PUBLISHING"),
|
||||
("REAL ESTATE", "REAL ESTATE"),
|
||||
("SERVICE", "SERVICE"),
|
||||
("SOFTWARE", "SOFTWARE"),
|
||||
("SPORTS", "SPORTS"),
|
||||
("TECHNOLOGY", "TECHNOLOGY"),
|
||||
("TELECOMMUNICATIONS", "TELECOMMUNICATIONS"),
|
||||
("TELEVISION", "TELEVISION"),
|
||||
("TRANSPORTATION", "TRANSPORTATION"),
|
||||
("VENTURE CAPITAL", "VENTURE CAPITAL"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Industry",
|
||||
),
|
||||
),
|
||||
(
|
||||
"number_of_employees",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, null=True, verbose_name="Number of Employees"
|
||||
),
|
||||
),
|
||||
(
|
||||
"annual_revenue",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=2,
|
||||
max_digits=15,
|
||||
null=True,
|
||||
verbose_name="Annual Revenue",
|
||||
),
|
||||
),
|
||||
(
|
||||
"address_line",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="Address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"city",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="City"
|
||||
),
|
||||
),
|
||||
(
|
||||
"state",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="State"
|
||||
),
|
||||
),
|
||||
(
|
||||
"postcode",
|
||||
models.CharField(
|
||||
blank=True, max_length=64, null=True, verbose_name="Postal Code"
|
||||
),
|
||||
),
|
||||
(
|
||||
"country",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("GB", "United Kingdom"),
|
||||
("AF", "Afghanistan"),
|
||||
("AX", "Aland Islands"),
|
||||
("AL", "Albania"),
|
||||
("DZ", "Algeria"),
|
||||
("AS", "American Samoa"),
|
||||
("AD", "Andorra"),
|
||||
("AO", "Angola"),
|
||||
("AI", "Anguilla"),
|
||||
("AQ", "Antarctica"),
|
||||
("AG", "Antigua and Barbuda"),
|
||||
("AR", "Argentina"),
|
||||
("AM", "Armenia"),
|
||||
("AW", "Aruba"),
|
||||
("AU", "Australia"),
|
||||
("AT", "Austria"),
|
||||
("AZ", "Azerbaijan"),
|
||||
("BS", "Bahamas"),
|
||||
("BH", "Bahrain"),
|
||||
("BD", "Bangladesh"),
|
||||
("BB", "Barbados"),
|
||||
("BY", "Belarus"),
|
||||
("BE", "Belgium"),
|
||||
("BZ", "Belize"),
|
||||
("BJ", "Benin"),
|
||||
("BM", "Bermuda"),
|
||||
("BT", "Bhutan"),
|
||||
("BO", "Bolivia"),
|
||||
("BA", "Bosnia and Herzegovina"),
|
||||
("BW", "Botswana"),
|
||||
("BV", "Bouvet Island"),
|
||||
("BR", "Brazil"),
|
||||
("IO", "British Indian Ocean Territory"),
|
||||
("BN", "Brunei Darussalam"),
|
||||
("BG", "Bulgaria"),
|
||||
("BF", "Burkina Faso"),
|
||||
("BI", "Burundi"),
|
||||
("KH", "Cambodia"),
|
||||
("CM", "Cameroon"),
|
||||
("CA", "Canada"),
|
||||
("CV", "Cape Verde"),
|
||||
("KY", "Cayman Islands"),
|
||||
("CF", "Central African Republic"),
|
||||
("TD", "Chad"),
|
||||
("CL", "Chile"),
|
||||
("CN", "China"),
|
||||
("CX", "Christmas Island"),
|
||||
("CC", "Cocos (Keeling) Islands"),
|
||||
("CO", "Colombia"),
|
||||
("KM", "Comoros"),
|
||||
("CG", "Congo"),
|
||||
("CD", "Congo, The Democratic Republic of the"),
|
||||
("CK", "Cook Islands"),
|
||||
("CR", "Costa Rica"),
|
||||
("CI", "Cote d'Ivoire"),
|
||||
("HR", "Croatia"),
|
||||
("CU", "Cuba"),
|
||||
("CY", "Cyprus"),
|
||||
("CZ", "Czech Republic"),
|
||||
("DK", "Denmark"),
|
||||
("DJ", "Djibouti"),
|
||||
("DM", "Dominica"),
|
||||
("DO", "Dominican Republic"),
|
||||
("EC", "Ecuador"),
|
||||
("EG", "Egypt"),
|
||||
("SV", "El Salvador"),
|
||||
("GQ", "Equatorial Guinea"),
|
||||
("ER", "Eritrea"),
|
||||
("EE", "Estonia"),
|
||||
("ET", "Ethiopia"),
|
||||
("FK", "Falkland Islands (Malvinas)"),
|
||||
("FO", "Faroe Islands"),
|
||||
("FJ", "Fiji"),
|
||||
("FI", "Finland"),
|
||||
("FR", "France"),
|
||||
("GF", "French Guiana"),
|
||||
("PF", "French Polynesia"),
|
||||
("TF", "French Southern Territories"),
|
||||
("GA", "Gabon"),
|
||||
("GM", "Gambia"),
|
||||
("GE", "Georgia"),
|
||||
("DE", "Germany"),
|
||||
("GH", "Ghana"),
|
||||
("GI", "Gibraltar"),
|
||||
("GR", "Greece"),
|
||||
("GL", "Greenland"),
|
||||
("GD", "Grenada"),
|
||||
("GP", "Guadeloupe"),
|
||||
("GU", "Guam"),
|
||||
("GT", "Guatemala"),
|
||||
("GG", "Guernsey"),
|
||||
("GN", "Guinea"),
|
||||
("GW", "Guinea-Bissau"),
|
||||
("GY", "Guyana"),
|
||||
("HT", "Haiti"),
|
||||
("HM", "Heard Island and McDonald Islands"),
|
||||
("VA", "Holy See (Vatican City State)"),
|
||||
("HN", "Honduras"),
|
||||
("HK", "Hong Kong"),
|
||||
("HU", "Hungary"),
|
||||
("IS", "Iceland"),
|
||||
("IN", "India"),
|
||||
("ID", "Indonesia"),
|
||||
("IR", "Iran, Islamic Republic of"),
|
||||
("IQ", "Iraq"),
|
||||
("IE", "Ireland"),
|
||||
("IM", "Isle of Man"),
|
||||
("IL", "Israel"),
|
||||
("IT", "Italy"),
|
||||
("JM", "Jamaica"),
|
||||
("JP", "Japan"),
|
||||
("JE", "Jersey"),
|
||||
("JO", "Jordan"),
|
||||
("KZ", "Kazakhstan"),
|
||||
("KE", "Kenya"),
|
||||
("KI", "Kiribati"),
|
||||
("KP", "Korea, Democratic People's Republic of"),
|
||||
("KR", "Korea, Republic of"),
|
||||
("KW", "Kuwait"),
|
||||
("KG", "Kyrgyzstan"),
|
||||
("LA", "Lao People's Democratic Republic"),
|
||||
("LV", "Latvia"),
|
||||
("LB", "Lebanon"),
|
||||
("LS", "Lesotho"),
|
||||
("LR", "Liberia"),
|
||||
("LY", "Libyan Arab Jamahiriya"),
|
||||
("LI", "Liechtenstein"),
|
||||
("LT", "Lithuania"),
|
||||
("LU", "Luxembourg"),
|
||||
("MO", "Macao"),
|
||||
("MK", "Macedonia, The Former Yugoslav Republic of"),
|
||||
("MG", "Madagascar"),
|
||||
("MW", "Malawi"),
|
||||
("MY", "Malaysia"),
|
||||
("MV", "Maldives"),
|
||||
("ML", "Mali"),
|
||||
("MT", "Malta"),
|
||||
("MH", "Marshall Islands"),
|
||||
("MQ", "Martinique"),
|
||||
("MR", "Mauritania"),
|
||||
("MU", "Mauritius"),
|
||||
("YT", "Mayotte"),
|
||||
("MX", "Mexico"),
|
||||
("FM", "Micronesia, Federated States of"),
|
||||
("MD", "Moldova"),
|
||||
("MC", "Monaco"),
|
||||
("MN", "Mongolia"),
|
||||
("ME", "Montenegro"),
|
||||
("MS", "Montserrat"),
|
||||
("MA", "Morocco"),
|
||||
("MZ", "Mozambique"),
|
||||
("MM", "Myanmar"),
|
||||
("NA", "Namibia"),
|
||||
("NR", "Nauru"),
|
||||
("NP", "Nepal"),
|
||||
("NL", "Netherlands"),
|
||||
("AN", "Netherlands Antilles"),
|
||||
("NC", "New Caledonia"),
|
||||
("NZ", "New Zealand"),
|
||||
("NI", "Nicaragua"),
|
||||
("NE", "Niger"),
|
||||
("NG", "Nigeria"),
|
||||
("NU", "Niue"),
|
||||
("NF", "Norfolk Island"),
|
||||
("MP", "Northern Mariana Islands"),
|
||||
("NO", "Norway"),
|
||||
("OM", "Oman"),
|
||||
("PK", "Pakistan"),
|
||||
("PW", "Palau"),
|
||||
("PS", "Palestinian Territory, Occupied"),
|
||||
("PA", "Panama"),
|
||||
("PG", "Papua New Guinea"),
|
||||
("PY", "Paraguay"),
|
||||
("PE", "Peru"),
|
||||
("PH", "Philippines"),
|
||||
("PN", "Pitcairn"),
|
||||
("PL", "Poland"),
|
||||
("PT", "Portugal"),
|
||||
("PR", "Puerto Rico"),
|
||||
("QA", "Qatar"),
|
||||
("RE", "Reunion"),
|
||||
("RO", "Romania"),
|
||||
("RU", "Russian Federation"),
|
||||
("RW", "Rwanda"),
|
||||
("BL", "Saint Barthelemy"),
|
||||
("SH", "Saint Helena"),
|
||||
("KN", "Saint Kitts and Nevis"),
|
||||
("LC", "Saint Lucia"),
|
||||
("MF", "Saint Martin"),
|
||||
("PM", "Saint Pierre and Miquelon"),
|
||||
("VC", "Saint Vincent and the Grenadines"),
|
||||
("WS", "Samoa"),
|
||||
("SM", "San Marino"),
|
||||
("ST", "Sao Tome and Principe"),
|
||||
("SA", "Saudi Arabia"),
|
||||
("SN", "Senegal"),
|
||||
("RS", "Serbia"),
|
||||
("SC", "Seychelles"),
|
||||
("SL", "Sierra Leone"),
|
||||
("SG", "Singapore"),
|
||||
("SK", "Slovakia"),
|
||||
("SI", "Slovenia"),
|
||||
("SB", "Solomon Islands"),
|
||||
("SO", "Somalia"),
|
||||
("ZA", "South Africa"),
|
||||
("GS", "South Georgia and the South Sandwich Islands"),
|
||||
("ES", "Spain"),
|
||||
("LK", "Sri Lanka"),
|
||||
("SD", "Sudan"),
|
||||
("SR", "Suriname"),
|
||||
("SJ", "Svalbard and Jan Mayen"),
|
||||
("SZ", "Swaziland"),
|
||||
("SE", "Sweden"),
|
||||
("CH", "Switzerland"),
|
||||
("SY", "Syrian Arab Republic"),
|
||||
("TW", "Taiwan, Province of China"),
|
||||
("TJ", "Tajikistan"),
|
||||
("TZ", "Tanzania, United Republic of"),
|
||||
("TH", "Thailand"),
|
||||
("TL", "Timor-Leste"),
|
||||
("TG", "Togo"),
|
||||
("TK", "Tokelau"),
|
||||
("TO", "Tonga"),
|
||||
("TT", "Trinidad and Tobago"),
|
||||
("TN", "Tunisia"),
|
||||
("TR", "Turkey"),
|
||||
("TM", "Turkmenistan"),
|
||||
("TC", "Turks and Caicos Islands"),
|
||||
("TV", "Tuvalu"),
|
||||
("UG", "Uganda"),
|
||||
("UA", "Ukraine"),
|
||||
("AE", "United Arab Emirates"),
|
||||
("US", "United States"),
|
||||
("UM", "United States Minor Outlying Islands"),
|
||||
("UY", "Uruguay"),
|
||||
("UZ", "Uzbekistan"),
|
||||
("VU", "Vanuatu"),
|
||||
("VE", "Venezuela"),
|
||||
("VN", "Viet Nam"),
|
||||
("VG", "Virgin Islands, British"),
|
||||
("VI", "Virgin Islands, U.S."),
|
||||
("WF", "Wallis and Futuna"),
|
||||
("EH", "Western Sahara"),
|
||||
("YE", "Yemen"),
|
||||
("ZM", "Zambia"),
|
||||
("ZW", "Zimbabwe"),
|
||||
],
|
||||
max_length=3,
|
||||
null=True,
|
||||
verbose_name="Country",
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, null=True, verbose_name="Notes"),
|
||||
),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Account",
|
||||
"verbose_name_plural": "Accounts",
|
||||
"db_table": "accounts",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AccountEmail",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("message_subject", models.TextField(null=True)),
|
||||
("message_body", models.TextField(null=True)),
|
||||
("timezone", models.CharField(default="UTC", max_length=100)),
|
||||
("scheduled_date_time", models.DateTimeField(null=True)),
|
||||
("scheduled_later", models.BooleanField(default=False)),
|
||||
("from_email", models.EmailField(max_length=254)),
|
||||
("rendered_message_body", models.TextField(null=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Account Email",
|
||||
"verbose_name_plural": "Account Emails",
|
||||
"db_table": "account_email",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AccountEmailLog",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("is_sent", models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "EmailLog",
|
||||
"verbose_name_plural": "EmailLogs",
|
||||
"db_table": "emailLogs",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
202
backend/accounts/migrations/0002_initial.py
Normal file
202
backend/accounts/migrations/0002_initial.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# Generated by Django 5.2.8 on 2025-11-27 12:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
("common", "0001_initial"),
|
||||
("contacts", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="assigned_to",
|
||||
field=models.ManyToManyField(
|
||||
related_name="account_assigned_users", to="common.profile"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="contacts",
|
||||
field=models.ManyToManyField(
|
||||
related_name="account_contacts", to="contacts.contact"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="created_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="org",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accounts",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="tags",
|
||||
field=models.ManyToManyField(blank=True, to="common.tags"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="teams",
|
||||
field=models.ManyToManyField(
|
||||
related_name="account_teams", to="common.teams"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="updated_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemail",
|
||||
name="created_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemail",
|
||||
name="from_account",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sent_email",
|
||||
to="accounts.account",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemail",
|
||||
name="org",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="account_emails",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemail",
|
||||
name="recipients",
|
||||
field=models.ManyToManyField(
|
||||
related_name="recieved_email", to="contacts.contact"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemail",
|
||||
name="updated_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemaillog",
|
||||
name="contact",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="contact_email_log",
|
||||
to="contacts.contact",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemaillog",
|
||||
name="created_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemaillog",
|
||||
name="email",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="email_log",
|
||||
to="accounts.accountemail",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemaillog",
|
||||
name="org",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="account_email_logs",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="accountemaillog",
|
||||
name="updated_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="account",
|
||||
index=models.Index(fields=["name"], name="accounts_name_94ecef_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="account",
|
||||
index=models.Index(fields=["industry"], name="accounts_industr_6498ca_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="account",
|
||||
index=models.Index(
|
||||
fields=["org", "-created_at"], name="accounts_org_id_6f0889_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="accountemail",
|
||||
index=models.Index(
|
||||
fields=["org", "-created_at"], name="account_ema_org_id_3bbcd2_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="accountemaillog",
|
||||
index=models.Index(
|
||||
fields=["org", "-created_at"], name="emailLogs_org_id_84814f_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
190
backend/accounts/models.py
Normal file
190
backend/accounts/models.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import arrow
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from common.base import AssignableMixin, BaseModel
|
||||
from common.models import Org, Profile, Tags, Teams
|
||||
from common.utils import COUNTRIES, INDCHOICES
|
||||
from contacts.models import Contact
|
||||
|
||||
|
||||
class Account(AssignableMixin, BaseModel):
|
||||
"""
|
||||
Account model for CRM - Streamlined for modern sales workflow
|
||||
Based on Twenty CRM and Salesforce patterns
|
||||
"""
|
||||
|
||||
# Core Account Information
|
||||
name = models.CharField(_("Account Name"), max_length=255)
|
||||
email = models.EmailField(_("Email"), blank=True, null=True)
|
||||
phone = PhoneNumberField(_("Phone"), null=True, blank=True)
|
||||
website = models.URLField(_("Website"), blank=True, null=True)
|
||||
|
||||
# Business Information
|
||||
industry = models.CharField(
|
||||
_("Industry"), max_length=255, choices=INDCHOICES, blank=True, null=True
|
||||
)
|
||||
number_of_employees = models.PositiveIntegerField(
|
||||
_("Number of Employees"), blank=True, null=True
|
||||
)
|
||||
annual_revenue = models.DecimalField(
|
||||
_("Annual Revenue"), max_digits=15, decimal_places=2, blank=True, null=True
|
||||
)
|
||||
|
||||
# Address (flat fields like Lead and Contact models)
|
||||
address_line = models.CharField(_("Address"), max_length=255, blank=True, null=True)
|
||||
city = models.CharField(_("City"), max_length=255, blank=True, null=True)
|
||||
state = models.CharField(_("State"), max_length=255, blank=True, null=True)
|
||||
postcode = models.CharField(_("Postal Code"), max_length=64, blank=True, null=True)
|
||||
country = models.CharField(
|
||||
_("Country"), max_length=3, choices=COUNTRIES, blank=True, null=True
|
||||
)
|
||||
|
||||
# Assignment
|
||||
assigned_to = models.ManyToManyField(Profile, related_name="account_assigned_users")
|
||||
teams = models.ManyToManyField(Teams, related_name="account_teams")
|
||||
contacts = models.ManyToManyField(
|
||||
"contacts.Contact", related_name="account_contacts"
|
||||
)
|
||||
|
||||
# Tags
|
||||
tags = models.ManyToManyField(Tags, blank=True)
|
||||
|
||||
# Notes
|
||||
description = models.TextField(_("Notes"), blank=True, null=True)
|
||||
|
||||
# System Fields
|
||||
is_active = models.BooleanField(default=True)
|
||||
org = models.ForeignKey(
|
||||
Org,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="accounts",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Account"
|
||||
verbose_name_plural = "Accounts"
|
||||
db_table = "accounts"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["name"]),
|
||||
models.Index(fields=["industry"]),
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
def get_complete_address(self):
|
||||
"""Concatenates complete address."""
|
||||
address_parts = [
|
||||
self.address_line,
|
||||
self.city,
|
||||
self.state,
|
||||
self.postcode,
|
||||
self.get_country_display() if self.country else None,
|
||||
]
|
||||
return ", ".join(part for part in address_parts if part)
|
||||
|
||||
@property
|
||||
def created_on_arrow(self):
|
||||
return arrow.get(self.created_at).humanize()
|
||||
|
||||
@property
|
||||
def contact_values(self):
|
||||
contacts = list(self.contacts.values_list("id", flat=True))
|
||||
return ",".join(str(contact) for contact in contacts)
|
||||
|
||||
|
||||
class AccountEmail(BaseModel):
|
||||
from_account = models.ForeignKey(
|
||||
Account, related_name="sent_email", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
recipients = models.ManyToManyField(Contact, related_name="recieved_email")
|
||||
message_subject = models.TextField(null=True)
|
||||
message_body = models.TextField(null=True)
|
||||
timezone = models.CharField(max_length=100, default="UTC")
|
||||
scheduled_date_time = models.DateTimeField(null=True)
|
||||
scheduled_later = models.BooleanField(default=False)
|
||||
from_email = models.EmailField()
|
||||
rendered_message_body = models.TextField(null=True)
|
||||
org = models.ForeignKey(
|
||||
Org,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="account_emails",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Account Email"
|
||||
verbose_name_plural = "Account Emails"
|
||||
db_table = "account_email"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.message_subject}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Ensure org follows the parent account; fallback to the first recipient's org.
|
||||
|
||||
This guards against missing org assignment, which would violate multi-tenant isolation.
|
||||
"""
|
||||
if not self.org_id:
|
||||
if self.from_account and self.from_account.org_id:
|
||||
self.org_id = self.from_account.org_id
|
||||
else:
|
||||
# For unsaved m2m, recipients may not be available until after save
|
||||
recipient = self.recipients.first() if self.pk else None
|
||||
if recipient and recipient.org_id:
|
||||
self.org_id = recipient.org_id
|
||||
if not self.org_id:
|
||||
raise ValidationError("Organization is required for AccountEmail")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class AccountEmailLog(BaseModel):
|
||||
"""this model is used to track if the email is sent or not"""
|
||||
|
||||
email = models.ForeignKey(
|
||||
AccountEmail, related_name="email_log", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
Contact, related_name="contact_email_log", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
is_sent = models.BooleanField(default=False)
|
||||
org = models.ForeignKey(
|
||||
Org,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="account_email_logs",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "EmailLog"
|
||||
verbose_name_plural = "EmailLogs"
|
||||
db_table = "emailLogs"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email.message_subject}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Align log org with parent email (preferred) or contact org.
|
||||
"""
|
||||
if not self.org_id:
|
||||
if self.email and self.email.org_id:
|
||||
self.org_id = self.email.org_id
|
||||
elif self.contact and self.contact.org_id:
|
||||
self.org_id = self.contact.org_id
|
||||
if not self.org_id:
|
||||
raise ValidationError("Organization is required for AccountEmailLog")
|
||||
super().save(*args, **kwargs)
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from accounts.models import Account, AccountEmail, Tags, AccountEmailLog
|
||||
from accounts.models import Account, AccountEmail, AccountEmailLog
|
||||
from common.models import Tags
|
||||
from common.serializer import (
|
||||
AttachmentsSerializer,
|
||||
OrganizationSerializer,
|
||||
ProfileSerializer,
|
||||
UserSerializer
|
||||
TeamsSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from contacts.serializer import ContactSerializer
|
||||
from leads.serializer import LeadSerializer
|
||||
from teams.serializer import TeamsSerializer
|
||||
|
||||
|
||||
class TagsSerailizer(serializers.ModelSerializer):
|
||||
|
|
@ -19,44 +19,61 @@ class TagsSerailizer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class AccountSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for reading Account data"""
|
||||
|
||||
created_by = UserSerializer()
|
||||
lead = LeadSerializer()
|
||||
org = OrganizationSerializer()
|
||||
tags = TagsSerailizer(read_only=True, many=True)
|
||||
assigned_to = ProfileSerializer(read_only=True, many=True)
|
||||
contacts = ContactSerializer(read_only=True, many=True)
|
||||
teams = TeamsSerializer(read_only=True, many=True)
|
||||
account_attachment = AttachmentsSerializer(read_only=True, many=True)
|
||||
get_team_users = ProfileSerializer(read_only=True, many=True)
|
||||
get_team_and_assigned_users = ProfileSerializer(read_only=True, many=True)
|
||||
get_assigned_users_not_in_teams = ProfileSerializer(read_only=True, many=True)
|
||||
country = serializers.SerializerMethodField()
|
||||
|
||||
def get_country(self, obj):
|
||||
return obj.get_country_display() if obj.country else None
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
# fields = ‘__all__’
|
||||
fields = (
|
||||
"id",
|
||||
# Core Account Information
|
||||
"name",
|
||||
"email",
|
||||
"phone",
|
||||
"industry",
|
||||
"billing_address_line",
|
||||
"billing_street",
|
||||
"billing_city",
|
||||
"billing_state",
|
||||
"billing_postcode",
|
||||
"billing_country",
|
||||
"website",
|
||||
# Business Information
|
||||
"industry",
|
||||
"number_of_employees",
|
||||
"annual_revenue",
|
||||
# Address
|
||||
"address_line",
|
||||
"city",
|
||||
"state",
|
||||
"postcode",
|
||||
"country",
|
||||
# Assignment
|
||||
"assigned_to",
|
||||
"teams",
|
||||
"contacts",
|
||||
# Tags
|
||||
"tags",
|
||||
# Notes
|
||||
"description",
|
||||
# Related
|
||||
"account_attachment",
|
||||
# System
|
||||
"created_by",
|
||||
"created_at",
|
||||
"is_active",
|
||||
"tags",
|
||||
"status",
|
||||
"lead",
|
||||
"contact_name",
|
||||
"contacts",
|
||||
"assigned_to",
|
||||
"teams",
|
||||
"org",
|
||||
"created_on_arrow",
|
||||
"get_team_users",
|
||||
"get_team_and_assigned_users",
|
||||
"get_assigned_users_not_in_teams",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -107,33 +124,44 @@ class AccountReadSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = ["name", "billing_city", "tags"]
|
||||
fields = ["name", "city", "tags"]
|
||||
|
||||
|
||||
class AccountWriteSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = ["name","phone", "email", "billing_address_line","billing_street","billing_city", "billing_state", "billing_postcode","billing_country","contacts", "teams", "assigned_to","tags","account_attachment", "website", "status","lead"]
|
||||
fields = [
|
||||
"name",
|
||||
"phone",
|
||||
"email",
|
||||
"website",
|
||||
"industry",
|
||||
"number_of_employees",
|
||||
"annual_revenue",
|
||||
"address_line",
|
||||
"city",
|
||||
"state",
|
||||
"postcode",
|
||||
"country",
|
||||
"contacts",
|
||||
"teams",
|
||||
"assigned_to",
|
||||
"tags",
|
||||
"account_attachment",
|
||||
"description",
|
||||
]
|
||||
|
||||
|
||||
class AccountCreateSerializer(serializers.ModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
account_view = kwargs.pop("account", False)
|
||||
request_obj = kwargs.pop("request_obj", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["status"].required = False
|
||||
if account_view:
|
||||
self.fields["billing_address_line"].required = True
|
||||
self.fields["billing_street"].required = True
|
||||
self.fields["billing_city"].required = True
|
||||
self.fields["billing_state"].required = True
|
||||
self.fields["billing_postcode"].required = True
|
||||
self.fields["billing_country"].required = True
|
||||
"""Serializer for creating/updating Account data"""
|
||||
|
||||
if self.instance:
|
||||
self.fields["lead"].required = False
|
||||
self.fields["lead"].required = False
|
||||
self.org = request_obj.profile.org
|
||||
def __init__(self, *args, **kwargs):
|
||||
request_obj = kwargs.pop("request_obj", None)
|
||||
kwargs.pop("account", None) # Remove unused 'account' parameter passed by views
|
||||
super().__init__(*args, **kwargs)
|
||||
if request_obj:
|
||||
self.org = request_obj.profile.org
|
||||
|
||||
def validate_name(self, name):
|
||||
if self.instance:
|
||||
|
|
@ -151,31 +179,44 @@ class AccountCreateSerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = Account
|
||||
fields = (
|
||||
# Core Account Information
|
||||
"name",
|
||||
"phone",
|
||||
"email",
|
||||
"phone",
|
||||
"website",
|
||||
# Business Information
|
||||
"industry",
|
||||
"number_of_employees",
|
||||
"annual_revenue",
|
||||
# Address
|
||||
"address_line",
|
||||
"city",
|
||||
"state",
|
||||
"postcode",
|
||||
"country",
|
||||
# Notes
|
||||
"description",
|
||||
"status",
|
||||
"billing_address_line",
|
||||
"billing_street",
|
||||
"billing_city",
|
||||
"billing_state",
|
||||
"billing_postcode",
|
||||
"billing_country",
|
||||
"lead",
|
||||
"contact_name",
|
||||
)
|
||||
|
||||
|
||||
class AccountDetailEditSwaggerSerializer(serializers.Serializer):
|
||||
comment = serializers.CharField()
|
||||
account_attachment = serializers.FileField()
|
||||
|
||||
|
||||
class AccountCommentEditSwaggerSerializer(serializers.Serializer):
|
||||
comment = serializers.CharField()
|
||||
|
||||
|
||||
class EmailWriteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AccountEmail
|
||||
fields = ("from_email", "recipients", "message_subject","scheduled_later","timezone","scheduled_date_time","message_body")
|
||||
fields = (
|
||||
"from_email",
|
||||
"recipients",
|
||||
"message_subject",
|
||||
"scheduled_later",
|
||||
"timezone",
|
||||
"scheduled_date_time",
|
||||
"message_body",
|
||||
)
|
||||
|
|
@ -15,5 +15,3 @@ account_get_params = [
|
|||
OpenApiParameter("city", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("tags", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -26,14 +26,16 @@ def send_email(email_obj_id):
|
|||
).exists():
|
||||
html = email_obj.message_body
|
||||
context_data = {
|
||||
"email": contact_obj.primary_email
|
||||
if contact_obj.primary_email
|
||||
else "",
|
||||
"name": contact_obj.first_name
|
||||
if contact_obj.first_name
|
||||
else "" + " " + contact_obj.last_name
|
||||
if contact_obj.last_name
|
||||
else "",
|
||||
"email": contact_obj.email if contact_obj.email else "",
|
||||
"name": (
|
||||
contact_obj.first_name
|
||||
if contact_obj.first_name
|
||||
else (
|
||||
"" + " " + contact_obj.last_name
|
||||
if contact_obj.last_name
|
||||
else ""
|
||||
)
|
||||
),
|
||||
}
|
||||
try:
|
||||
html_content = Template(html).render(Context(context_data))
|
||||
|
|
@ -43,7 +45,7 @@ def send_email(email_obj_id):
|
|||
html_content,
|
||||
from_email=from_email,
|
||||
to=[
|
||||
contact_obj.primary_email,
|
||||
contact_obj.email,
|
||||
],
|
||||
)
|
||||
msg.content_subtype = "html"
|
||||
|
|
@ -1,43 +1,39 @@
|
|||
import json
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from drf_rw_serializers import generics
|
||||
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.generics import GenericAPIView
|
||||
|
||||
from accounts import swagger_params1
|
||||
from accounts.models import Account, Tags
|
||||
from accounts import swagger_params
|
||||
from accounts.models import Account
|
||||
from accounts.serializer import (
|
||||
AccountCreateSerializer,
|
||||
AccountSerializer,
|
||||
EmailSerializer,
|
||||
TagsSerailizer,
|
||||
AccountReadSerializer,
|
||||
AccountWriteSerializer,
|
||||
AccountDetailEditSwaggerSerializer,
|
||||
AccountCommentEditSwaggerSerializer,
|
||||
EmailWriteSerializer
|
||||
AccountCreateSerializer,
|
||||
AccountDetailEditSwaggerSerializer,
|
||||
AccountReadSerializer,
|
||||
AccountSerializer,
|
||||
AccountWriteSerializer,
|
||||
EmailSerializer,
|
||||
EmailWriteSerializer,
|
||||
TagsSerailizer,
|
||||
)
|
||||
from teams.serializer import TeamsSerializer
|
||||
from accounts.tasks import send_email, send_email_to_assigned_user
|
||||
from cases.serializer import CaseSerializer
|
||||
from common.models import Attachments, Comment, Profile
|
||||
from leads.models import Lead
|
||||
from leads.serializer import LeadSerializer
|
||||
from common.models import Attachments, Comment, Profile, Tags, Teams
|
||||
|
||||
#from common.external_auth import CustomDualAuthentication
|
||||
# from common.external_auth import CustomDualAuthentication
|
||||
from common.serializer import (
|
||||
AttachmentsSerializer,
|
||||
CommentSerializer,
|
||||
ProfileSerializer,
|
||||
TeamsSerializer,
|
||||
)
|
||||
from common.utils import (
|
||||
CASE_TYPE,
|
||||
|
|
@ -50,71 +46,79 @@ from common.utils import (
|
|||
from contacts.models import Contact
|
||||
from contacts.serializer import ContactSerializer
|
||||
from invoices.serializer import InvoiceSerailizer
|
||||
from leads.models import Lead
|
||||
from leads.serializer import LeadSerializer
|
||||
from opportunity.models import SOURCES, STAGES, Opportunity
|
||||
from opportunity.serializer import OpportunitySerializer
|
||||
from tasks.serializer import TaskSerializer
|
||||
from teams.models import Teams
|
||||
|
||||
|
||||
class AccountsListView(APIView, LimitOffsetPagination):
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
model = Account
|
||||
serializer_class = AccountReadSerializer
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
params = self.request.query_params
|
||||
queryset = self.model.objects.filter(org=self.request.profile.org).order_by("-id")
|
||||
queryset = self.model.objects.filter(org=self.request.profile.org).order_by(
|
||||
"-id"
|
||||
)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
queryset = queryset.filter(
|
||||
Q(created_by=self.request.profile.user) | Q(assigned_to=self.request.profile)
|
||||
Q(created_by=self.request.profile.user)
|
||||
| Q(assigned_to=self.request.profile)
|
||||
).distinct()
|
||||
|
||||
if params:
|
||||
if params.get("name"):
|
||||
queryset = queryset.filter(name__icontains=params.get("name"))
|
||||
if params.get("city"):
|
||||
queryset = queryset.filter(billing_city__contains=params.get("city"))
|
||||
queryset = queryset.filter(city__icontains=params.get("city"))
|
||||
if params.get("industry"):
|
||||
queryset = queryset.filter(industry__icontains=params.get("industry"))
|
||||
if params.get("tags"):
|
||||
queryset = queryset.filter(
|
||||
tags__in=params.get("tags")
|
||||
).distinct()
|
||||
queryset = queryset.filter(tags__in=params.get("tags")).distinct()
|
||||
|
||||
context = {}
|
||||
queryset_open = queryset.filter(status="open")
|
||||
results_accounts_open = self.paginate_queryset(
|
||||
queryset_open.distinct(), self.request, view=self
|
||||
|
||||
# Account model no longer has status field, return all accounts
|
||||
# Filter by is_active instead
|
||||
queryset_active = queryset.filter(is_active=True)
|
||||
results_accounts_active = self.paginate_queryset(
|
||||
queryset_active.distinct(), self.request, view=self
|
||||
)
|
||||
if results_accounts_open:
|
||||
offset = queryset_open.filter(id__gte=results_accounts_open[-1].id).count()
|
||||
if offset == queryset_open.count():
|
||||
if results_accounts_active:
|
||||
offset = queryset_active.filter(
|
||||
id__gte=results_accounts_active[-1].id
|
||||
).count()
|
||||
if offset == queryset_active.count():
|
||||
offset = None
|
||||
else:
|
||||
offset = 0
|
||||
accounts_open = AccountSerializer(results_accounts_open, many=True).data
|
||||
accounts_active = AccountSerializer(results_accounts_active, many=True).data
|
||||
context["per_page"] = 10
|
||||
page_number = (int(self.offset / 10) + 1,)
|
||||
context["page_number"] = page_number
|
||||
context["active_accounts"] = {
|
||||
"offset": offset,
|
||||
"open_accounts": accounts_open,
|
||||
"open_accounts": accounts_active,
|
||||
}
|
||||
|
||||
queryset_close = queryset.filter(status="close")
|
||||
results_accounts_close = self.paginate_queryset(
|
||||
queryset_close.distinct(), self.request, view=self
|
||||
# Inactive accounts
|
||||
queryset_inactive = queryset.filter(is_active=False)
|
||||
results_accounts_inactive = self.paginate_queryset(
|
||||
queryset_inactive.distinct(), self.request, view=self
|
||||
)
|
||||
if results_accounts_close:
|
||||
offset = queryset_close.filter(
|
||||
id__gte=results_accounts_close[-1].id
|
||||
if results_accounts_inactive:
|
||||
offset = queryset_inactive.filter(
|
||||
id__gte=results_accounts_inactive[-1].id
|
||||
).count()
|
||||
if offset == queryset_close.count():
|
||||
if offset == queryset_inactive.count():
|
||||
offset = None
|
||||
else:
|
||||
offset = 0
|
||||
accounts_close = AccountSerializer(results_accounts_close, many=True).data
|
||||
accounts_inactive = AccountSerializer(results_accounts_inactive, many=True).data
|
||||
|
||||
contacts = Contact.objects.filter(org=self.request.profile.org).values(
|
||||
"id", "first_name"
|
||||
|
|
@ -122,7 +126,7 @@ class AccountsListView(APIView, LimitOffsetPagination):
|
|||
context["contacts"] = contacts
|
||||
context["closed_accounts"] = {
|
||||
"offset": offset,
|
||||
"close_accounts": accounts_close,
|
||||
"close_accounts": accounts_inactive,
|
||||
}
|
||||
context["teams"] = TeamsSerializer(
|
||||
Teams.objects.filter(org=self.request.profile.org), many=True
|
||||
|
|
@ -130,28 +134,32 @@ class AccountsListView(APIView, LimitOffsetPagination):
|
|||
context["countries"] = COUNTRIES
|
||||
context["industries"] = INDCHOICES
|
||||
|
||||
tags = Tags.objects.all()
|
||||
tags = Tags.objects.filter(org=self.request.profile.org)
|
||||
tags = TagsSerailizer(tags, many=True).data
|
||||
|
||||
context["tags"] = tags
|
||||
users = Profile.objects.filter(is_active=True, org=self.request.profile.org).values(
|
||||
"id", "user__email"
|
||||
)
|
||||
users = Profile.objects.filter(
|
||||
is_active=True, org=self.request.profile.org
|
||||
).values("id", "user__email")
|
||||
context["users"] = users
|
||||
leads = Lead.objects.filter(org=self.request.profile.org).exclude(
|
||||
Q(status="converted") | Q(status="closed")
|
||||
)
|
||||
context["users"] = users
|
||||
context["leads"] = LeadSerializer(leads, many=True).data
|
||||
context["status"] = ["open","close"]
|
||||
context["status"] = ["active", "inactive"] # Maps to is_active field
|
||||
return context
|
||||
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params1.account_get_params)
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params.account_get_params)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
return Response(context)
|
||||
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params1.organization_params,request=AccountWriteSerializer)
|
||||
@extend_schema(
|
||||
tags=["Accounts"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=AccountWriteSerializer,
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
data = request.data
|
||||
serializer = AccountCreateSerializer(
|
||||
|
|
@ -159,12 +167,12 @@ class AccountsListView(APIView, LimitOffsetPagination):
|
|||
)
|
||||
# Save Account
|
||||
if serializer.is_valid():
|
||||
account_object = serializer.save(
|
||||
org=request.profile.org
|
||||
)
|
||||
account_object = serializer.save(org=request.profile.org)
|
||||
if data.get("contacts"):
|
||||
contacts_list = json.loads(data.get("contacts"))
|
||||
contacts = Contact.objects.filter(id__in=contacts_list, org=request.profile.org)
|
||||
contacts = Contact.objects.filter(
|
||||
id__in=contacts_list, org=request.profile.org
|
||||
)
|
||||
if contacts:
|
||||
account_object.contacts.add(*contacts)
|
||||
if data.get("tags"):
|
||||
|
|
@ -215,14 +223,18 @@ class AccountsListView(APIView, LimitOffsetPagination):
|
|||
|
||||
|
||||
class AccountDetailView(APIView):
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = AccountReadSerializer
|
||||
|
||||
def get_object(self, pk):
|
||||
return get_object_or_404(Account, id=pk)
|
||||
return get_object_or_404(Account, id=pk, org=self.request.profile.org)
|
||||
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params1.organization_params,request=AccountWriteSerializer)
|
||||
@extend_schema(
|
||||
tags=["Accounts"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=AccountWriteSerializer,
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
data = request.data
|
||||
account_object = self.get_object(pk=pk)
|
||||
|
|
@ -259,7 +271,9 @@ class AccountDetailView(APIView):
|
|||
account_object.contacts.clear()
|
||||
if data.get("contacts"):
|
||||
contacts_list = json.loads(data.get("contacts"))
|
||||
contacts = Contact.objects.filter(id__in=contacts_list, org=request.profile.org)
|
||||
contacts = Contact.objects.filter(
|
||||
id__in=contacts_list, org=request.profile.org
|
||||
)
|
||||
if contacts:
|
||||
account_object.contacts.add(*contacts)
|
||||
|
||||
|
|
@ -315,7 +329,7 @@ class AccountDetailView(APIView):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params1.organization_params)
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
if self.object.org != request.profile.org:
|
||||
|
|
@ -338,7 +352,7 @@ class AccountDetailView(APIView):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params1.organization_params)
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params.organization_params)
|
||||
def get(self, request, pk, format=None):
|
||||
self.account = self.get_object(pk=pk)
|
||||
if self.account.org != request.profile.org:
|
||||
|
|
@ -371,9 +385,9 @@ class AccountDetailView(APIView):
|
|||
|
||||
if self.request.profile.is_admin or self.request.profile.role == "ADMIN":
|
||||
users_mention = list(
|
||||
Profile.objects.filter(is_active=True, org=self.request.profile.org).values(
|
||||
"user__email"
|
||||
)
|
||||
Profile.objects.filter(
|
||||
is_active=True, org=self.request.profile.org
|
||||
).values("user__email")
|
||||
)
|
||||
elif self.request.profile != self.account.created_by:
|
||||
if self.account.created_by:
|
||||
|
|
@ -385,14 +399,21 @@ class AccountDetailView(APIView):
|
|||
leads = Lead.objects.filter(org=self.request.profile.org).exclude(
|
||||
Q(status="converted") | Q(status="closed")
|
||||
)
|
||||
account_content_type = ContentType.objects.get_for_model(Account)
|
||||
comments = Comment.objects.filter(
|
||||
content_type=account_content_type,
|
||||
object_id=self.account.id,
|
||||
org=self.request.profile.org,
|
||||
).order_by("-id")
|
||||
attachments = Attachments.objects.filter(
|
||||
content_type=account_content_type,
|
||||
object_id=self.account.id,
|
||||
org=self.request.profile.org,
|
||||
).order_by("-id")
|
||||
context.update(
|
||||
{
|
||||
"attachments": AttachmentsSerializer(
|
||||
self.account.account_attachment.all(), many=True
|
||||
).data,
|
||||
"comments": CommentSerializer(
|
||||
self.account.accounts_comments.all(), many=True
|
||||
).data,
|
||||
"attachments": AttachmentsSerializer(attachments, many=True).data,
|
||||
"comments": CommentSerializer(comments, many=True).data,
|
||||
"contacts": ContactSerializer(
|
||||
self.account.contacts.all(), many=True
|
||||
).data,
|
||||
|
|
@ -408,7 +429,7 @@ class AccountDetailView(APIView):
|
|||
"cases": CaseSerializer(
|
||||
self.account.accounts_cases.all(), many=True
|
||||
).data,
|
||||
"teams" : TeamsSerializer(
|
||||
"teams": TeamsSerializer(
|
||||
Teams.objects.filter(org=self.request.profile.org), many=True
|
||||
).data,
|
||||
"stages": STAGES,
|
||||
|
|
@ -429,19 +450,21 @@ class AccountDetailView(APIView):
|
|||
self.account.sent_email.all(), many=True
|
||||
).data,
|
||||
"users_mention": users_mention,
|
||||
"leads" : LeadSerializer(leads, many=True).data,
|
||||
"status" : ["open","close"]
|
||||
"leads": LeadSerializer(leads, many=True).data,
|
||||
"status": ["open", "close"],
|
||||
}
|
||||
)
|
||||
return Response(context)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Accounts"], parameters=swagger_params1.organization_params,request=AccountDetailEditSwaggerSerializer
|
||||
tags=["Accounts"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=AccountDetailEditSwaggerSerializer,
|
||||
)
|
||||
def post(self, request, pk, **kwargs):
|
||||
data = request.data
|
||||
context = {}
|
||||
self.account_obj = Account.objects.get(pk=pk)
|
||||
self.account_obj = Account.objects.get(pk=pk, org=request.profile.org)
|
||||
if self.account_obj.org != request.profile.org:
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -478,11 +501,16 @@ class AccountDetailView(APIView):
|
|||
attachment.attachment = self.request.FILES.get("account_attachment")
|
||||
attachment.save()
|
||||
|
||||
comments = Comment.objects.filter(account__id=self.account_obj.id).order_by(
|
||||
"-id"
|
||||
)
|
||||
account_content_type = ContentType.objects.get_for_model(Account)
|
||||
comments = Comment.objects.filter(
|
||||
content_type=account_content_type,
|
||||
object_id=self.account_obj.id,
|
||||
org=request.profile.org,
|
||||
).order_by("-id")
|
||||
attachments = Attachments.objects.filter(
|
||||
account__id=self.account_obj.id
|
||||
content_type=account_content_type,
|
||||
object_id=self.account_obj.id,
|
||||
org=request.profile.org,
|
||||
).order_by("-id")
|
||||
context.update(
|
||||
{
|
||||
|
|
@ -496,15 +524,17 @@ class AccountDetailView(APIView):
|
|||
|
||||
class AccountCommentView(APIView):
|
||||
model = Comment
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = AccountCommentEditSwaggerSerializer
|
||||
|
||||
def get_object(self, pk):
|
||||
return self.model.objects.get(pk=pk)
|
||||
return self.model.objects.get(pk=pk, org=self.request.profile.org)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Accounts"], parameters=swagger_params1.organization_params,request=AccountCommentEditSwaggerSerializer
|
||||
tags=["Accounts"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=AccountCommentEditSwaggerSerializer,
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
data = request.data
|
||||
|
|
@ -534,7 +564,7 @@ class AccountCommentView(APIView):
|
|||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params1.organization_params)
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
if (
|
||||
|
|
@ -558,11 +588,11 @@ class AccountCommentView(APIView):
|
|||
|
||||
class AccountAttachmentView(APIView):
|
||||
model = Attachments
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = AccountDetailEditSwaggerSerializer
|
||||
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params1.organization_params)
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.model.objects.get(pk=pk)
|
||||
if (
|
||||
|
|
@ -585,12 +615,16 @@ class AccountAttachmentView(APIView):
|
|||
|
||||
|
||||
class AccountCreateMailView(APIView):
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
model = Account
|
||||
serializer_class = EmailWriteSerializer
|
||||
|
||||
@extend_schema(tags=["Accounts"], parameters=swagger_params1.organization_params,request=EmailWriteSerializer)
|
||||
@extend_schema(
|
||||
tags=["Accounts"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=EmailWriteSerializer,
|
||||
)
|
||||
def post(self, request, pk, *args, **kwargs):
|
||||
data = request.data
|
||||
scheduled_later = data.get("scheduled_later")
|
||||
|
|
@ -614,7 +648,9 @@ class AccountCreateMailView(APIView):
|
|||
if data.get("recipients"):
|
||||
contacts = json.loads(data.get("recipients"))
|
||||
for contact in contacts:
|
||||
obj_contact = Contact.objects.filter(id=contact, org=request.profile.org)
|
||||
obj_contact = Contact.objects.filter(
|
||||
id=contact, org=request.profile.org
|
||||
)
|
||||
if obj_contact.exists():
|
||||
email_obj.recipients.add(contact)
|
||||
else:
|
||||
151
backend/cases/migrations/0001_initial.py
Normal file
151
backend/cases/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# Generated by Django 5.2.8 on 2025-11-27 12:17
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Solution",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("description", models.TextField()),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("draft", "Draft"),
|
||||
("reviewed", "Reviewed"),
|
||||
("approved", "Approved"),
|
||||
],
|
||||
default="draft",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("is_published", models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Solution",
|
||||
"verbose_name_plural": "Solutions",
|
||||
"db_table": "solution",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Case",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=64, verbose_name="Name")),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("New", "New"),
|
||||
("Assigned", "Assigned"),
|
||||
("Pending", "Pending"),
|
||||
("Closed", "Closed"),
|
||||
("Rejected", "Rejected"),
|
||||
("Duplicate", "Duplicate"),
|
||||
],
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
(
|
||||
"priority",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Low", "Low"),
|
||||
("Normal", "Normal"),
|
||||
("High", "High"),
|
||||
("Urgent", "Urgent"),
|
||||
],
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
(
|
||||
"case_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("Question", "Question"),
|
||||
("Incident", "Incident"),
|
||||
("Problem", "Problem"),
|
||||
],
|
||||
default="",
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("closed_on", models.DateField(blank=True, null=True)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
(
|
||||
"account",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accounts_cases",
|
||||
to="accounts.account",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Case",
|
||||
"verbose_name_plural": "Cases",
|
||||
"db_table": "case",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
134
backend/cases/migrations/0002_initial.py
Normal file
134
backend/cases/migrations/0002_initial.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Generated by Django 5.2.8 on 2025-11-27 12:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("cases", "0001_initial"),
|
||||
("common", "0001_initial"),
|
||||
("contacts", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="case",
|
||||
name="assigned_to",
|
||||
field=models.ManyToManyField(
|
||||
related_name="case_assigned_users", to="common.profile"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="case",
|
||||
name="contacts",
|
||||
field=models.ManyToManyField(to="contacts.contact"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="case",
|
||||
name="created_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="case",
|
||||
name="org",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="cases",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="case",
|
||||
name="teams",
|
||||
field=models.ManyToManyField(related_name="cases_teams", to="common.teams"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="case",
|
||||
name="updated_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="solution",
|
||||
name="cases",
|
||||
field=models.ManyToManyField(
|
||||
blank=True, related_name="solutions", to="cases.case"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="solution",
|
||||
name="created_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="solution",
|
||||
name="org",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="solutions",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="solution",
|
||||
name="updated_by",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="case",
|
||||
index=models.Index(fields=["status"], name="case_status_deb121_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="case",
|
||||
index=models.Index(fields=["priority"], name="case_priorit_bccbdf_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="case",
|
||||
index=models.Index(
|
||||
fields=["org", "-created_at"], name="case_org_id_9c4eb6_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="solution",
|
||||
index=models.Index(fields=["status"], name="solution_status_c9b160_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="solution",
|
||||
index=models.Index(
|
||||
fields=["is_published"], name="solution_is_publ_2f7331_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="solution",
|
||||
index=models.Index(fields=["org"], name="solution_org_id_577c5c_idx"),
|
||||
),
|
||||
]
|
||||
110
backend/cases/models.py
Normal file
110
backend/cases/models.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import arrow
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from accounts.models import Account
|
||||
from common.base import AssignableMixin, BaseModel
|
||||
from common.models import Org, Profile, Teams
|
||||
from common.utils import CASE_TYPE, PRIORITY_CHOICE, STATUS_CHOICE
|
||||
from contacts.models import Contact
|
||||
|
||||
|
||||
class Case(AssignableMixin, BaseModel):
|
||||
name = models.CharField(pgettext_lazy("Name of the case", "Name"), max_length=64)
|
||||
status = models.CharField(choices=STATUS_CHOICE, max_length=64)
|
||||
priority = models.CharField(choices=PRIORITY_CHOICE, max_length=64)
|
||||
case_type = models.CharField(
|
||||
choices=CASE_TYPE, max_length=255, blank=True, null=True, default=""
|
||||
)
|
||||
account = models.ForeignKey(
|
||||
Account,
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="accounts_cases",
|
||||
)
|
||||
contacts = models.ManyToManyField(Contact)
|
||||
# closed_on = models.DateTimeField()
|
||||
closed_on = models.DateField(blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
assigned_to = models.ManyToManyField(Profile, related_name="case_assigned_users")
|
||||
# created_by = models.ForeignKey(
|
||||
# Profile, related_name="case_created_by", on_delete=models.SET_NULL, null=True
|
||||
# )
|
||||
is_active = models.BooleanField(default=True)
|
||||
teams = models.ManyToManyField(Teams, related_name="cases_teams")
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="cases")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Case"
|
||||
verbose_name_plural = "Cases"
|
||||
db_table = "case"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["status"]),
|
||||
models.Index(fields=["priority"]),
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
@property
|
||||
def created_on_arrow(self):
|
||||
return arrow.get(self.created_at).humanize()
|
||||
|
||||
|
||||
class Solution(BaseModel):
|
||||
"""
|
||||
Knowledge Base Solution
|
||||
|
||||
Solutions are reusable answers/guides that can be linked to cases.
|
||||
They form a knowledge base for common issues and their resolutions.
|
||||
"""
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField()
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("draft", "Draft"),
|
||||
("reviewed", "Reviewed"),
|
||||
("approved", "Approved"),
|
||||
]
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="draft")
|
||||
is_published = models.BooleanField(default=False)
|
||||
|
||||
# Organization relation
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="solutions")
|
||||
|
||||
# Cases that use this solution
|
||||
cases = models.ManyToManyField(Case, related_name="solutions", blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Solution"
|
||||
verbose_name_plural = "Solutions"
|
||||
db_table = "solution"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["status"]),
|
||||
models.Index(fields=["is_published"]),
|
||||
models.Index(fields=["org"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def publish(self):
|
||||
"""Publish the solution (must be approved first)"""
|
||||
if self.status == "approved":
|
||||
self.is_published = True
|
||||
self.save()
|
||||
|
||||
def unpublish(self):
|
||||
"""Unpublish the solution"""
|
||||
self.is_published = False
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def created_on_arrow(self):
|
||||
return arrow.get(self.created_at).humanize()
|
||||
|
|
@ -2,9 +2,13 @@ from rest_framework import serializers
|
|||
|
||||
from accounts.serializer import AccountSerializer
|
||||
from cases.models import Case
|
||||
from common.serializer import OrganizationSerializer, ProfileSerializer,UserSerializer
|
||||
from common.serializer import (
|
||||
OrganizationSerializer,
|
||||
ProfileSerializer,
|
||||
TeamsSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from contacts.serializer import ContactSerializer
|
||||
from teams.serializer import TeamsSerializer
|
||||
|
||||
|
||||
class CaseSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -74,6 +78,7 @@ class CaseCreateSerializer(serializers.ModelSerializer):
|
|||
"created_on_arrow",
|
||||
)
|
||||
|
||||
|
||||
class CaseCreateSwaggerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Case
|
||||
|
|
@ -91,10 +96,11 @@ class CaseCreateSwaggerSerializer(serializers.ModelSerializer):
|
|||
"description",
|
||||
)
|
||||
|
||||
|
||||
class CaseDetailEditSwaggerSerializer(serializers.Serializer):
|
||||
comment = serializers.CharField()
|
||||
case_attachment = serializers.FileField()
|
||||
|
||||
|
||||
class CaseCommentEditSwaggerSerializer(serializers.Serializer):
|
||||
comment = serializers.CharField()
|
||||
|
||||
94
backend/cases/solution_serializers.py
Normal file
94
backend/cases/solution_serializers.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""
|
||||
Solution (Knowledge Base) Serializers
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from cases.models import Case, Solution
|
||||
from common.serializer import OrganizationSerializer
|
||||
|
||||
|
||||
class SolutionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Solution model"""
|
||||
|
||||
org = OrganizationSerializer(read_only=True)
|
||||
case_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Solution
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
"is_published",
|
||||
"org",
|
||||
"case_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"org",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
def get_case_count(self, obj):
|
||||
"""Get number of cases using this solution"""
|
||||
return obj.cases.count()
|
||||
|
||||
|
||||
class SolutionCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating/updating solutions"""
|
||||
|
||||
class Meta:
|
||||
model = Solution
|
||||
fields = [
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
"is_published",
|
||||
]
|
||||
|
||||
def validate_is_published(self, value):
|
||||
"""Only approved solutions can be published"""
|
||||
if value and self.instance:
|
||||
if self.instance.status != "approved":
|
||||
raise serializers.ValidationError(
|
||||
"Only approved solutions can be published. Please set status to 'approved' first."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class SolutionDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed serializer for solution with linked cases"""
|
||||
|
||||
org = OrganizationSerializer(read_only=True)
|
||||
linked_cases = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Solution
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
"is_published",
|
||||
"org",
|
||||
"linked_cases",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
def get_linked_cases(self, obj):
|
||||
"""Get cases that use this solution"""
|
||||
from cases.serializer import CaseSerializer
|
||||
|
||||
return CaseSerializer(obj.cases.all()[:10], many=True).data
|
||||
236
backend/cases/solution_views.py
Normal file
236
backend/cases/solution_views.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""
|
||||
Solution (Knowledge Base) Views
|
||||
"""
|
||||
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from cases.models import Solution
|
||||
from cases.solution_serializers import (
|
||||
SolutionCreateUpdateSerializer,
|
||||
SolutionDetailSerializer,
|
||||
SolutionSerializer,
|
||||
)
|
||||
|
||||
|
||||
class SolutionListView(APIView, LimitOffsetPagination):
|
||||
"""
|
||||
List and create solutions (Knowledge Base)
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Solutions (Knowledge Base)"],
|
||||
description="List all solutions with filters",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
"status", str, description="Filter by status (draft/reviewed/approved)"
|
||||
),
|
||||
OpenApiParameter(
|
||||
"is_published", bool, description="Filter by published status"
|
||||
),
|
||||
OpenApiParameter(
|
||||
"search", str, description="Search in title and description"
|
||||
),
|
||||
],
|
||||
responses={200: SolutionSerializer(many=True)},
|
||||
)
|
||||
def get(self, request):
|
||||
"""List solutions with filters"""
|
||||
queryset = Solution.objects.filter(org=request.profile.org).order_by(
|
||||
"-created_at"
|
||||
)
|
||||
|
||||
# Filters
|
||||
status_filter = request.query_params.get("status")
|
||||
is_published = request.query_params.get("is_published")
|
||||
search = request.query_params.get("search")
|
||||
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
|
||||
if is_published is not None:
|
||||
queryset = queryset.filter(is_published=is_published.lower() == "true")
|
||||
|
||||
if search:
|
||||
queryset = queryset.filter(title__icontains=search) | queryset.filter(
|
||||
description__icontains=search
|
||||
)
|
||||
|
||||
# Paginate
|
||||
results = self.paginate_queryset(queryset, request, view=self)
|
||||
serializer = SolutionSerializer(results, many=True)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Solutions (Knowledge Base)"],
|
||||
description="Create a new solution",
|
||||
request=SolutionCreateUpdateSerializer,
|
||||
responses={201: SolutionSerializer},
|
||||
)
|
||||
def post(self, request):
|
||||
"""Create a new solution"""
|
||||
serializer = SolutionCreateUpdateSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
solution = serializer.save(org=request.profile.org, created_by=request.user)
|
||||
|
||||
response_serializer = SolutionSerializer(solution)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class SolutionDetailView(APIView):
|
||||
"""
|
||||
Get, update, or delete a solution
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Solutions (Knowledge Base)"],
|
||||
description="Get solution details",
|
||||
responses={200: SolutionDetailSerializer},
|
||||
)
|
||||
def get(self, request, pk):
|
||||
"""Get solution details"""
|
||||
try:
|
||||
solution = Solution.objects.get(pk=pk, org=request.profile.org)
|
||||
serializer = SolutionDetailSerializer(solution)
|
||||
return Response(serializer.data)
|
||||
except Solution.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Solution not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Solutions (Knowledge Base)"],
|
||||
description="Update solution",
|
||||
request=SolutionCreateUpdateSerializer,
|
||||
responses={200: SolutionSerializer},
|
||||
)
|
||||
def put(self, request, pk):
|
||||
"""Update solution"""
|
||||
try:
|
||||
solution = Solution.objects.get(pk=pk, org=request.profile.org)
|
||||
|
||||
serializer = SolutionCreateUpdateSerializer(solution, data=request.data)
|
||||
if serializer.is_valid():
|
||||
solution = serializer.save(updated_by=request.user)
|
||||
response_serializer = SolutionSerializer(solution)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Solution.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Solution not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Solutions (Knowledge Base)"],
|
||||
description="Partially update solution",
|
||||
request=SolutionCreateUpdateSerializer,
|
||||
responses={200: SolutionSerializer},
|
||||
)
|
||||
def patch(self, request, pk):
|
||||
"""Partially update solution"""
|
||||
try:
|
||||
solution = Solution.objects.get(pk=pk, org=request.profile.org)
|
||||
|
||||
serializer = SolutionCreateUpdateSerializer(
|
||||
solution, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
solution = serializer.save(updated_by=request.user)
|
||||
response_serializer = SolutionSerializer(solution)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Solution.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Solution not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Solutions (Knowledge Base)"],
|
||||
description="Delete solution",
|
||||
responses={204: None},
|
||||
)
|
||||
def delete(self, request, pk):
|
||||
"""Delete solution"""
|
||||
try:
|
||||
solution = Solution.objects.get(pk=pk, org=request.profile.org)
|
||||
solution.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Solution.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Solution not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class SolutionPublishView(APIView):
|
||||
"""
|
||||
Publish or unpublish a solution
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Solutions (Knowledge Base)"],
|
||||
description="Publish an approved solution",
|
||||
responses={200: SolutionSerializer},
|
||||
)
|
||||
def post(self, request, pk):
|
||||
"""Publish solution"""
|
||||
try:
|
||||
solution = Solution.objects.get(pk=pk, org=request.profile.org)
|
||||
|
||||
if solution.status != "approved":
|
||||
return Response(
|
||||
{
|
||||
"error": "Only approved solutions can be published. Please approve it first."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
solution.publish()
|
||||
serializer = SolutionSerializer(solution)
|
||||
return Response(serializer.data)
|
||||
|
||||
except Solution.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Solution not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class SolutionUnpublishView(APIView):
|
||||
"""
|
||||
Unpublish a solution
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Solutions (Knowledge Base)"],
|
||||
description="Unpublish a solution",
|
||||
responses={200: SolutionSerializer},
|
||||
)
|
||||
def post(self, request, pk):
|
||||
"""Unpublish solution"""
|
||||
try:
|
||||
solution = Solution.objects.get(pk=pk, org=request.profile.org)
|
||||
solution.unpublish()
|
||||
serializer = SolutionSerializer(solution)
|
||||
return Response(serializer.data)
|
||||
|
||||
except Solution.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Solution not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
|
@ -11,8 +11,8 @@ organization_params = [
|
|||
|
||||
cases_list_get_params = [
|
||||
organization_params_in_header,
|
||||
OpenApiParameter("name", OpenApiTypes.STR,OpenApiParameter.QUERY),
|
||||
OpenApiParameter("status", OpenApiTypes.STR,OpenApiParameter.QUERY),
|
||||
OpenApiParameter("priority", OpenApiTypes.STR,OpenApiParameter.QUERY),
|
||||
OpenApiParameter("account", OpenApiTypes.STR,OpenApiParameter.QUERY),
|
||||
OpenApiParameter("name", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("status", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("priority", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("account", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
]
|
||||
32
backend/cases/urls.py
Normal file
32
backend/cases/urls.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from django.urls import path
|
||||
|
||||
from cases import solution_views, views
|
||||
|
||||
app_name = "api_cases"
|
||||
|
||||
urlpatterns = [
|
||||
# Cases endpoints
|
||||
path("", views.CaseListView.as_view()),
|
||||
path("<str:pk>/", views.CaseDetailView.as_view()),
|
||||
path("comment/<str:pk>/", views.CaseCommentView.as_view()),
|
||||
path("attachment/<str:pk>/", views.CaseAttachmentView.as_view()),
|
||||
# Solutions (Knowledge Base) endpoints
|
||||
path(
|
||||
"solutions/", solution_views.SolutionListView.as_view(), name="solutions_list"
|
||||
),
|
||||
path(
|
||||
"solutions/<str:pk>/",
|
||||
solution_views.SolutionDetailView.as_view(),
|
||||
name="solution_detail",
|
||||
),
|
||||
path(
|
||||
"solutions/<str:pk>/publish/",
|
||||
solution_views.SolutionPublishView.as_view(),
|
||||
name="solution_publish",
|
||||
),
|
||||
path(
|
||||
"solutions/<str:pk>/unpublish/",
|
||||
solution_views.SolutionUnpublishView.as_view(),
|
||||
name="solution_unpublish",
|
||||
),
|
||||
]
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
|
|
@ -10,40 +11,50 @@ from rest_framework.views import APIView
|
|||
|
||||
from accounts.models import Account
|
||||
from accounts.serializer import AccountSerializer
|
||||
from cases import swagger_params1
|
||||
from cases import swagger_params
|
||||
from cases.models import Case
|
||||
from cases.serializer import CaseCreateSerializer, CaseSerializer,CaseCreateSwaggerSerializer,CaseDetailEditSwaggerSerializer,CaseCommentEditSwaggerSerializer
|
||||
from cases.serializer import (
|
||||
CaseCommentEditSwaggerSerializer,
|
||||
CaseCreateSerializer,
|
||||
CaseCreateSwaggerSerializer,
|
||||
CaseDetailEditSwaggerSerializer,
|
||||
CaseSerializer,
|
||||
)
|
||||
from cases.tasks import send_email_to_assigned_user
|
||||
from common.models import Attachments, Comment, Profile
|
||||
from common.models import Attachments, Comment, Profile, Teams
|
||||
|
||||
#from common.external_auth import CustomDualAuthentication
|
||||
# from common.external_auth import CustomDualAuthentication
|
||||
from common.serializer import AttachmentsSerializer, CommentSerializer
|
||||
from common.utils import CASE_TYPE, PRIORITY_CHOICE, STATUS_CHOICE
|
||||
from contacts.models import Contact
|
||||
from contacts.serializer import ContactSerializer
|
||||
from teams.models import Teams
|
||||
|
||||
|
||||
class CaseListView(APIView, LimitOffsetPagination):
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
model = Case
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
params = self.request.query_params
|
||||
queryset = self.model.objects.filter(org=self.request.profile.org).order_by("-id")
|
||||
queryset = self.model.objects.filter(org=self.request.profile.org).order_by(
|
||||
"-id"
|
||||
)
|
||||
accounts = Account.objects.filter(org=self.request.profile.org).order_by("-id")
|
||||
contacts = Contact.objects.filter(org=self.request.profile.org).order_by("-id")
|
||||
profiles = Profile.objects.filter(is_active=True, org=self.request.profile.org)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
queryset = queryset.filter(
|
||||
Q(created_by=self.request.profile.user) | Q(assigned_to=self.request.profile)
|
||||
Q(created_by=self.request.profile.user)
|
||||
| Q(assigned_to=self.request.profile)
|
||||
).distinct()
|
||||
accounts = accounts.filter(
|
||||
Q(created_by=self.request.profile.user) | Q(assigned_to=self.request.profile)
|
||||
Q(created_by=self.request.profile.user)
|
||||
| Q(assigned_to=self.request.profile)
|
||||
).distinct()
|
||||
contacts = contacts.filter(
|
||||
Q(created_by=self.request.profile.user) | Q(assigned_to=self.request.profile)
|
||||
Q(created_by=self.request.profile.user)
|
||||
| Q(assigned_to=self.request.profile)
|
||||
).distinct()
|
||||
profiles = profiles.filter(role="ADMIN")
|
||||
|
||||
|
|
@ -82,15 +93,15 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
context["contacts_list"] = ContactSerializer(contacts, many=True).data
|
||||
return context
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.cases_list_get_params
|
||||
)
|
||||
@extend_schema(tags=["Cases"], parameters=swagger_params.cases_list_get_params)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
return Response(context)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.organization_params,request=CaseCreateSwaggerSerializer
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CaseCreateSwaggerSerializer,
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
params = request.data
|
||||
|
|
@ -105,7 +116,9 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
|
||||
if params.get("contacts"):
|
||||
contacts_list = params.get("contacts")
|
||||
contacts = Contact.objects.filter(id__in=contacts_list, org=request.profile.org)
|
||||
contacts = Contact.objects.filter(
|
||||
id__in=contacts_list, org=request.profile.org
|
||||
)
|
||||
if contacts:
|
||||
cases_obj.contacts.add(*contacts)
|
||||
|
||||
|
|
@ -137,7 +150,12 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
cases_obj.id,
|
||||
)
|
||||
return Response(
|
||||
{"error": False, "message": "Case Created Successfully"},
|
||||
{
|
||||
"error": False,
|
||||
"message": "Case Created Successfully",
|
||||
"id": str(cases_obj.id),
|
||||
"cases_obj": CaseSerializer(cases_obj).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
|
@ -148,15 +166,17 @@ class CaseListView(APIView, LimitOffsetPagination):
|
|||
|
||||
|
||||
class CaseDetailView(APIView):
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
model = Case
|
||||
|
||||
def get_object(self, pk):
|
||||
return self.model.objects.filter(id=pk).first()
|
||||
return self.model.objects.filter(id=pk, org=self.request.profile.org).first()
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.organization_params,request=CaseCreateSwaggerSerializer
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CaseCreateSwaggerSerializer,
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
params = request.data
|
||||
|
|
@ -168,7 +188,7 @@ class CaseDetailView(APIView):
|
|||
)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
if not (
|
||||
(self.request.profile == cases_object.created_by)
|
||||
(self.request.profile.user == cases_object.created_by)
|
||||
or (self.request.profile in cases_object.assigned_to.all())
|
||||
):
|
||||
return Response(
|
||||
|
|
@ -195,7 +215,9 @@ class CaseDetailView(APIView):
|
|||
cases_object.contacts.clear()
|
||||
if params.get("contacts"):
|
||||
contacts_list = params.get("contacts")
|
||||
contacts = Contact.objects.filter(id__in=contacts_list, org=request.profile.org)
|
||||
contacts = Contact.objects.filter(
|
||||
id__in=contacts_list, org=request.profile.org
|
||||
)
|
||||
if contacts:
|
||||
cases_object.contacts.add(*contacts)
|
||||
|
||||
|
|
@ -240,9 +262,7 @@ class CaseDetailView(APIView):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.organization_params
|
||||
)
|
||||
@extend_schema(tags=["Cases"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
if self.object.org != request.profile.org:
|
||||
|
|
@ -265,11 +285,14 @@ class CaseDetailView(APIView):
|
|||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.organization_params
|
||||
)
|
||||
@extend_schema(tags=["Cases"], parameters=swagger_params.organization_params)
|
||||
def get(self, request, pk, format=None):
|
||||
self.cases = self.get_object(pk=pk)
|
||||
if not self.cases:
|
||||
return Response(
|
||||
{"error": True, "errors": "Case not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if self.cases.org != request.profile.org:
|
||||
return Response(
|
||||
{"error": True, "errors": "User company doesnot match with header...."},
|
||||
|
|
@ -279,7 +302,7 @@ class CaseDetailView(APIView):
|
|||
context["cases_obj"] = CaseSerializer(self.cases).data
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
if not (
|
||||
(self.request.profile == self.cases.created_by)
|
||||
(self.request.profile.user == self.cases.created_by)
|
||||
or (self.request.profile in self.cases.assigned_to.all())
|
||||
):
|
||||
return Response(
|
||||
|
|
@ -293,7 +316,7 @@ class CaseDetailView(APIView):
|
|||
comment_permission = False
|
||||
|
||||
if (
|
||||
self.request.profile == self.cases.created_by
|
||||
self.request.profile.user == self.cases.created_by
|
||||
or self.request.profile.is_admin
|
||||
or self.request.profile.role == "ADMIN"
|
||||
):
|
||||
|
|
@ -301,9 +324,9 @@ class CaseDetailView(APIView):
|
|||
|
||||
if self.request.profile.is_admin or self.request.profile.role == "ADMIN":
|
||||
users_mention = list(
|
||||
Profile.objects.filter(is_active=True, org=self.request.profile.org).values(
|
||||
"user__email"
|
||||
)
|
||||
Profile.objects.filter(
|
||||
is_active=True, org=self.request.profile.org
|
||||
).values("user__email")
|
||||
)
|
||||
elif self.request.profile != self.cases.created_by:
|
||||
if self.cases.created_by:
|
||||
|
|
@ -313,8 +336,17 @@ class CaseDetailView(APIView):
|
|||
else:
|
||||
users_mention = []
|
||||
|
||||
attachments = Attachments.objects.filter(case=self.cases).order_by("-id")
|
||||
comments = Comment.objects.filter(case=self.cases).order_by("-id")
|
||||
case_content_type = ContentType.objects.get_for_model(Case)
|
||||
attachments = Attachments.objects.filter(
|
||||
content_type=case_content_type,
|
||||
object_id=self.cases.id,
|
||||
org=self.request.profile.org,
|
||||
).order_by("-id")
|
||||
comments = Comment.objects.filter(
|
||||
content_type=case_content_type,
|
||||
object_id=self.cases.id,
|
||||
org=self.request.profile.org,
|
||||
).order_by("-id")
|
||||
|
||||
context.update(
|
||||
{
|
||||
|
|
@ -333,11 +365,13 @@ class CaseDetailView(APIView):
|
|||
return Response(context)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.organization_params,request=CaseDetailEditSwaggerSerializer
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CaseDetailEditSwaggerSerializer,
|
||||
)
|
||||
def post(self, request, pk, **kwargs):
|
||||
params = request.data
|
||||
self.cases_obj = Case.objects.get(pk=pk)
|
||||
self.cases_obj = Case.objects.get(pk=pk, org=request.profile.org)
|
||||
if self.cases_obj.org != request.profile.org:
|
||||
return Response(
|
||||
{"error": True, "errors": "User company doesnot match with header...."},
|
||||
|
|
@ -347,7 +381,7 @@ class CaseDetailView(APIView):
|
|||
comment_serializer = CommentSerializer(data=params)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
if not (
|
||||
(self.request.profile == self.cases_obj.created_by)
|
||||
(self.request.profile.user == self.cases_obj.created_by)
|
||||
or (self.request.profile in self.cases_obj.assigned_to.all())
|
||||
):
|
||||
return Response(
|
||||
|
|
@ -372,8 +406,17 @@ class CaseDetailView(APIView):
|
|||
attachment.attachment = self.request.FILES.get("case_attachment")
|
||||
attachment.save()
|
||||
|
||||
attachments = Attachments.objects.filter(case=self.cases_obj).order_by("-id")
|
||||
comments = Comment.objects.filter(case=self.cases_obj).order_by("-id")
|
||||
case_content_type = ContentType.objects.get_for_model(Case)
|
||||
attachments = Attachments.objects.filter(
|
||||
content_type=case_content_type,
|
||||
object_id=self.cases_obj.id,
|
||||
org=request.profile.org,
|
||||
).order_by("-id")
|
||||
comments = Comment.objects.filter(
|
||||
content_type=case_content_type,
|
||||
object_id=self.cases_obj.id,
|
||||
org=request.profile.org,
|
||||
).order_by("-id")
|
||||
|
||||
context.update(
|
||||
{
|
||||
|
|
@ -387,14 +430,16 @@ class CaseDetailView(APIView):
|
|||
|
||||
class CaseCommentView(APIView):
|
||||
model = Comment
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get_object(self, pk):
|
||||
return self.model.objects.get(pk=pk)
|
||||
return self.model.objects.get(pk=pk, org=self.request.profile.org)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.organization_params,request=CaseCommentEditSwaggerSerializer
|
||||
tags=["Cases"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CaseCommentEditSwaggerSerializer,
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
params = request.data
|
||||
|
|
@ -424,9 +469,7 @@ class CaseCommentView(APIView):
|
|||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.organization_params
|
||||
)
|
||||
@extend_schema(tags=["Cases"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
if (
|
||||
|
|
@ -450,12 +493,10 @@ class CaseCommentView(APIView):
|
|||
|
||||
class CaseAttachmentView(APIView):
|
||||
model = Attachments
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Cases"], parameters=swagger_params1.organization_params
|
||||
)
|
||||
@extend_schema(tags=["Cases"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.model.objects.get(pk=pk)
|
||||
if (
|
||||
53
backend/common/admin.py
Normal file
53
backend/common/admin.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from common.models import Address, Comment, CommentFiles, SessionToken, User
|
||||
|
||||
# Register your models here.
|
||||
|
||||
admin.site.register(User)
|
||||
admin.site.register(Address)
|
||||
admin.site.register(Comment)
|
||||
admin.site.register(CommentFiles)
|
||||
|
||||
|
||||
@admin.register(SessionToken)
|
||||
class SessionTokenAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"user",
|
||||
"token_jti_short",
|
||||
"is_active",
|
||||
"expires_at",
|
||||
"last_used_at",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("is_active", "expires_at", "created_at")
|
||||
search_fields = ("user__email", "token_jti", "ip_address")
|
||||
raw_id_fields = ("user",)
|
||||
readonly_fields = (
|
||||
"token_jti",
|
||||
"refresh_token_jti",
|
||||
"created_at",
|
||||
"last_used_at",
|
||||
"revoked_at",
|
||||
)
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ("-created_at",)
|
||||
actions = ["revoke_tokens", "cleanup_expired"]
|
||||
|
||||
def token_jti_short(self, obj):
|
||||
return f"{obj.token_jti[:16]}..."
|
||||
|
||||
token_jti_short.short_description = "Token JTI"
|
||||
|
||||
def revoke_tokens(self, request, queryset):
|
||||
for token in queryset:
|
||||
token.revoke()
|
||||
self.message_user(request, f"{queryset.count()} tokens revoked successfully.")
|
||||
|
||||
revoke_tokens.short_description = "Revoke selected tokens"
|
||||
|
||||
def cleanup_expired(self, request, queryset):
|
||||
count, _ = SessionToken.cleanup_expired()
|
||||
self.message_user(request, f"{count} expired tokens cleaned up.")
|
||||
|
||||
cleanup_expired.short_description = "Cleanup expired tokens"
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
from django.urls import include, path
|
||||
|
||||
from tasks.urls import board_urlpatterns
|
||||
|
||||
app_name = "common_urls"
|
||||
urlpatterns = [
|
||||
path("", include(("common.urls"))),
|
||||
|
|
@ -7,8 +9,11 @@ urlpatterns = [
|
|||
path("contacts/", include("contacts.urls", namespace="api_contacts")),
|
||||
path("leads/", include("leads.urls", namespace="api_leads")),
|
||||
path("opportunities/", include("opportunity.urls", namespace="api_opportunities")),
|
||||
path("teams/", include("teams.urls", namespace="api_teams")),
|
||||
# Teams URLs are now in common app at /api/teams/
|
||||
path("tasks/", include("tasks.urls", namespace="api_tasks")),
|
||||
path("events/", include("events.urls", namespace="api_events")),
|
||||
path("cases/", include("cases.urls", namespace="api_cases")),
|
||||
path("invoices/", include("invoices.api_urls", namespace="api_invoices")),
|
||||
path(
|
||||
"boards/", include((board_urlpatterns, "api_boards"))
|
||||
), # Kanban Boards (merged into tasks app)
|
||||
]
|
||||
9
backend/common/apps.py
Normal file
9
backend/common/apps.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
name = "common"
|
||||
|
||||
def ready(self):
|
||||
# Import signals to register them
|
||||
import common.signals # noqa: F401
|
||||
307
backend/common/audit_log.py
Normal file
307
backend/common/audit_log.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"""
|
||||
Security Audit Logging for Multi-Tenancy
|
||||
|
||||
Tracks security-relevant events for compliance and incident investigation:
|
||||
- Login attempts (success/failure)
|
||||
- Org switches
|
||||
- Permission denials
|
||||
- API key usage
|
||||
- Suspicious activities
|
||||
|
||||
Usage:
|
||||
from common.audit_log import audit_log
|
||||
|
||||
audit_log.login_success(user, org, request)
|
||||
audit_log.org_switch(user, from_org, to_org, request)
|
||||
audit_log.permission_denied(user, org, action, resource, request)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from common.base import BaseModel
|
||||
|
||||
logger = logging.getLogger("security.audit")
|
||||
|
||||
|
||||
class SecurityAuditLog(BaseModel):
|
||||
"""
|
||||
Model to store security audit events.
|
||||
|
||||
This is separate from Activity model which tracks business operations.
|
||||
SecurityAuditLog tracks authentication and authorization events.
|
||||
"""
|
||||
|
||||
EVENT_TYPES = (
|
||||
("LOGIN_SUCCESS", "Login Success"),
|
||||
("LOGIN_FAILURE", "Login Failure"),
|
||||
("LOGOUT", "Logout"),
|
||||
("ORG_SWITCH", "Organization Switch"),
|
||||
("TOKEN_REFRESH", "Token Refresh"),
|
||||
("TOKEN_REVOKED", "Token Revoked"),
|
||||
("PERMISSION_DENIED", "Permission Denied"),
|
||||
("CROSS_ORG_ATTEMPT", "Cross-Org Access Attempt"),
|
||||
("API_KEY_USED", "API Key Used"),
|
||||
("API_KEY_INVALID", "Invalid API Key"),
|
||||
("MEMBERSHIP_REVOKED", "Membership Revoked"),
|
||||
("SUSPICIOUS_ACTIVITY", "Suspicious Activity"),
|
||||
)
|
||||
|
||||
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
|
||||
user = models.ForeignKey(
|
||||
"common.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="security_audit_logs",
|
||||
)
|
||||
org = models.ForeignKey(
|
||||
"common.Org",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="security_audit_logs",
|
||||
)
|
||||
|
||||
# Event details
|
||||
description = models.TextField(blank=True, default="")
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Request information
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True, default="")
|
||||
request_path = models.CharField(max_length=500, blank=True, default="")
|
||||
request_method = models.CharField(max_length=10, blank=True, default="")
|
||||
|
||||
# Outcome
|
||||
success = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Security Audit Log"
|
||||
verbose_name_plural = "Security Audit Logs"
|
||||
db_table = "security_audit_log"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["event_type", "-created_at"]),
|
||||
models.Index(fields=["user", "-created_at"]),
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
models.Index(fields=["ip_address"]),
|
||||
models.Index(fields=["success", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} - {self.user} - {self.created_at}"
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
"""
|
||||
Helper class for logging security events.
|
||||
|
||||
Logs to both database (SecurityAuditLog) and Python logger.
|
||||
"""
|
||||
|
||||
def _get_request_info(self, request):
|
||||
"""Extract request information for logging."""
|
||||
if not request:
|
||||
return {}
|
||||
|
||||
# Get IP address
|
||||
ip = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if ip:
|
||||
ip = ip.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.META.get("REMOTE_ADDR")
|
||||
|
||||
return {
|
||||
"ip_address": ip,
|
||||
"user_agent": request.META.get("HTTP_USER_AGENT", "")[:500],
|
||||
"request_path": request.path[:500],
|
||||
"request_method": request.method,
|
||||
}
|
||||
|
||||
def _log(
|
||||
self,
|
||||
event_type,
|
||||
user=None,
|
||||
org=None,
|
||||
description="",
|
||||
metadata=None,
|
||||
success=True,
|
||||
request=None,
|
||||
):
|
||||
"""
|
||||
Internal method to create audit log entry.
|
||||
"""
|
||||
request_info = self._get_request_info(request)
|
||||
|
||||
# Log to database
|
||||
try:
|
||||
SecurityAuditLog.objects.create(
|
||||
event_type=event_type,
|
||||
user=user,
|
||||
org=org,
|
||||
description=description,
|
||||
metadata=metadata or {},
|
||||
success=success,
|
||||
**request_info,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create audit log: {e}")
|
||||
|
||||
# Log to Python logger
|
||||
log_level = logging.INFO if success else logging.WARNING
|
||||
user_email = user.email if user else "anonymous"
|
||||
org_name = org.name if org else "none"
|
||||
|
||||
logger.log(
|
||||
log_level,
|
||||
f"{event_type} | user={user_email} | org={org_name} | "
|
||||
f"ip={request_info.get('ip_address')} | {description}",
|
||||
)
|
||||
|
||||
def login_success(self, user, org, request=None):
|
||||
"""Log successful login."""
|
||||
self._log(
|
||||
"LOGIN_SUCCESS",
|
||||
user=user,
|
||||
org=org,
|
||||
description=f"User logged in to {org.name if org else 'platform'}",
|
||||
request=request,
|
||||
)
|
||||
|
||||
def login_failure(self, email, reason, request=None):
|
||||
"""Log failed login attempt."""
|
||||
from common.models import User
|
||||
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
self._log(
|
||||
"LOGIN_FAILURE",
|
||||
user=user,
|
||||
description=f"Login failed for {email}: {reason}",
|
||||
metadata={"email": email, "reason": reason},
|
||||
success=False,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def logout(self, user, org, request=None):
|
||||
"""Log user logout."""
|
||||
self._log(
|
||||
"LOGOUT",
|
||||
user=user,
|
||||
org=org,
|
||||
description=f"User logged out",
|
||||
request=request,
|
||||
)
|
||||
|
||||
def org_switch(self, user, from_org, to_org, request=None):
|
||||
"""Log organization switch."""
|
||||
self._log(
|
||||
"ORG_SWITCH",
|
||||
user=user,
|
||||
org=to_org,
|
||||
description=f"Switched from {from_org.name if from_org else 'none'} to {to_org.name}",
|
||||
metadata={
|
||||
"from_org_id": str(from_org.id) if from_org else None,
|
||||
"to_org_id": str(to_org.id),
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def token_refresh(self, user, org, request=None):
|
||||
"""Log token refresh."""
|
||||
self._log(
|
||||
"TOKEN_REFRESH",
|
||||
user=user,
|
||||
org=org,
|
||||
description="Token refreshed",
|
||||
request=request,
|
||||
)
|
||||
|
||||
def token_revoked(self, user, org, reason, request=None):
|
||||
"""Log token revocation."""
|
||||
self._log(
|
||||
"TOKEN_REVOKED",
|
||||
user=user,
|
||||
org=org,
|
||||
description=f"Token revoked: {reason}",
|
||||
metadata={"reason": reason},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def permission_denied(self, user, org, action, resource, request=None):
|
||||
"""Log permission denial."""
|
||||
self._log(
|
||||
"PERMISSION_DENIED",
|
||||
user=user,
|
||||
org=org,
|
||||
description=f"Permission denied for {action} on {resource}",
|
||||
metadata={"action": action, "resource": resource},
|
||||
success=False,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def cross_org_attempt(self, user, user_org, target_org, resource, request=None):
|
||||
"""Log attempted cross-org data access."""
|
||||
self._log(
|
||||
"CROSS_ORG_ATTEMPT",
|
||||
user=user,
|
||||
org=user_org,
|
||||
description=f"Attempted to access {resource} in org {target_org.name if target_org else 'unknown'}",
|
||||
metadata={
|
||||
"user_org_id": str(user_org.id) if user_org else None,
|
||||
"target_org_id": str(target_org.id) if target_org else None,
|
||||
"resource": resource,
|
||||
},
|
||||
success=False,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def api_key_used(self, org, endpoint, request=None):
|
||||
"""Log API key usage."""
|
||||
self._log(
|
||||
"API_KEY_USED",
|
||||
org=org,
|
||||
description=f"API key used for {endpoint}",
|
||||
metadata={"endpoint": endpoint},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def api_key_invalid(self, api_key_prefix, request=None):
|
||||
"""Log invalid API key attempt."""
|
||||
self._log(
|
||||
"API_KEY_INVALID",
|
||||
description=f"Invalid API key attempted: {api_key_prefix}...",
|
||||
metadata={"api_key_prefix": api_key_prefix},
|
||||
success=False,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def membership_revoked(self, user, org, revoked_by=None, request=None):
|
||||
"""Log when user's org membership is revoked."""
|
||||
self._log(
|
||||
"MEMBERSHIP_REVOKED",
|
||||
user=user,
|
||||
org=org,
|
||||
description=f"Membership revoked by {revoked_by.email if revoked_by else 'system'}",
|
||||
metadata={"revoked_by": str(revoked_by.id) if revoked_by else None},
|
||||
request=request,
|
||||
)
|
||||
|
||||
def suspicious_activity(self, user, org, activity_type, details, request=None):
|
||||
"""Log suspicious activity for security review."""
|
||||
self._log(
|
||||
"SUSPICIOUS_ACTIVITY",
|
||||
user=user,
|
||||
org=org,
|
||||
description=f"Suspicious activity: {activity_type}",
|
||||
metadata={"activity_type": activity_type, "details": details},
|
||||
success=False,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
# Global audit logger instance
|
||||
audit_log = AuditLogger()
|
||||
153
backend/common/base.py
Normal file
153
backend/common/base.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import uuid
|
||||
|
||||
# Third party imports
|
||||
from crum import get_current_user
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from common.mixins import AuditModel
|
||||
|
||||
|
||||
class AssignableMixin(models.Model):
|
||||
"""
|
||||
Mixin for models that have assigned_to (Profile) and teams (Teams) fields.
|
||||
|
||||
Provides helper methods for getting users from teams and assignments.
|
||||
Models using this mixin must define:
|
||||
- assigned_to = ManyToManyField(Profile, ...)
|
||||
- teams = ManyToManyField(Teams, ...)
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def get_team_users(self):
|
||||
"""Get all users who are members of assigned teams."""
|
||||
from common.models import Profile
|
||||
|
||||
team_user_ids = list(self.teams.values_list("users__id", flat=True))
|
||||
return Profile.objects.filter(id__in=team_user_ids)
|
||||
|
||||
@property
|
||||
def get_team_and_assigned_users(self):
|
||||
"""Get all users from both teams and direct assignments."""
|
||||
from common.models import Profile
|
||||
|
||||
team_user_ids = list(self.teams.values_list("users__id", flat=True))
|
||||
assigned_user_ids = list(self.assigned_to.values_list("id", flat=True))
|
||||
user_ids = team_user_ids + assigned_user_ids
|
||||
return Profile.objects.filter(id__in=user_ids)
|
||||
|
||||
@property
|
||||
def get_assigned_users_not_in_teams(self):
|
||||
"""Get users who are directly assigned but not part of any assigned team."""
|
||||
from common.models import Profile
|
||||
|
||||
team_user_ids = list(self.teams.values_list("users__id", flat=True))
|
||||
assigned_user_ids = list(self.assigned_to.values_list("id", flat=True))
|
||||
user_ids = set(assigned_user_ids) - set(team_user_ids)
|
||||
return Profile.objects.filter(id__in=list(user_ids))
|
||||
|
||||
|
||||
class BaseModel(AuditModel):
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
user = get_current_user()
|
||||
if user is None or user.is_anonymous:
|
||||
self.created_by = None
|
||||
self.updated_by = None
|
||||
super(BaseModel, self).save(*args, **kwargs)
|
||||
else:
|
||||
# Check if the model is being created or updated
|
||||
if self._state.adding:
|
||||
# If created only set created_by value: set updated_by to None
|
||||
self.created_by = user
|
||||
self.updated_by = None
|
||||
# If updated only set updated_by value don't touch created_by
|
||||
self.updated_by = user
|
||||
super(BaseModel, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.id)
|
||||
|
||||
|
||||
class OrgScopedQuerySet(models.QuerySet):
|
||||
"""QuerySet with automatic organization filtering support"""
|
||||
|
||||
def for_org(self, org):
|
||||
"""Filter queryset by organization"""
|
||||
return self.filter(org=org)
|
||||
|
||||
def for_request(self, request):
|
||||
"""Filter queryset by organization from request"""
|
||||
if hasattr(request, "profile") and request.profile:
|
||||
return self.filter(org=request.profile.org)
|
||||
return self.none()
|
||||
|
||||
|
||||
class OrgScopedManager(models.Manager):
|
||||
"""Manager that uses OrgScopedQuerySet"""
|
||||
|
||||
def get_queryset(self):
|
||||
return OrgScopedQuerySet(self.model, using=self._db)
|
||||
|
||||
def for_org(self, org):
|
||||
return self.get_queryset().for_org(org)
|
||||
|
||||
def for_request(self, request):
|
||||
return self.get_queryset().for_request(request)
|
||||
|
||||
|
||||
class BaseOrgModel(BaseModel):
|
||||
"""
|
||||
Base model for all organization-scoped entities.
|
||||
|
||||
All tenant-owned models should inherit from this instead of BaseModel.
|
||||
This ensures:
|
||||
- Required org field (no null values)
|
||||
- Per-org indexes for performance
|
||||
- OrgScopedManager for easier filtering
|
||||
|
||||
RLS Protection:
|
||||
---------------
|
||||
Models inheriting from BaseOrgModel are protected by PostgreSQL Row-Level
|
||||
Security (RLS) policies. The database automatically filters rows by the
|
||||
current organization context (app.current_org session variable).
|
||||
|
||||
RLS Configuration: See common/rls/__init__.py for policy definitions.
|
||||
Management: Use `python manage.py manage_rls --status` to check status.
|
||||
|
||||
IMPORTANT: RLS is bypassed if the database user is a superuser.
|
||||
"""
|
||||
|
||||
org = models.ForeignKey(
|
||||
"common.Org",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="%(class)s_set",
|
||||
help_text="Organization this record belongs to",
|
||||
)
|
||||
|
||||
objects = OrgScopedManager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
indexes = [
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Validate org is set
|
||||
if not self.org_id:
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
raise ValidationError("Organization (org) is required for this model")
|
||||
super().save(*args, **kwargs)
|
||||
280
backend/common/celery_utils.py
Normal file
280
backend/common/celery_utils.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""
|
||||
Celery utilities for multi-tenancy support.
|
||||
|
||||
Provides org-aware task execution to ensure background tasks
|
||||
respect organization context and data isolation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from celery import shared_task
|
||||
from django.db import connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrgContext:
|
||||
"""
|
||||
Context manager for setting organization context in background tasks.
|
||||
|
||||
Usage:
|
||||
with OrgContext(org_id):
|
||||
# All queries here will have org context
|
||||
leads = Lead.objects.filter(org_id=org_id)
|
||||
|
||||
For RLS (when implemented):
|
||||
Sets PostgreSQL app.current_org parameter for Row-Level Security.
|
||||
"""
|
||||
|
||||
def __init__(self, org_id):
|
||||
self.org_id = str(org_id) if org_id else None
|
||||
|
||||
def __enter__(self):
|
||||
if self.org_id:
|
||||
# Set PostgreSQL session variable for RLS (future use)
|
||||
# This will be used when Row-Level Security is enabled
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SET app.current_org = %s", [self.org_id])
|
||||
except Exception as e:
|
||||
# RLS might not be configured yet - this is expected
|
||||
logger.debug(f"Could not set app.current_org: {e}")
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
# Reset the context
|
||||
if self.org_id:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("RESET app.current_org")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def org_aware_task(func):
|
||||
"""
|
||||
Decorator for Celery tasks that require organization context.
|
||||
|
||||
The first argument to the task must be org_id.
|
||||
|
||||
Usage:
|
||||
@shared_task
|
||||
@org_aware_task
|
||||
def my_task(org_id, other_arg):
|
||||
# org_id is automatically validated and context is set
|
||||
pass
|
||||
|
||||
Example:
|
||||
@shared_task
|
||||
@org_aware_task
|
||||
def send_org_report(org_id, report_type):
|
||||
org = Org.objects.get(id=org_id)
|
||||
# Generate report for this org
|
||||
pass
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(org_id, *args, **kwargs):
|
||||
from common.models import Org
|
||||
|
||||
# Validate org exists
|
||||
try:
|
||||
org = Org.objects.get(id=org_id, is_active=True)
|
||||
except Org.DoesNotExist:
|
||||
logger.error(
|
||||
f"Task {func.__name__} failed: Org {org_id} not found or inactive"
|
||||
)
|
||||
raise ValueError(f"Invalid org_id: {org_id}")
|
||||
|
||||
# Execute with org context
|
||||
with OrgContext(org_id):
|
||||
logger.info(f"Executing {func.__name__} for org {org_id}")
|
||||
return func(org_id, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_org_id(func):
|
||||
"""
|
||||
Simple decorator that ensures org_id is provided to a task.
|
||||
|
||||
Usage:
|
||||
@shared_task
|
||||
@require_org_id
|
||||
def my_task(org_id, data):
|
||||
pass
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
org_id = kwargs.get("org_id") or (args[0] if args else None)
|
||||
|
||||
if not org_id:
|
||||
raise ValueError(f"Task {func.__name__} requires org_id parameter")
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Example org-aware tasks
|
||||
|
||||
|
||||
@shared_task
|
||||
@org_aware_task
|
||||
def cleanup_expired_sessions(org_id):
|
||||
"""
|
||||
Clean up expired session tokens for an organization.
|
||||
|
||||
Usage:
|
||||
cleanup_expired_sessions.delay(org_id=str(org.id))
|
||||
"""
|
||||
from common.models import SessionToken
|
||||
|
||||
deleted_count, _ = SessionToken.objects.filter(
|
||||
user__profiles__org_id=org_id, is_active=False
|
||||
).delete()
|
||||
|
||||
logger.info(f"Cleaned up {deleted_count} expired sessions for org {org_id}")
|
||||
return deleted_count
|
||||
|
||||
|
||||
@shared_task
|
||||
@org_aware_task
|
||||
def generate_org_activity_report(org_id, days=30):
|
||||
"""
|
||||
Generate activity report for an organization.
|
||||
|
||||
Usage:
|
||||
generate_org_activity_report.delay(org_id=str(org.id), days=30)
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from common.models import Activity
|
||||
|
||||
start_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
activities = Activity.objects.filter(org_id=org_id, created_at__gte=start_date)
|
||||
|
||||
report = {
|
||||
"org_id": org_id,
|
||||
"period_days": days,
|
||||
"total_activities": activities.count(),
|
||||
"by_action": {},
|
||||
"by_entity": {},
|
||||
}
|
||||
|
||||
for activity in activities:
|
||||
# Count by action
|
||||
action = activity.action
|
||||
report["by_action"][action] = report["by_action"].get(action, 0) + 1
|
||||
|
||||
# Count by entity type
|
||||
entity = activity.entity_type
|
||||
report["by_entity"][entity] = report["by_entity"].get(entity, 0) + 1
|
||||
|
||||
logger.info(
|
||||
f"Generated activity report for org {org_id}: {report['total_activities']} activities"
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_with_org_context(org_id, user_id, template_name, subject, context):
|
||||
"""
|
||||
Send email with organization branding/context.
|
||||
|
||||
Usage:
|
||||
send_email_with_org_context.delay(
|
||||
org_id=str(org.id),
|
||||
user_id=str(user.id),
|
||||
template_name='welcome_email.html',
|
||||
subject='Welcome to Our CRM',
|
||||
context={'custom_key': 'value'}
|
||||
)
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from common.models import Org, User
|
||||
|
||||
try:
|
||||
org = Org.objects.get(id=org_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
except (Org.DoesNotExist, User.DoesNotExist) as e:
|
||||
logger.error(f"Email send failed: {e}")
|
||||
raise
|
||||
|
||||
# Add org context to email template
|
||||
email_context = {
|
||||
"org_name": org.name,
|
||||
"user_email": user.email,
|
||||
"domain": settings.DOMAIN_NAME,
|
||||
**context,
|
||||
}
|
||||
|
||||
html_content = render_to_string(template_name, context=email_context)
|
||||
|
||||
msg = EmailMessage(
|
||||
subject,
|
||||
html_content,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[user.email],
|
||||
)
|
||||
msg.content_subtype = "html"
|
||||
msg.send()
|
||||
|
||||
logger.info(f"Sent email '{subject}' to {user.email} for org {org.name}")
|
||||
|
||||
|
||||
# Batch processing utilities
|
||||
|
||||
|
||||
@shared_task
|
||||
@org_aware_task
|
||||
def batch_process_org_data(org_id, model_name, processor_func, batch_size=100):
|
||||
"""
|
||||
Process org data in batches to avoid memory issues.
|
||||
|
||||
Usage:
|
||||
batch_process_org_data.delay(
|
||||
org_id=str(org.id),
|
||||
model_name='leads.Lead',
|
||||
processor_func='leads.tasks.process_lead',
|
||||
batch_size=100
|
||||
)
|
||||
"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
# Get model
|
||||
app_label, model_class = model_name.rsplit(".", 1)
|
||||
Model = apps.get_model(app_label, model_class)
|
||||
|
||||
# Get processor function
|
||||
module_path, func_name = processor_func.rsplit(".", 1)
|
||||
module = import_module(module_path)
|
||||
processor = getattr(module, func_name)
|
||||
|
||||
# Process in batches
|
||||
queryset = Model.objects.filter(org_id=org_id)
|
||||
total = queryset.count()
|
||||
processed = 0
|
||||
|
||||
for i in range(0, total, batch_size):
|
||||
batch = queryset[i : i + batch_size]
|
||||
for obj in batch:
|
||||
processor(obj)
|
||||
processed += 1
|
||||
|
||||
logger.info(f"Processed {processed}/{total} {model_name} for org {org_id}")
|
||||
|
||||
return processed
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
def preprocessing_filter_spec(endpoints):
|
||||
filtered = []
|
||||
for (path, path_regex, method, callback) in endpoints:
|
||||
for path, path_regex, method, callback in endpoints:
|
||||
# Remove all but DRF API endpoints
|
||||
if path.startswith("/api/"):
|
||||
filtered.append((path, path_regex, method, callback))
|
||||
|
|
@ -1,20 +1,27 @@
|
|||
import jwt
|
||||
from django.conf import settings
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.request import Request
|
||||
from common.models import Org,Profile,User
|
||||
from django.conf import settings
|
||||
|
||||
from common.models import Org, Profile, User
|
||||
|
||||
|
||||
def verify_jwt_token(token):
|
||||
secret_key = (settings.SECRET_KEY) # Replace with your secret key used for token encoding/decoding
|
||||
secret_key = (
|
||||
settings.SECRET_KEY
|
||||
) # Replace with your secret key used for token encoding/decoding
|
||||
try:
|
||||
payload = jwt.decode(token, (settings.SECRET_KEY), algorithms=[settings.JWT_ALGO])
|
||||
payload = jwt.decode(
|
||||
token, (settings.SECRET_KEY), algorithms=[settings.JWT_ALGO]
|
||||
)
|
||||
return True, payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return False, "Token is expired"
|
||||
except jwt.InvalidTokenError:
|
||||
return False, "Invalid token"
|
||||
|
||||
|
||||
class CustomDualAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
|
|
@ -22,31 +29,37 @@ class CustomDualAuthentication(BaseAuthentication):
|
|||
profile = None
|
||||
|
||||
# Check JWT authentication
|
||||
jwt_token = request.headers.get('Authorization', '').split(' ')[1] if 'Authorization' in request.headers else None
|
||||
jwt_token = (
|
||||
request.headers.get("Authorization", "").split(" ")[1]
|
||||
if "Authorization" in request.headers
|
||||
else None
|
||||
)
|
||||
if jwt_token:
|
||||
is_valid, jwt_payload = verify_jwt_token(jwt_token)
|
||||
if is_valid:
|
||||
jwt_user = (User.objects.get(id=jwt_payload['user_id']), True)
|
||||
if jwt_payload['user_id'] is not None:
|
||||
jwt_user = (User.objects.get(id=jwt_payload["user_id"]), True)
|
||||
if jwt_payload["user_id"] is not None:
|
||||
if request.headers.get("org"):
|
||||
profile = Profile.objects.get(
|
||||
user_id=jwt_payload['user_id'], org=request.headers.get("org"), is_active=True
|
||||
user_id=jwt_payload["user_id"],
|
||||
org=request.headers.get("org"),
|
||||
is_active=True,
|
||||
)
|
||||
if profile:
|
||||
request.profile = profile
|
||||
|
||||
# Check API key authentication
|
||||
api_key = request.headers.get('Token') # Get API key from request query params
|
||||
api_key = request.headers.get("Token") # Get API key from request query params
|
||||
if api_key:
|
||||
try:
|
||||
organization = Org.objects.get(api_key=api_key)
|
||||
api_key_user = organization
|
||||
request.META['org'] = api_key_user.id
|
||||
request.META["org"] = api_key_user.id
|
||||
profile = Profile.objects.filter(org=api_key_user, role="ADMIN").first()
|
||||
request.profile = profile
|
||||
profile = (profile.user, True)
|
||||
except Org.DoesNotExist:
|
||||
raise AuthenticationFailed('Invalid API Key')
|
||||
raise AuthenticationFailed("Invalid API Key")
|
||||
|
||||
# Select the appropriate user based on authentication method
|
||||
# Return the user if any authentication method succeeded
|
||||
227
backend/common/management/commands/audit_org_fields.py
Normal file
227
backend/common/management/commands/audit_org_fields.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""
|
||||
Management command to audit and fix nullable org fields.
|
||||
|
||||
This is part of Phase 1 multi-tenancy hardening - ensures all org-scoped
|
||||
models have proper org field values before adding NOT NULL constraints.
|
||||
|
||||
Usage:
|
||||
python manage.py audit_org_fields --check # Just check for issues
|
||||
python manage.py audit_org_fields --fix # Attempt to fix issues
|
||||
python manage.py audit_org_fields --verbose # Show detailed output
|
||||
"""
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Audit and report on models with nullable org fields"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Check for null org values without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fix",
|
||||
action="store_true",
|
||||
help="Attempt to backfill null org values from related objects",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed information about each model",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
check_only = options["check"]
|
||||
fix = options["fix"]
|
||||
verbose = options["verbose"]
|
||||
|
||||
self.stdout.write(
|
||||
self.style.NOTICE("\n=== Multi-Tenancy Org Field Audit ===\n")
|
||||
)
|
||||
|
||||
# Models that should have org but currently allow null
|
||||
models_with_nullable_org = [
|
||||
("common", "Address"),
|
||||
("common", "Tags"),
|
||||
]
|
||||
|
||||
# Models with proper org fields (for reference)
|
||||
models_with_required_org = [
|
||||
("common", "Comment"),
|
||||
("common", "Attachments"),
|
||||
("common", "Document"),
|
||||
("common", "Activity"),
|
||||
("common", "Teams"),
|
||||
("common", "APISettings"),
|
||||
("accounts", "Account"),
|
||||
("leads", "Lead"),
|
||||
("contacts", "Contact"),
|
||||
("opportunity", "Opportunity"),
|
||||
("cases", "Case"),
|
||||
("tasks", "Task"),
|
||||
("invoices", "Invoice"),
|
||||
]
|
||||
|
||||
issues_found = []
|
||||
|
||||
# Check models with nullable org
|
||||
self.stdout.write(
|
||||
self.style.WARNING("Checking models with nullable org fields:\n")
|
||||
)
|
||||
|
||||
for app_label, model_name in models_with_nullable_org:
|
||||
try:
|
||||
model = apps.get_model(app_label, model_name)
|
||||
total_count = model.objects.count()
|
||||
null_count = model.objects.filter(org__isnull=True).count()
|
||||
|
||||
if null_count > 0:
|
||||
issues_found.append(
|
||||
(app_label, model_name, null_count, total_count)
|
||||
)
|
||||
self.stdout.write(
|
||||
f" ❌ {app_label}.{model_name}: "
|
||||
f"{null_count}/{total_count} records have NULL org"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" ✅ {app_label}.{model_name}: "
|
||||
f"All {total_count} records have org set"
|
||||
)
|
||||
|
||||
if verbose:
|
||||
self._show_model_details(model)
|
||||
|
||||
except LookupError:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f" ⚠️ {app_label}.{model_name}: Model not found")
|
||||
)
|
||||
|
||||
# Check models with required org
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("\nChecking models with required org fields:\n")
|
||||
)
|
||||
|
||||
for app_label, model_name in models_with_required_org:
|
||||
try:
|
||||
model = apps.get_model(app_label, model_name)
|
||||
total_count = model.objects.count()
|
||||
|
||||
# Check if any null orgs exist (shouldn't happen but let's verify)
|
||||
if hasattr(model, "org"):
|
||||
null_count = model.objects.filter(org__isnull=True).count()
|
||||
if null_count > 0:
|
||||
issues_found.append(
|
||||
(app_label, model_name, null_count, total_count)
|
||||
)
|
||||
self.stdout.write(
|
||||
f" ❌ {app_label}.{model_name}: "
|
||||
f"{null_count}/{total_count} records have NULL org (UNEXPECTED)"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" ✅ {app_label}.{model_name}: {total_count} records OK"
|
||||
)
|
||||
|
||||
except LookupError:
|
||||
if verbose:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" ⚠️ {app_label}.{model_name}: Model not found"
|
||||
)
|
||||
)
|
||||
|
||||
# Summary
|
||||
self.stdout.write(self.style.NOTICE("\n=== Summary ===\n"))
|
||||
|
||||
if issues_found:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Found {len(issues_found)} models with NULL org values:\n"
|
||||
)
|
||||
)
|
||||
for app_label, model_name, null_count, total_count in issues_found:
|
||||
self.stdout.write(f" - {app_label}.{model_name}: {null_count} records")
|
||||
|
||||
if fix:
|
||||
self.stdout.write(self.style.WARNING("\nAttempting to fix issues...\n"))
|
||||
self._fix_null_orgs(issues_found)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.NOTICE(
|
||||
"\nRun with --fix to attempt automatic backfill, "
|
||||
"or manually update records before adding NOT NULL constraints."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"All models have proper org values! Ready for NOT NULL migration."
|
||||
)
|
||||
)
|
||||
|
||||
# Recommendations
|
||||
self.stdout.write(self.style.NOTICE("\n=== Next Steps ===\n"))
|
||||
self.stdout.write(
|
||||
"1. Fix any NULL org values using --fix or manually\n"
|
||||
"2. Create migration to set org fields to NOT NULL:\n"
|
||||
" - Address.org: null=False, blank=False\n"
|
||||
" - Tags.org: null=False, blank=False\n"
|
||||
"3. Run: python manage.py makemigrations common\n"
|
||||
"4. Run: python manage.py migrate\n"
|
||||
)
|
||||
|
||||
def _show_model_details(self, model):
|
||||
"""Show detailed information about a model's org field configuration."""
|
||||
org_field = model._meta.get_field("org")
|
||||
self.stdout.write(
|
||||
f" Field config: null={org_field.null}, blank={org_field.blank}, "
|
||||
f"related_name='{org_field.related_query_name()}'"
|
||||
)
|
||||
|
||||
def _fix_null_orgs(self, issues):
|
||||
"""Attempt to fix NULL org values by inferring from related objects."""
|
||||
from common.models import Address, Profile, Tags
|
||||
|
||||
for app_label, model_name, null_count, total_count in issues:
|
||||
model = apps.get_model(app_label, model_name)
|
||||
|
||||
if model_name == "Address":
|
||||
# Address can be inferred from Profile.address relationship
|
||||
fixed = 0
|
||||
for address in Address.objects.filter(org__isnull=True):
|
||||
# Find profile that references this address
|
||||
profile = Profile.objects.filter(address=address).first()
|
||||
if profile and profile.org:
|
||||
address.org = profile.org
|
||||
address.save()
|
||||
fixed += 1
|
||||
|
||||
self.stdout.write(f" Fixed {fixed}/{null_count} Address records")
|
||||
if fixed < null_count:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" {null_count - fixed} records couldn't be fixed - "
|
||||
"no related Profile found. Consider deleting orphaned records."
|
||||
)
|
||||
)
|
||||
|
||||
elif model_name == "Tags":
|
||||
# Tags are harder - might need to check which entities use them
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" Tags with NULL org need manual review. "
|
||||
"Consider checking Account.tags, Lead.tags, etc. for usage."
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" {model_name}: Automatic fix not implemented. "
|
||||
"Please fix manually."
|
||||
)
|
||||
189
backend/common/management/commands/manage_rls.py
Normal file
189
backend/common/management/commands/manage_rls.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""
|
||||
Management command to check Row-Level Security (RLS) status.
|
||||
|
||||
Usage:
|
||||
python manage.py manage_rls --status # Check RLS status
|
||||
python manage.py manage_rls --test # Test RLS is working
|
||||
python manage.py manage_rls --verify-user # Verify DB user is not superuser
|
||||
|
||||
RLS Configuration: See common/rls/__init__.py for centralized policy definitions.
|
||||
RLS is enabled/disabled via Django migrations, not this command.
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
from common.rls import RLS_CONFIG, get_check_rls_status_sql, get_set_context_sql
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Check Row-Level Security (RLS) status for multi-tenancy"
|
||||
|
||||
# Use centralized RLS configuration
|
||||
ORG_SCOPED_TABLES = RLS_CONFIG["tables"]
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--status", action="store_true", help="Check RLS status for all tables"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test", action="store_true", help="Test that RLS is working correctly"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verify-user",
|
||||
action="store_true",
|
||||
help="Verify database user is not a superuser",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if connection.vendor != "postgresql":
|
||||
self.stderr.write(self.style.ERROR("RLS is only supported on PostgreSQL"))
|
||||
return
|
||||
|
||||
if options["status"]:
|
||||
self.check_status()
|
||||
elif options["test"]:
|
||||
self.test_rls()
|
||||
elif options["verify_user"]:
|
||||
self.verify_user()
|
||||
else:
|
||||
self.check_status()
|
||||
|
||||
def check_status(self):
|
||||
"""Check RLS status for all org-scoped tables."""
|
||||
self.stdout.write(self.style.MIGRATE_HEADING("RLS Status:"))
|
||||
self.stdout.write("")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Check if current user is superuser
|
||||
cursor.execute(
|
||||
"SELECT current_user, usesuper FROM pg_user WHERE usename = current_user"
|
||||
)
|
||||
user, is_super = cursor.fetchone()
|
||||
|
||||
if is_super:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f' Database user "{user}" is a SUPERUSER - RLS will be bypassed!'
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f' Database user "{user}" is not a superuser - RLS will be enforced'
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write("")
|
||||
|
||||
enabled_count = 0
|
||||
disabled_count = 0
|
||||
|
||||
for table in self.ORG_SCOPED_TABLES:
|
||||
cursor.execute(get_check_rls_status_sql(), [table])
|
||||
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
rls_enabled, rls_forced = result
|
||||
if rls_enabled:
|
||||
status = self.style.SUCCESS("ENABLED")
|
||||
if rls_forced:
|
||||
status += " (forced)"
|
||||
enabled_count += 1
|
||||
else:
|
||||
status = self.style.WARNING("disabled")
|
||||
disabled_count += 1
|
||||
else:
|
||||
status = self.style.ERROR("TABLE NOT FOUND")
|
||||
|
||||
self.stdout.write(f" {table}: {status}")
|
||||
|
||||
self.stdout.write("")
|
||||
self.stdout.write(f" Enabled: {enabled_count}, Disabled: {disabled_count}")
|
||||
|
||||
def test_rls(self):
|
||||
"""Test that RLS is working correctly."""
|
||||
self.stdout.write(self.style.MIGRATE_HEADING("Testing RLS..."))
|
||||
|
||||
set_context_sql = get_set_context_sql()
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Get two different orgs
|
||||
cursor.execute("SELECT id FROM company LIMIT 2")
|
||||
orgs = cursor.fetchall()
|
||||
|
||||
if len(orgs) < 2:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("Need at least 2 orgs to test RLS. Skipping.")
|
||||
)
|
||||
return
|
||||
|
||||
org_a = str(orgs[0][0])
|
||||
org_b = str(orgs[1][0])
|
||||
|
||||
# Test with org_a context
|
||||
cursor.execute(set_context_sql, [org_a])
|
||||
cursor.execute("SELECT COUNT(*) FROM lead")
|
||||
count_a = cursor.fetchone()[0]
|
||||
|
||||
# Test with org_b context
|
||||
cursor.execute(set_context_sql, [org_b])
|
||||
cursor.execute("SELECT COUNT(*) FROM lead")
|
||||
count_b = cursor.fetchone()[0]
|
||||
|
||||
# Test with no context
|
||||
cursor.execute(set_context_sql, [""])
|
||||
cursor.execute("SELECT COUNT(*) FROM lead")
|
||||
count_none = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write(f" Leads with org_a context: {count_a}")
|
||||
self.stdout.write(f" Leads with org_b context: {count_b}")
|
||||
self.stdout.write(f" Leads with no context: {count_none}")
|
||||
|
||||
if count_none == 0 and (count_a > 0 or count_b > 0):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("RLS is working - no data without context")
|
||||
)
|
||||
elif count_none > 0:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"RLS may not be fully enabled - data visible without context. "
|
||||
"This is expected if the policy allows empty context."
|
||||
)
|
||||
)
|
||||
|
||||
def verify_user(self):
|
||||
"""Verify the database user is not a superuser."""
|
||||
self.stdout.write(self.style.MIGRATE_HEADING("Verifying database user..."))
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT usename, usesuper, usecreatedb
|
||||
FROM pg_user
|
||||
WHERE usename = current_user
|
||||
"""
|
||||
)
|
||||
user, is_super, can_create_db = cursor.fetchone()
|
||||
|
||||
self.stdout.write(f" Current user: {user}")
|
||||
self.stdout.write(f" Is superuser: {is_super}")
|
||||
self.stdout.write(f" Can create DB: {can_create_db}")
|
||||
|
||||
if is_super:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"WARNING: Superusers bypass RLS!\n"
|
||||
"Create a non-superuser for the application:\n\n"
|
||||
" CREATE USER crm_app WITH PASSWORD 'secure_password';\n"
|
||||
" GRANT CONNECT ON DATABASE bottlecrm TO crm_app;\n"
|
||||
" GRANT USAGE ON SCHEMA public TO crm_app;\n"
|
||||
" GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_app;\n"
|
||||
" GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_app;\n"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Database user is properly configured for RLS")
|
||||
)
|
||||
1502
backend/common/management/commands/migrate_from_prisma.py
Normal file
1502
backend/common/management/commands/migrate_from_prisma.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,10 @@
|
|||
from django.contrib.auth.models import BaseUserManager
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
if not email:
|
||||
raise ValueError('The Email field must be set')
|
||||
raise ValueError("The Email field must be set")
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
|
|
@ -11,14 +12,13 @@ class UserManager(BaseUserManager):
|
|||
return user
|
||||
|
||||
def create_superuser(self, email, password=None, **extra_fields):
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
extra_fields.setdefault('is_staff', True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
|
||||
if extra_fields.get('is_staff') is not True:
|
||||
raise ValueError('Superuser must have is_staff=True.')
|
||||
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
raise ValueError("Superuser must have is_staff=True.")
|
||||
|
||||
if extra_fields.get('is_superuser') is not True:
|
||||
raise ValueError('Superuser must have is_superuser=True.')
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError("Superuser must have is_superuser=True.")
|
||||
|
||||
return self.create_user(email, password, **extra_fields)
|
||||
152
backend/common/middleware/get_company.py
Normal file
152
backend/common/middleware/get_company.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import logging
|
||||
|
||||
import jwt
|
||||
from crum import get_current_user
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.models import Org, Profile, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_actual_value(request):
|
||||
if request.user is None:
|
||||
return None
|
||||
return request.user
|
||||
|
||||
|
||||
class GetProfileAndOrg(object):
|
||||
"""
|
||||
Middleware to extract and validate organization context from JWT tokens.
|
||||
|
||||
SECURITY: This middleware validates org_id from the SIGNED JWT token,
|
||||
not from client-provided headers. This prevents org spoofing attacks.
|
||||
|
||||
The org context is cryptographically verified as part of the JWT signature.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
self.process_request(request)
|
||||
return self.get_response(request)
|
||||
|
||||
def process_request(self, request):
|
||||
# Skip JWT validation for authentication endpoints that don't need org context
|
||||
auth_skip_paths = [
|
||||
"/api/auth/login/",
|
||||
"/api/auth/google/",
|
||||
"/api/auth/register/",
|
||||
"/api/auth/refresh-token/",
|
||||
"/api/auth/me/",
|
||||
"/api/auth/switch-org/",
|
||||
]
|
||||
if request.path in auth_skip_paths:
|
||||
return
|
||||
|
||||
# Initialize request attributes
|
||||
request.profile = None
|
||||
request.org = None
|
||||
|
||||
# Try JWT token first (primary authentication)
|
||||
if request.headers.get("Authorization"):
|
||||
self._process_jwt_auth(request)
|
||||
return
|
||||
|
||||
# Fall back to API key authentication
|
||||
api_key = request.headers.get("Token")
|
||||
if api_key:
|
||||
self._process_api_key_auth(request, api_key)
|
||||
return
|
||||
|
||||
def _process_jwt_auth(self, request):
|
||||
"""
|
||||
Process JWT authentication and extract org context from token.
|
||||
|
||||
SECURITY: Org ID is extracted from the signed JWT payload, not headers.
|
||||
"""
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
try:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
token_value = auth_header.split(" ")[1]
|
||||
|
||||
# Validate and decode token
|
||||
access_token = AccessToken(token_value)
|
||||
user_id = access_token["user_id"]
|
||||
|
||||
# Get org_id from JWT claim (SECURE - cryptographically signed)
|
||||
org_id = access_token.get("org_id")
|
||||
|
||||
if not org_id:
|
||||
# Token doesn't have org context - this is allowed for some endpoints
|
||||
# like /api/auth/me/ or initial login
|
||||
logger.debug(f"JWT token for user {user_id} has no org_id claim")
|
||||
return
|
||||
|
||||
# Validate user membership in the org
|
||||
try:
|
||||
profile = Profile.objects.select_related("org").get(
|
||||
user_id=user_id, org_id=org_id, is_active=True
|
||||
)
|
||||
request.profile = profile
|
||||
request.org = profile.org
|
||||
logger.debug(f"Set org context from JWT: user={user_id}, org={org_id}")
|
||||
|
||||
except Profile.DoesNotExist:
|
||||
# User doesn't have access to this org anymore
|
||||
# This can happen if membership was revoked after token was issued
|
||||
logger.warning(
|
||||
f"User {user_id} no longer has access to org {org_id}. "
|
||||
"Token may be stale."
|
||||
)
|
||||
raise PermissionDenied(
|
||||
"You no longer have access to this organization. "
|
||||
"Please login again."
|
||||
)
|
||||
|
||||
except (IndexError, KeyError) as e:
|
||||
logger.warning(f"Malformed Authorization header: {e}")
|
||||
# Let DRF authentication handle this
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"JWT validation failed: {e}")
|
||||
# Let DRF authentication handle invalid tokens
|
||||
return
|
||||
|
||||
def _process_api_key_auth(self, request, api_key):
|
||||
"""
|
||||
Process API key authentication.
|
||||
|
||||
Note: API keys are scoped to a specific org and use an admin profile.
|
||||
TODO: Create dedicated API key model with granular permissions.
|
||||
"""
|
||||
try:
|
||||
organization = Org.objects.get(api_key=api_key, is_active=True)
|
||||
|
||||
# Get an admin profile for this org
|
||||
profile = Profile.objects.filter(
|
||||
org=organization, role="ADMIN", is_active=True
|
||||
).first()
|
||||
|
||||
if not profile:
|
||||
logger.error(f"No active admin profile found for org {organization.id}")
|
||||
raise AuthenticationFailed("Invalid API Key configuration")
|
||||
|
||||
request.profile = profile
|
||||
request.org = organization
|
||||
request.META["org"] = str(organization.id)
|
||||
|
||||
logger.debug(f"Set org context from API key: org={organization.id}")
|
||||
|
||||
except Org.DoesNotExist:
|
||||
logger.warning(f"Invalid API key attempted: {api_key[:8]}...")
|
||||
raise AuthenticationFailed("Invalid API Key")
|
||||
187
backend/common/middleware/rls_context.py
Normal file
187
backend/common/middleware/rls_context.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""
|
||||
Database middleware for Row-Level Security (RLS) context.
|
||||
|
||||
This middleware sets the PostgreSQL session variable `app.current_org`
|
||||
which is used by RLS policies to filter data at the database level.
|
||||
|
||||
Enable RLS policies after this middleware is in place:
|
||||
|
||||
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY org_isolation ON leads
|
||||
USING (org_id = current_setting('app.current_org', true)::uuid);
|
||||
|
||||
Usage in settings.py:
|
||||
MIDDLEWARE = [
|
||||
...
|
||||
'common.middleware.get_company.GetProfileAndOrg',
|
||||
'common.middleware.rls_context.SetOrgContext', # After GetProfileAndOrg
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SetOrgContext:
|
||||
"""
|
||||
Middleware to set PostgreSQL session variable for Row-Level Security.
|
||||
|
||||
This sets `app.current_org` to the user's organization ID, which is
|
||||
used by RLS policies to automatically filter data at the database level.
|
||||
|
||||
Security: This provides defense-in-depth. Even if application code
|
||||
forgets to filter by org, the database will enforce isolation.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Set org context before processing request
|
||||
self._set_org_context(request)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
# Reset context after request
|
||||
self._reset_org_context()
|
||||
|
||||
return response
|
||||
|
||||
def _set_org_context(self, request):
|
||||
"""
|
||||
Set the PostgreSQL session variable for RLS.
|
||||
|
||||
Args:
|
||||
request: Django request object with profile attached
|
||||
"""
|
||||
if not hasattr(request, "org") or request.org is None:
|
||||
return
|
||||
|
||||
org_id = str(request.org.id)
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# Set the session variable
|
||||
cursor.execute(
|
||||
"SELECT set_config('app.current_org', %s, true)", [org_id]
|
||||
)
|
||||
logger.debug(f"Set RLS context: app.current_org = {org_id}")
|
||||
|
||||
except Exception as e:
|
||||
# RLS might not be configured - log but don't fail
|
||||
logger.debug(f"Could not set RLS context: {e}")
|
||||
|
||||
def _reset_org_context(self):
|
||||
"""
|
||||
Reset the PostgreSQL session variable after request.
|
||||
"""
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT set_config('app.current_org', '', true)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class RequireOrgContext:
|
||||
"""
|
||||
Stricter middleware that fails if org context is not set.
|
||||
|
||||
Use this instead of SetOrgContext when you want to ensure
|
||||
all requests have proper org context (after RLS is fully enabled).
|
||||
|
||||
Usage in settings.py:
|
||||
MIDDLEWARE = [
|
||||
...
|
||||
'common.middleware.get_company.GetProfileAndOrg',
|
||||
'common.middleware.rls_context.RequireOrgContext',
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
# Paths that don't require org context
|
||||
EXEMPT_PATHS = [
|
||||
"/api/auth/login/",
|
||||
"/api/auth/register/",
|
||||
"/api/auth/refresh-token/",
|
||||
"/api/auth/me/",
|
||||
"/api/auth/switch-org/",
|
||||
"/api/auth/google/",
|
||||
"/admin/",
|
||||
"/swagger-ui/",
|
||||
"/api/schema/",
|
||||
]
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Check if path requires org context
|
||||
if not self._is_exempt(request.path):
|
||||
if not hasattr(request, "org") or request.org is None:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
raise PermissionDenied(
|
||||
"Organization context is required. Please login again."
|
||||
)
|
||||
|
||||
# Set org context
|
||||
self._set_org_context(request)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
# Reset context
|
||||
self._reset_org_context()
|
||||
|
||||
return response
|
||||
|
||||
def _is_exempt(self, path):
|
||||
"""Check if path is exempt from org context requirement."""
|
||||
return any(path.startswith(exempt) for exempt in self.EXEMPT_PATHS)
|
||||
|
||||
def _set_org_context(self, request):
|
||||
"""Set PostgreSQL session variable."""
|
||||
if not hasattr(request, "org") or request.org is None:
|
||||
return
|
||||
|
||||
org_id = str(request.org.id)
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT set_config('app.current_org', %s, true)", [org_id]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set RLS context: {e}")
|
||||
|
||||
def _reset_org_context(self):
|
||||
"""Reset PostgreSQL session variable."""
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT set_config('app.current_org', '', true)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# SQL to enable RLS on all org-scoped tables
|
||||
RLS_SETUP_SQL = """
|
||||
-- Enable RLS on main tables
|
||||
-- Run this after all org-scoped tables are identified
|
||||
|
||||
-- Example for leads table:
|
||||
-- ALTER TABLE lead ENABLE ROW LEVEL SECURITY;
|
||||
-- ALTER TABLE lead FORCE ROW LEVEL SECURITY; -- Apply to table owner too
|
||||
-- CREATE POLICY org_isolation ON lead
|
||||
-- USING (org_id = NULLIF(current_setting('app.current_org', true), '')::uuid);
|
||||
|
||||
-- Tables that need RLS policies:
|
||||
-- lead, accounts, contacts, opportunity, cases, tasks, invoices,
|
||||
-- comment, attachments, document, teams, activity, tags, address,
|
||||
-- api_settings, board, board_column, board_task, board_member
|
||||
|
||||
-- Note: Use NULLIF to handle empty string when context is not set
|
||||
-- This makes the policy return no rows when context is not set (fail-safe)
|
||||
"""
|
||||
1616
backend/common/migrations/0001_initial.py
Normal file
1616
backend/common/migrations/0001_initial.py
Normal file
File diff suppressed because it is too large
Load diff
119
backend/common/migrations/0002_enable_rls.py
Normal file
119
backend/common/migrations/0002_enable_rls.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Row-Level Security (RLS) Migration
|
||||
# Enables PostgreSQL RLS on all org-scoped tables for multi-tenant isolation
|
||||
#
|
||||
# RLS Configuration: See common/rls/__init__.py for centralized policy definitions
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from common.rls import (
|
||||
RLS_CONFIG,
|
||||
get_check_table_exists_sql,
|
||||
get_disable_policy_sql,
|
||||
get_enable_policy_sql,
|
||||
)
|
||||
|
||||
|
||||
def check_column_exists(cursor, table, column):
|
||||
"""Check if a column exists in a table."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = %s
|
||||
AND column_name = %s
|
||||
)
|
||||
""",
|
||||
[table, column],
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
def enable_rls(apps, schema_editor):
|
||||
"""Enable RLS on all org-scoped tables."""
|
||||
if schema_editor.connection.vendor != "postgresql":
|
||||
print("RLS is only supported on PostgreSQL. Skipping.")
|
||||
return
|
||||
|
||||
isolation_policy = RLS_CONFIG["policies"]["isolation"]
|
||||
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
for table in RLS_CONFIG["tables"]:
|
||||
# Check if table exists
|
||||
cursor.execute(get_check_table_exists_sql(), [table])
|
||||
if not cursor.fetchone()[0]:
|
||||
print(f" Skipping {table} (table does not exist)")
|
||||
continue
|
||||
|
||||
# Check if org_id column exists
|
||||
if not check_column_exists(cursor, table, "org_id"):
|
||||
print(f" Skipping {table} (no org_id column)")
|
||||
continue
|
||||
|
||||
# Check if policy already exists
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM pg_policies
|
||||
WHERE tablename = %s AND policyname = %s
|
||||
""",
|
||||
[table, isolation_policy],
|
||||
)
|
||||
|
||||
if cursor.fetchone()[0] == 0:
|
||||
cursor.execute(get_enable_policy_sql(table))
|
||||
print(f" Enabled RLS on {table}")
|
||||
else:
|
||||
print(f" RLS already enabled on {table}")
|
||||
|
||||
|
||||
def disable_rls(apps, schema_editor):
|
||||
"""Disable RLS on all org-scoped tables (rollback)."""
|
||||
if schema_editor.connection.vendor != "postgresql":
|
||||
return
|
||||
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
for table in RLS_CONFIG["tables"]:
|
||||
cursor.execute(get_check_table_exists_sql(), [table])
|
||||
if not cursor.fetchone()[0]:
|
||||
continue
|
||||
|
||||
if not check_column_exists(cursor, table, "org_id"):
|
||||
continue
|
||||
|
||||
cursor.execute(get_disable_policy_sql(table))
|
||||
print(f" Disabled RLS on {table}")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""
|
||||
Enable Row-Level Security (RLS) on all organization-scoped tables.
|
||||
|
||||
This provides database-level isolation ensuring that:
|
||||
1. Queries automatically filter by current org
|
||||
2. Even if application code misses a filter, data is protected
|
||||
3. Direct SQL access respects org boundaries
|
||||
|
||||
Prerequisites:
|
||||
- PostgreSQL 9.5+
|
||||
- Database user must NOT be a superuser (superusers bypass RLS)
|
||||
- Application must set app.current_org before queries
|
||||
|
||||
Configuration:
|
||||
- Table list and policy SQL are defined in common/rls/__init__.py
|
||||
- Use `python manage.py manage_rls --status` to check RLS status
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("common", "0001_initial"),
|
||||
("accounts", "0002_initial"),
|
||||
("cases", "0002_initial"),
|
||||
("contacts", "0001_initial"),
|
||||
("invoices", "0001_initial"),
|
||||
("leads", "0001_initial"),
|
||||
("opportunity", "0001_initial"),
|
||||
("tasks", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(enable_rls, disable_rls),
|
||||
]
|
||||
422
backend/common/mixins.py
Normal file
422
backend/common/mixins.py
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
# Python imports
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from common.models import models
|
||||
|
||||
|
||||
class TimeAuditModel(models.Model):
|
||||
"""To path when the record was created and last modified"""
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Created At",
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At")
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class UserAuditModel(models.Model):
|
||||
"""To path when the record was created and last modified"""
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
"common.User",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
verbose_name="Created By",
|
||||
null=True,
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
"common.User",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
verbose_name="Last Modified By",
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class AuditModel(TimeAuditModel, UserAuditModel):
|
||||
"""To path when the record was created and last modified"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# View Mixins for Organization-Scoped Access
|
||||
# =============================================================================
|
||||
|
||||
from django.db.models import Q
|
||||
from rest_framework.exceptions import NotFound, PermissionDenied
|
||||
|
||||
|
||||
class OrgFilterMixin:
|
||||
"""
|
||||
Mixin for API views that automatically filters querysets by organization.
|
||||
|
||||
Usage:
|
||||
class LeadListView(OrgFilterMixin, APIView):
|
||||
model = Lead
|
||||
|
||||
def get(self, request):
|
||||
queryset = self.get_org_queryset()
|
||||
# queryset is already filtered by org
|
||||
"""
|
||||
|
||||
model = None # Override in subclass
|
||||
|
||||
def get_org_queryset(self):
|
||||
"""
|
||||
Get queryset filtered by the user's organization.
|
||||
|
||||
Returns:
|
||||
QuerySet filtered by org, or empty queryset if no org context
|
||||
"""
|
||||
if not hasattr(self, "model") or self.model is None:
|
||||
raise NotImplementedError("OrgFilterMixin requires 'model' attribute")
|
||||
|
||||
if not hasattr(self.request, "profile") or self.request.profile is None:
|
||||
return self.model.objects.none()
|
||||
|
||||
return self.model.objects.filter(org=self.request.profile.org)
|
||||
|
||||
def get_org_object(self, pk):
|
||||
"""
|
||||
Get a single object by pk, ensuring it belongs to user's org.
|
||||
|
||||
Args:
|
||||
pk: Primary key of the object
|
||||
|
||||
Returns:
|
||||
Model instance
|
||||
|
||||
Raises:
|
||||
NotFound: If object doesn't exist or belongs to different org
|
||||
"""
|
||||
try:
|
||||
obj = self.model.objects.get(pk=pk)
|
||||
except self.model.DoesNotExist:
|
||||
raise NotFound(f"{self.model.__name__} not found")
|
||||
|
||||
# Verify org ownership
|
||||
if hasattr(obj, "org_id"):
|
||||
if obj.org_id != self.request.profile.org_id:
|
||||
raise NotFound(f"{self.model.__name__} not found")
|
||||
|
||||
return obj
|
||||
|
||||
def filter_by_role(self, queryset):
|
||||
"""
|
||||
Apply role-based filtering to queryset.
|
||||
|
||||
Admins see all org data, users see only assigned/created items.
|
||||
|
||||
Args:
|
||||
queryset: QuerySet already filtered by org
|
||||
|
||||
Returns:
|
||||
QuerySet with role-based filtering applied
|
||||
"""
|
||||
if self.request.profile.role == "ADMIN":
|
||||
return queryset
|
||||
|
||||
# Non-admins see items they created or are assigned to
|
||||
filters = Q(created_by=self.request.profile.user)
|
||||
|
||||
if hasattr(self.model, "assigned_to"):
|
||||
filters |= Q(assigned_to=self.request.profile)
|
||||
|
||||
if hasattr(self.model, "teams"):
|
||||
user_team_ids = self.request.profile.user_teams.values_list("id", flat=True)
|
||||
filters |= Q(teams__id__in=user_team_ids)
|
||||
|
||||
return queryset.filter(filters).distinct()
|
||||
|
||||
|
||||
class OrgCreateMixin:
|
||||
"""
|
||||
Mixin for API views that automatically sets org on object creation.
|
||||
|
||||
Usage:
|
||||
class LeadCreateView(OrgCreateMixin, APIView):
|
||||
model = Lead
|
||||
serializer_class = LeadSerializer
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.serializer_class(data=request.data)
|
||||
if serializer.is_valid():
|
||||
instance = self.perform_org_create(serializer)
|
||||
return Response(...)
|
||||
"""
|
||||
|
||||
def perform_org_create(self, serializer):
|
||||
"""
|
||||
Save serializer with org and created_by set automatically.
|
||||
|
||||
Args:
|
||||
serializer: Valid serializer instance
|
||||
|
||||
Returns:
|
||||
Created model instance
|
||||
"""
|
||||
return serializer.save(
|
||||
org=self.request.profile.org, created_by=self.request.profile.user
|
||||
)
|
||||
|
||||
def get_org_context(self):
|
||||
"""
|
||||
Get org-related context for serializer.
|
||||
|
||||
Returns:
|
||||
Dict with org and profile for serializer context
|
||||
"""
|
||||
return {
|
||||
"org": self.request.profile.org,
|
||||
"profile": self.request.profile,
|
||||
"user": self.request.profile.user,
|
||||
}
|
||||
|
||||
|
||||
class OrgUpdateMixin:
|
||||
"""
|
||||
Mixin for API views that validates org ownership on update.
|
||||
|
||||
Usage:
|
||||
class LeadUpdateView(OrgUpdateMixin, OrgFilterMixin, APIView):
|
||||
model = Lead
|
||||
|
||||
def put(self, request, pk):
|
||||
instance = self.get_org_object(pk)
|
||||
self.validate_update_permission(instance)
|
||||
# proceed with update
|
||||
"""
|
||||
|
||||
def validate_update_permission(self, instance):
|
||||
"""
|
||||
Validate user has permission to update this instance.
|
||||
|
||||
Args:
|
||||
instance: Model instance to update
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If user cannot update this instance
|
||||
"""
|
||||
# Verify org ownership
|
||||
if hasattr(instance, "org_id"):
|
||||
if instance.org_id != self.request.profile.org_id:
|
||||
raise PermissionDenied("Cannot update object from another organization")
|
||||
|
||||
# Admins can update anything in their org
|
||||
if self.request.profile.role == "ADMIN":
|
||||
return
|
||||
|
||||
# Check if user created the object
|
||||
if hasattr(instance, "created_by_id"):
|
||||
if instance.created_by_id == self.request.profile.user_id:
|
||||
return
|
||||
|
||||
# Check if user is assigned
|
||||
if hasattr(instance, "assigned_to"):
|
||||
if self.request.profile in instance.assigned_to.all():
|
||||
return
|
||||
|
||||
raise PermissionDenied("You don't have permission to update this object")
|
||||
|
||||
|
||||
class OrgDeleteMixin:
|
||||
"""
|
||||
Mixin for API views that validates org ownership on delete.
|
||||
|
||||
Usage:
|
||||
class LeadDeleteView(OrgDeleteMixin, OrgFilterMixin, APIView):
|
||||
model = Lead
|
||||
|
||||
def delete(self, request, pk):
|
||||
instance = self.get_org_object(pk)
|
||||
self.validate_delete_permission(instance)
|
||||
instance.delete()
|
||||
"""
|
||||
|
||||
def validate_delete_permission(self, instance):
|
||||
"""
|
||||
Validate user has permission to delete this instance.
|
||||
|
||||
Args:
|
||||
instance: Model instance to delete
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If user cannot delete this instance
|
||||
"""
|
||||
# Verify org ownership
|
||||
if hasattr(instance, "org_id"):
|
||||
if instance.org_id != self.request.profile.org_id:
|
||||
raise PermissionDenied("Cannot delete object from another organization")
|
||||
|
||||
# Only admins and creators can delete
|
||||
if self.request.profile.role == "ADMIN":
|
||||
return
|
||||
|
||||
if hasattr(instance, "created_by_id"):
|
||||
if instance.created_by_id == self.request.profile.user_id:
|
||||
return
|
||||
|
||||
raise PermissionDenied("You don't have permission to delete this object")
|
||||
|
||||
|
||||
class OrgViewMixin(OrgFilterMixin, OrgCreateMixin, OrgUpdateMixin, OrgDeleteMixin):
|
||||
"""
|
||||
Combined mixin for standard CRUD operations with org isolation.
|
||||
|
||||
Usage:
|
||||
class LeadView(OrgViewMixin, APIView):
|
||||
model = Lead
|
||||
serializer_class = LeadSerializer
|
||||
|
||||
def get(self, request, pk=None):
|
||||
if pk:
|
||||
instance = self.get_org_object(pk)
|
||||
return Response(self.serializer_class(instance).data)
|
||||
queryset = self.filter_by_role(self.get_org_queryset())
|
||||
return Response(self.serializer_class(queryset, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.serializer_class(data=request.data)
|
||||
if serializer.is_valid():
|
||||
instance = self.perform_org_create(serializer)
|
||||
return Response(self.serializer_class(instance).data)
|
||||
|
||||
def put(self, request, pk):
|
||||
instance = self.get_org_object(pk)
|
||||
self.validate_update_permission(instance)
|
||||
serializer = self.serializer_class(instance, data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
def delete(self, request, pk):
|
||||
instance = self.get_org_object(pk)
|
||||
self.validate_delete_permission(instance)
|
||||
instance.delete()
|
||||
return Response(status=204)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActivityTrackingMixin:
|
||||
"""
|
||||
Mixin to automatically track activities on CRUD operations.
|
||||
|
||||
Usage:
|
||||
class LeadView(ActivityTrackingMixin, OrgViewMixin, APIView):
|
||||
model = Lead
|
||||
entity_type = 'Lead'
|
||||
"""
|
||||
|
||||
entity_type = None # Override in subclass
|
||||
|
||||
def track_activity(self, action, instance, description=""):
|
||||
"""
|
||||
Create an activity record for the action.
|
||||
|
||||
Args:
|
||||
action: Action type (CREATE, UPDATE, DELETE, VIEW)
|
||||
instance: Model instance
|
||||
description: Optional description
|
||||
"""
|
||||
from common.models import Activity
|
||||
|
||||
if not self.entity_type:
|
||||
return
|
||||
|
||||
entity_name = ""
|
||||
if hasattr(instance, "name"):
|
||||
entity_name = instance.name
|
||||
elif hasattr(instance, "title"):
|
||||
entity_name = instance.title
|
||||
elif hasattr(instance, "first_name"):
|
||||
entity_name = f"{instance.first_name} {getattr(instance, 'last_name', '')}"
|
||||
|
||||
Activity.objects.create(
|
||||
user=self.request.profile,
|
||||
action=action,
|
||||
entity_type=self.entity_type,
|
||||
entity_id=instance.id,
|
||||
entity_name=entity_name[:255],
|
||||
description=description[:500] if description else "",
|
||||
org=self.request.profile.org,
|
||||
)
|
||||
|
||||
|
||||
class BulkOperationMixin:
|
||||
"""
|
||||
Mixin for bulk operations with org validation.
|
||||
|
||||
Usage:
|
||||
class LeadBulkView(BulkOperationMixin, OrgFilterMixin, APIView):
|
||||
model = Lead
|
||||
|
||||
def post(self, request):
|
||||
ids = request.data.get('ids', [])
|
||||
instances = self.get_bulk_objects(ids)
|
||||
# perform bulk operation
|
||||
"""
|
||||
|
||||
def get_bulk_objects(self, ids):
|
||||
"""
|
||||
Get multiple objects by ids, ensuring all belong to user's org.
|
||||
|
||||
Args:
|
||||
ids: List of primary keys
|
||||
|
||||
Returns:
|
||||
QuerySet of objects
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If any object belongs to different org
|
||||
"""
|
||||
queryset = self.model.objects.filter(pk__in=ids)
|
||||
|
||||
# Verify all objects belong to user's org
|
||||
for obj in queryset:
|
||||
if hasattr(obj, "org_id"):
|
||||
if obj.org_id != self.request.profile.org_id:
|
||||
raise PermissionDenied(
|
||||
"One or more objects do not belong to your organization"
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
def bulk_update(self, ids, update_data):
|
||||
"""
|
||||
Bulk update objects with org validation.
|
||||
|
||||
Args:
|
||||
ids: List of primary keys
|
||||
update_data: Dict of fields to update
|
||||
|
||||
Returns:
|
||||
Number of updated objects
|
||||
"""
|
||||
queryset = self.get_bulk_objects(ids)
|
||||
return queryset.update(**update_data)
|
||||
|
||||
def bulk_delete(self, ids):
|
||||
"""
|
||||
Bulk delete objects with org validation.
|
||||
|
||||
Args:
|
||||
ids: List of primary keys
|
||||
|
||||
Returns:
|
||||
Number of deleted objects
|
||||
"""
|
||||
queryset = self.get_bulk_objects(ids)
|
||||
count = queryset.count()
|
||||
queryset.delete()
|
||||
return count
|
||||
|
|
@ -3,15 +3,18 @@ import datetime
|
|||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import arrow
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
|
||||
from .manager import UserManager
|
||||
from django.contrib.auth.models import AbstractBaseUser, AbstractUser, PermissionsMixin
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from common.base import BaseModel
|
||||
from common.templatetags.common_tags import (
|
||||
is_document_file_audio,
|
||||
is_document_file_code,
|
||||
|
|
@ -23,7 +26,8 @@ from common.templatetags.common_tags import (
|
|||
is_document_file_zip,
|
||||
)
|
||||
from common.utils import COUNTRIES, ROLES
|
||||
from common.base import BaseModel
|
||||
|
||||
from .manager import UserManager
|
||||
|
||||
|
||||
def img_url(self, filename):
|
||||
|
|
@ -36,18 +40,15 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
)
|
||||
email = models.EmailField(_("email address"), blank=True, unique=True)
|
||||
profile_pic = models.CharField(
|
||||
max_length=1000, null=True, blank=True
|
||||
)
|
||||
profile_pic = models.CharField(max_length=1000, null=True, blank=True)
|
||||
activation_key = models.CharField(max_length=150, null=True, blank=True)
|
||||
key_expires = models.DateTimeField(null=True, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_staff = models.BooleanField(_('staff status'),default=False)
|
||||
is_staff = models.BooleanField(_("staff status"), default=False)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -64,6 +65,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||
# self.key_expires = timezone.now() + datetime.timedelta(hours=2)
|
||||
# super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
address_line = models.CharField(
|
||||
_("Address"), max_length=255, blank=True, default=""
|
||||
|
|
@ -75,6 +77,11 @@ class Address(BaseModel):
|
|||
_("Post/Zip-code"), max_length=64, blank=True, default=""
|
||||
)
|
||||
country = models.CharField(max_length=3, choices=COUNTRIES, blank=True, default="")
|
||||
org = models.ForeignKey(
|
||||
"Org",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="addresses",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Address"
|
||||
|
|
@ -116,14 +123,14 @@ class Address(BaseModel):
|
|||
address += self.get_country_display()
|
||||
return address
|
||||
|
||||
|
||||
def generate_unique_key():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Org(BaseModel):
|
||||
name = models.CharField(max_length=100, blank=True, null=True)
|
||||
api_key = models.TextField(
|
||||
default=generate_unique_key, unique=True, editable=False
|
||||
)
|
||||
api_key = models.TextField(default=generate_unique_key, unique=True, editable=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
# address = models.TextField(blank=True, null=True)
|
||||
# user_limit = models.IntegerField(default=5)
|
||||
|
|
@ -139,6 +146,32 @@ class Org(BaseModel):
|
|||
return str(self.name)
|
||||
|
||||
|
||||
class Tags(BaseModel):
|
||||
"""Tags for categorizing CRM entities (Accounts, Leads, Opportunities)"""
|
||||
|
||||
name = models.CharField(max_length=20)
|
||||
slug = models.CharField(max_length=20, blank=True)
|
||||
org = models.ForeignKey(
|
||||
"Org",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="tags",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Tag"
|
||||
verbose_name_plural = "Tags"
|
||||
db_table = "tags"
|
||||
ordering = ("-created_at",)
|
||||
unique_together = ["slug", "org"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
# class User(AbstractBaseUser, PermissionsMixin):
|
||||
# email = models.EmailField(_("email address"), blank=True, unique=True)
|
||||
# profile_pic = models.FileField(
|
||||
|
|
@ -185,18 +218,14 @@ class Org(BaseModel):
|
|||
# super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
org = models.ForeignKey(
|
||||
Org, null=True, on_delete=models.CASCADE, blank=True, related_name="user_org"
|
||||
)
|
||||
phone = PhoneNumberField(null=True, unique=True)
|
||||
alternate_phone = PhoneNumberField(null=True,blank=True)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="profiles")
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="profiles")
|
||||
phone = PhoneNumberField(null=True, blank=True)
|
||||
alternate_phone = PhoneNumberField(null=True, blank=True)
|
||||
address = models.ForeignKey(
|
||||
Address,
|
||||
related_name="adress_users",
|
||||
related_name="address_users",
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
|
|
@ -213,7 +242,7 @@ class Profile(BaseModel):
|
|||
verbose_name_plural = "Profiles"
|
||||
db_table = "profile"
|
||||
ordering = ("-created_at",)
|
||||
unique_together = ["user", "org"]
|
||||
unique_together = [["user", "org"], ["phone", "org"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} <{self.org.name}>"
|
||||
|
|
@ -224,85 +253,37 @@ class Profile(BaseModel):
|
|||
|
||||
@property
|
||||
def user_details(self):
|
||||
return {
|
||||
'email' : self.user.email,
|
||||
'id' : self.user.id,
|
||||
'is_active' : self.user.is_active,
|
||||
'profile_pic' : self.user.profile_pic
|
||||
return {
|
||||
"email": self.user.email,
|
||||
"id": self.user.id,
|
||||
"is_active": self.user.is_active,
|
||||
"profile_pic": self.user.profile_pic,
|
||||
"last_login": self.user.last_login,
|
||||
}
|
||||
|
||||
|
||||
class Comment(BaseModel):
|
||||
case = models.ForeignKey(
|
||||
"cases.Case",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="cases",
|
||||
on_delete=models.CASCADE,
|
||||
"""
|
||||
Generic comment model using ContentType framework.
|
||||
Can be attached to any model (Account, Lead, Contact, Opportunity, Case, Task, Invoice, Profile).
|
||||
"""
|
||||
|
||||
# Generic relation to any model
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, related_name="comments"
|
||||
)
|
||||
object_id = models.UUIDField()
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
comment = models.CharField(max_length=255)
|
||||
commented_on = models.DateTimeField(auto_now_add=True)
|
||||
commented_by = models.ForeignKey(
|
||||
Profile, on_delete=models.CASCADE, blank=True, null=True
|
||||
)
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="accounts_comments",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
lead = models.ForeignKey(
|
||||
"leads.Lead",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="leads_comments",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
opportunity = models.ForeignKey(
|
||||
"opportunity.Opportunity",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="opportunity_comments",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
"contacts.Contact",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="contact_comments",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
profile = models.ForeignKey(
|
||||
"Profile",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="user_comments",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
task = models.ForeignKey(
|
||||
"tasks.Task",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="tasks_comments",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
invoice = models.ForeignKey(
|
||||
"invoices.Invoice",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="invoice_comments",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
event = models.ForeignKey(
|
||||
"events.Event",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="events_comments",
|
||||
org = models.ForeignKey(
|
||||
"Org",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="comments",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -310,6 +291,10 @@ class Comment(BaseModel):
|
|||
verbose_name_plural = "Comments"
|
||||
db_table = "comment"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.comment}"
|
||||
|
|
@ -321,10 +306,30 @@ class Comment(BaseModel):
|
|||
def commented_on_arrow(self):
|
||||
return arrow.get(self.commented_on).humanize()
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validate that the comment's org matches the content object's org.
|
||||
|
||||
SECURITY: This prevents cross-org data references where a comment
|
||||
in org_a could reference an object in org_b.
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.content_object and hasattr(self.content_object, "org"):
|
||||
if self.content_object.org_id != self.org_id:
|
||||
raise ValidationError(
|
||||
{
|
||||
"org": "Comment organization must match the referenced object's organization."
|
||||
}
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CommentFiles(BaseModel):
|
||||
comment = models.ForeignKey(Comment, on_delete=models.CASCADE)
|
||||
updated_on = models.DateTimeField(auto_now_add=True)
|
||||
comment_file = models.FileField(
|
||||
"File", upload_to="CommentFiles", null=True, blank=True
|
||||
)
|
||||
|
|
@ -346,72 +351,24 @@ class CommentFiles(BaseModel):
|
|||
|
||||
|
||||
class Attachments(BaseModel):
|
||||
created_by = models.ForeignKey(
|
||||
User,
|
||||
related_name="attachment_created_by",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
"""
|
||||
Generic attachment model using ContentType framework.
|
||||
Can be attached to any model (Account, Lead, Contact, Opportunity, Case, Task, Invoice).
|
||||
"""
|
||||
|
||||
# Generic relation to any model
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, related_name="attachments"
|
||||
)
|
||||
object_id = models.UUIDField()
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
file_name = models.CharField(max_length=60)
|
||||
attachment = models.FileField(max_length=1001, upload_to="attachments/%Y/%m/")
|
||||
lead = models.ForeignKey(
|
||||
"leads.Lead",
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="lead_attachment",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account",
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="account_attachment",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
"contacts.Contact",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="contact_attachment",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
opportunity = models.ForeignKey(
|
||||
"opportunity.Opportunity",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="opportunity_attachment",
|
||||
)
|
||||
case = models.ForeignKey(
|
||||
"cases.Case",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="case_attachment",
|
||||
)
|
||||
|
||||
task = models.ForeignKey(
|
||||
"tasks.Task",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="tasks_attachment",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
invoice = models.ForeignKey(
|
||||
"invoices.Invoice",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="invoice_attachment",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
"events.Event",
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="events_attachment",
|
||||
org = models.ForeignKey(
|
||||
"Org",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attachments",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -419,6 +376,10 @@ class Attachments(BaseModel):
|
|||
verbose_name_plural = "Attachments"
|
||||
db_table = "attachments"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.file_name}"
|
||||
|
|
@ -455,6 +416,27 @@ class Attachments(BaseModel):
|
|||
def created_on_arrow(self):
|
||||
return arrow.get(self.created_at).humanize()
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validate that the attachment's org matches the content object's org.
|
||||
|
||||
SECURITY: This prevents cross-org data references where an attachment
|
||||
in org_a could reference an object in org_b.
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.content_object and hasattr(self.content_object, "org"):
|
||||
if self.content_object.org_id != self.org_id:
|
||||
raise ValidationError(
|
||||
{
|
||||
"org": "Attachment organization must match the referenced object's organization."
|
||||
}
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def document_path(self, filename):
|
||||
hash_ = int(time.time())
|
||||
|
|
@ -467,25 +449,15 @@ class Document(BaseModel):
|
|||
|
||||
title = models.TextField(blank=True, null=True)
|
||||
document_file = models.FileField(upload_to=document_path, max_length=5000)
|
||||
created_by = models.ForeignKey(
|
||||
Profile,
|
||||
related_name="document_uploaded",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
choices=DOCUMENT_STATUS_CHOICE, max_length=64, default="active"
|
||||
)
|
||||
shared_to = models.ManyToManyField(Profile, related_name="document_shared_to")
|
||||
teams = models.ManyToManyField("teams.Teams", related_name="document_teams")
|
||||
teams = models.ManyToManyField("Teams", related_name="document_teams")
|
||||
org = models.ForeignKey(
|
||||
Org,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="document_org",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="documents",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -496,7 +468,7 @@ class Document(BaseModel):
|
|||
|
||||
def __str__(self):
|
||||
return f"{self.title}"
|
||||
|
||||
|
||||
def file_type(self):
|
||||
name_ext_list = self.document_file.url.split(".")
|
||||
if len(name_ext_list) > 1:
|
||||
|
|
@ -555,22 +527,12 @@ class APISettings(BaseModel):
|
|||
lead_assigned_to = models.ManyToManyField(
|
||||
Profile, related_name="lead_assignee_users"
|
||||
)
|
||||
tags = models.ManyToManyField("accounts.Tags", blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
Profile,
|
||||
related_name="settings_created_by",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
tags = models.ManyToManyField(Tags, blank=True)
|
||||
org = models.ForeignKey(
|
||||
Org,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="org_api_settings",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="api_settings",
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
verbose_name = "APISetting"
|
||||
|
|
@ -580,9 +542,236 @@ class APISettings(BaseModel):
|
|||
|
||||
def __str__(self):
|
||||
return f"{self.title}"
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.apikey or self.apikey is None or self.apikey == "":
|
||||
self.apikey = generate_key()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
# Phase 3: JWT Token Tracking
|
||||
|
||||
|
||||
class SessionToken(BaseModel):
|
||||
"""Track active JWT sessions for security"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, related_name="session_tokens"
|
||||
)
|
||||
token_jti = models.CharField(max_length=255, unique=True, db_index=True) # JWT ID
|
||||
refresh_token_jti = models.CharField(
|
||||
max_length=255, unique=True, db_index=True, null=True, blank=True
|
||||
)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True, null=True)
|
||||
expires_at = models.DateTimeField()
|
||||
is_active = models.BooleanField(default=True)
|
||||
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||
last_used_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Session Token"
|
||||
verbose_name_plural = "Session Tokens"
|
||||
db_table = "session_token"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["user", "is_active"]),
|
||||
models.Index(fields=["token_jti"]),
|
||||
models.Index(fields=["expires_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.token_jti[:8]}..."
|
||||
|
||||
def revoke(self):
|
||||
"""Revoke this session token"""
|
||||
from django.utils import timezone
|
||||
|
||||
self.is_active = False
|
||||
self.revoked_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Check if token is expired"""
|
||||
from django.utils import timezone
|
||||
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Remove expired tokens (call via cron/celery)"""
|
||||
from django.utils import timezone
|
||||
|
||||
return cls.objects.filter(expires_at__lt=timezone.now()).delete()
|
||||
|
||||
|
||||
# Activity Tracking for Recent Activities Dashboard
|
||||
|
||||
|
||||
class Activity(BaseModel):
|
||||
"""Track user activities across all CRM entities"""
|
||||
|
||||
ACTION_CHOICES = (
|
||||
("CREATE", "Created"),
|
||||
("UPDATE", "Updated"),
|
||||
("DELETE", "Deleted"),
|
||||
("VIEW", "Viewed"),
|
||||
("COMMENT", "Commented"),
|
||||
("ASSIGN", "Assigned"),
|
||||
)
|
||||
|
||||
ENTITY_TYPE_CHOICES = (
|
||||
("Account", "Account"),
|
||||
("Lead", "Lead"),
|
||||
("Contact", "Contact"),
|
||||
("Opportunity", "Opportunity"),
|
||||
("Case", "Case"),
|
||||
("Task", "Task"),
|
||||
("Invoice", "Invoice"),
|
||||
("Event", "Event"),
|
||||
("Document", "Document"),
|
||||
("Team", "Team"),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
Profile,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="activities",
|
||||
)
|
||||
action = models.CharField(max_length=20, choices=ACTION_CHOICES)
|
||||
entity_type = models.CharField(max_length=50, choices=ENTITY_TYPE_CHOICES)
|
||||
entity_id = models.UUIDField()
|
||||
entity_name = models.CharField(max_length=255, blank=True, default="")
|
||||
description = models.TextField(blank=True, default="")
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="activities")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Activity"
|
||||
verbose_name_plural = "Activities"
|
||||
db_table = "activity"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
models.Index(fields=["entity_type", "entity_id"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} {self.get_action_display()} {self.entity_type}: {self.entity_name}"
|
||||
|
||||
@property
|
||||
def created_on_arrow(self):
|
||||
return arrow.get(self.created_at).humanize()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Teams Model (merged from teams app)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Teams(BaseModel):
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.TextField()
|
||||
users = models.ManyToManyField(Profile, related_name="user_teams")
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="teams")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Team"
|
||||
verbose_name_plural = "Teams"
|
||||
db_table = "teams"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
@property
|
||||
def created_on_arrow(self):
|
||||
return arrow.get(self.created_at).humanize()
|
||||
|
||||
def get_users(self):
|
||||
return ",".join(
|
||||
[str(_id) for _id in list(self.users.values_list("id", flat=True))]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Contact Form Submission (for public website)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ContactFormSubmission(BaseModel):
|
||||
"""
|
||||
Contact form submission from public website.
|
||||
Stores inquiries from potential customers via contact forms.
|
||||
Not org-scoped as these are platform-level submissions.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
email = models.EmailField()
|
||||
message = models.TextField()
|
||||
reason = models.CharField(
|
||||
max_length=100,
|
||||
choices=[
|
||||
("general", "General Inquiry"),
|
||||
("sales", "Sales Question"),
|
||||
("support", "Technical Support"),
|
||||
("partnership", "Partnership Opportunity"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="general",
|
||||
)
|
||||
|
||||
# Tracking fields
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(null=True, blank=True)
|
||||
referrer = models.URLField(max_length=500, null=True, blank=True)
|
||||
|
||||
# Status tracking
|
||||
STATUS_CHOICES = [
|
||||
("new", "New"),
|
||||
("read", "Read"),
|
||||
("replied", "Replied"),
|
||||
("closed", "Closed"),
|
||||
]
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new")
|
||||
replied_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="contact_replies",
|
||||
)
|
||||
replied_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Contact Form Submission"
|
||||
verbose_name_plural = "Contact Form Submissions"
|
||||
db_table = "contact_form_submission"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["email"]),
|
||||
models.Index(fields=["status"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.email} ({self.reason})"
|
||||
|
||||
def mark_as_read(self):
|
||||
"""Mark submission as read"""
|
||||
if self.status == "new":
|
||||
self.status = "read"
|
||||
self.save()
|
||||
|
||||
def mark_as_replied(self, user):
|
||||
"""Mark submission as replied"""
|
||||
self.status = "replied"
|
||||
self.replied_by = user
|
||||
self.replied_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
|
||||
# Import SecurityAuditLog so Django discovers it for migrations
|
||||
from common.audit_log import SecurityAuditLog
|
||||
236
backend/common/permissions.py
Normal file
236
backend/common/permissions.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""
|
||||
Custom Permission Classes for Multi-Tenancy Security
|
||||
|
||||
These permission classes enforce organization context and access control
|
||||
across all API endpoints.
|
||||
"""
|
||||
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
|
||||
class HasOrgContext(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires valid organization context.
|
||||
|
||||
This should be used on all endpoints that require org-scoped data access.
|
||||
It verifies that:
|
||||
1. User is authenticated
|
||||
2. request.profile is set (from middleware)
|
||||
3. request.org is set (from JWT or API key)
|
||||
|
||||
Usage:
|
||||
class MyView(APIView):
|
||||
permission_classes = [IsAuthenticated, HasOrgContext]
|
||||
"""
|
||||
|
||||
message = "Organization context is required. Please login again."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
# Must have profile set by middleware
|
||||
if not hasattr(request, "profile") or request.profile is None:
|
||||
return False
|
||||
|
||||
# Must have org set
|
||||
if not hasattr(request, "org") or request.org is None:
|
||||
return False
|
||||
|
||||
# Profile must be active
|
||||
if not request.profile.is_active:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class IsOrgAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires user to be an organization admin.
|
||||
|
||||
Usage:
|
||||
class AdminOnlyView(APIView):
|
||||
permission_classes = [IsAuthenticated, HasOrgContext, IsOrgAdmin]
|
||||
"""
|
||||
|
||||
message = "You must be an organization administrator to perform this action."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not hasattr(request, "profile") or request.profile is None:
|
||||
return False
|
||||
|
||||
return request.profile.role == "ADMIN" or request.profile.is_organization_admin
|
||||
|
||||
|
||||
class IsOrgMember(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that verifies user belongs to the organization.
|
||||
|
||||
This is a safety check that complements the middleware validation.
|
||||
"""
|
||||
|
||||
message = "You are not a member of this organization."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not hasattr(request, "profile") or request.profile is None:
|
||||
return False
|
||||
|
||||
if not hasattr(request, "org") or request.org is None:
|
||||
return False
|
||||
|
||||
# Verify profile org matches request org
|
||||
return request.profile.org_id == request.org.id
|
||||
|
||||
|
||||
class HasSalesAccess(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires sales module access.
|
||||
|
||||
Usage:
|
||||
class LeadsView(APIView):
|
||||
permission_classes = [IsAuthenticated, HasOrgContext, HasSalesAccess]
|
||||
"""
|
||||
|
||||
message = "You do not have access to the sales module."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not hasattr(request, "profile") or request.profile is None:
|
||||
return False
|
||||
|
||||
# Admins always have access
|
||||
if request.profile.role == "ADMIN":
|
||||
return True
|
||||
|
||||
return request.profile.has_sales_access
|
||||
|
||||
|
||||
class HasMarketingAccess(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires marketing module access.
|
||||
|
||||
Usage:
|
||||
class CampaignsView(APIView):
|
||||
permission_classes = [IsAuthenticated, HasOrgContext, HasMarketingAccess]
|
||||
"""
|
||||
|
||||
message = "You do not have access to the marketing module."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not hasattr(request, "profile") or request.profile is None:
|
||||
return False
|
||||
|
||||
# Admins always have access
|
||||
if request.profile.role == "ADMIN":
|
||||
return True
|
||||
|
||||
return request.profile.has_marketing_access
|
||||
|
||||
|
||||
class CanAccessObject(permissions.BasePermission):
|
||||
"""
|
||||
Object-level permission that verifies user can access specific object.
|
||||
|
||||
Checks that:
|
||||
1. Object belongs to user's organization
|
||||
2. User is assigned to object OR is admin OR created the object
|
||||
|
||||
Usage:
|
||||
class LeadDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated, HasOrgContext, CanAccessObject]
|
||||
|
||||
def get_object(self):
|
||||
return Lead.objects.get(pk=self.kwargs['pk'])
|
||||
"""
|
||||
|
||||
message = "You do not have permission to access this object."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# Object must belong to same org
|
||||
if hasattr(obj, "org_id"):
|
||||
if obj.org_id != request.org.id:
|
||||
return False
|
||||
|
||||
# Admins can access all objects in their org
|
||||
if request.profile.role == "ADMIN":
|
||||
return True
|
||||
|
||||
# Check if user created the object
|
||||
if hasattr(obj, "created_by_id"):
|
||||
if obj.created_by_id == request.profile.user_id:
|
||||
return True
|
||||
|
||||
# Check if user is assigned to the object
|
||||
if hasattr(obj, "assigned_to"):
|
||||
if request.profile in obj.assigned_to.all():
|
||||
return True
|
||||
|
||||
# Check if user is in a team assigned to the object
|
||||
if hasattr(obj, "teams"):
|
||||
user_team_ids = request.profile.user_teams.values_list("id", flat=True)
|
||||
if obj.teams.filter(id__in=user_team_ids).exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsSuperAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Permission class for platform-level super admins.
|
||||
|
||||
Super admins are identified by @micropyramid.com email domain.
|
||||
They have access to admin panel and can manage all organizations.
|
||||
"""
|
||||
|
||||
message = "Super admin access required."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Check for super admin email domain
|
||||
return request.user.email.endswith("@micropyramid.com")
|
||||
|
||||
|
||||
def get_org_filtered_queryset(model, request):
|
||||
"""
|
||||
Helper function to get organization-filtered queryset.
|
||||
|
||||
Usage:
|
||||
queryset = get_org_filtered_queryset(Lead, request)
|
||||
|
||||
Args:
|
||||
model: Django model class with org field
|
||||
request: DRF request object with profile attached
|
||||
|
||||
Returns:
|
||||
QuerySet filtered by organization
|
||||
"""
|
||||
if not hasattr(request, "profile") or request.profile is None:
|
||||
return model.objects.none()
|
||||
|
||||
return model.objects.filter(org=request.profile.org)
|
||||
|
||||
|
||||
def validate_org_ownership(obj, request):
|
||||
"""
|
||||
Helper function to validate object belongs to user's org.
|
||||
|
||||
Raises PermissionDenied if object doesn't belong to user's org.
|
||||
|
||||
Usage:
|
||||
lead = Lead.objects.get(pk=pk)
|
||||
validate_org_ownership(lead, request)
|
||||
|
||||
Args:
|
||||
obj: Model instance with org field
|
||||
request: DRF request object
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If object doesn't belong to user's org
|
||||
"""
|
||||
if not hasattr(request, "profile") or request.profile is None:
|
||||
raise PermissionDenied("Organization context required")
|
||||
|
||||
if not hasattr(obj, "org_id"):
|
||||
return # Object doesn't have org field
|
||||
|
||||
if obj.org_id != request.profile.org_id:
|
||||
raise PermissionDenied("Object does not belong to your organization")
|
||||
214
backend/common/rls/__init__.py
Normal file
214
backend/common/rls/__init__.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""
|
||||
Row-Level Security (RLS) Configuration for PostgreSQL.
|
||||
|
||||
This module provides centralized configuration and SQL generators for
|
||||
database-level multi-tenancy using PostgreSQL Row-Level Security.
|
||||
|
||||
RLS ensures that:
|
||||
1. Queries automatically filter by current org
|
||||
2. Even if application code misses a filter, data is protected
|
||||
3. Direct SQL access respects org boundaries
|
||||
|
||||
Usage:
|
||||
from common.rls import RLS_CONFIG, get_enable_policy_sql
|
||||
|
||||
# Enable RLS on a table
|
||||
cursor.execute(get_enable_policy_sql('lead'))
|
||||
|
||||
# Check config
|
||||
tables = RLS_CONFIG['tables']
|
||||
"""
|
||||
|
||||
# PostgreSQL session variable for org context
|
||||
# Set via: SELECT set_config('app.current_org', '<org_id>', true)
|
||||
CONTEXT_VARIABLE = "app.current_org"
|
||||
|
||||
# Policy names
|
||||
ISOLATION_POLICY = "org_isolation"
|
||||
INSERT_POLICY = "org_insert_check"
|
||||
|
||||
# All org-scoped tables that need RLS policies
|
||||
# Add new org-scoped tables here
|
||||
# NOTE: Only include tables that have an org_id column directly
|
||||
# Table names must match actual PostgreSQL table names (check via \dt)
|
||||
ORG_SCOPED_TABLES = [
|
||||
# Core business entities
|
||||
"lead",
|
||||
"accounts", # Note: plural
|
||||
"contacts", # Note: plural
|
||||
"opportunity",
|
||||
"case", # Note: singular
|
||||
"task",
|
||||
"invoice",
|
||||
# Supporting entities
|
||||
"comment",
|
||||
"attachments",
|
||||
"document",
|
||||
"teams",
|
||||
"activity",
|
||||
"tags",
|
||||
"address",
|
||||
"solution",
|
||||
# Boards (Kanban)
|
||||
"board",
|
||||
"board_column",
|
||||
"board_task",
|
||||
"board_member",
|
||||
# Settings
|
||||
"apiSettings", # Note: camelCase
|
||||
# Email & Invoicing
|
||||
"account_email",
|
||||
"emailLogs",
|
||||
"invoice_history",
|
||||
# Security & Audit
|
||||
"security_audit_log",
|
||||
]
|
||||
|
||||
# Centralized RLS configuration
|
||||
RLS_CONFIG = {
|
||||
"context_variable": CONTEXT_VARIABLE,
|
||||
"tables": ORG_SCOPED_TABLES,
|
||||
"policies": {
|
||||
"isolation": ISOLATION_POLICY,
|
||||
"insert": INSERT_POLICY,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_check_table_exists_sql():
|
||||
"""
|
||||
Returns SQL to check if a table exists.
|
||||
|
||||
Usage:
|
||||
cursor.execute(get_check_table_exists_sql(), [table_name])
|
||||
exists = cursor.fetchone()[0]
|
||||
"""
|
||||
return """
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = %s
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def get_check_rls_status_sql():
|
||||
"""
|
||||
Returns SQL to check RLS status for a table.
|
||||
|
||||
Usage:
|
||||
cursor.execute(get_check_rls_status_sql(), [table_name])
|
||||
rls_enabled, rls_forced = cursor.fetchone()
|
||||
"""
|
||||
return """
|
||||
SELECT relrowsecurity, relforcerowsecurity
|
||||
FROM pg_class
|
||||
WHERE relname = %s AND relnamespace = 'public'::regnamespace
|
||||
"""
|
||||
|
||||
|
||||
def get_set_context_sql():
|
||||
"""
|
||||
Returns SQL to set the org context.
|
||||
|
||||
Usage:
|
||||
cursor.execute(get_set_context_sql(), [org_id])
|
||||
"""
|
||||
return f"SELECT set_config('{CONTEXT_VARIABLE}', %s, true)"
|
||||
|
||||
|
||||
def get_enable_policy_sql(table):
|
||||
"""
|
||||
Returns SQL to enable RLS on a table with proper policies.
|
||||
|
||||
This creates:
|
||||
1. Enables RLS on the table
|
||||
2. Forces RLS for table owner
|
||||
3. Creates isolation policy for SELECT/UPDATE/DELETE
|
||||
4. Creates insert check policy
|
||||
|
||||
Args:
|
||||
table: Table name
|
||||
|
||||
Returns:
|
||||
SQL string to execute
|
||||
"""
|
||||
return f"""
|
||||
-- Enable RLS on table
|
||||
ALTER TABLE "{table}" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "{table}" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- Drop existing policies if any
|
||||
DROP POLICY IF EXISTS {ISOLATION_POLICY} ON "{table}";
|
||||
DROP POLICY IF EXISTS {INSERT_POLICY} ON "{table}";
|
||||
|
||||
-- Create isolation policy (SELECT, UPDATE, DELETE)
|
||||
-- Uses NULLIF to return no rows when context is not set (fail-safe)
|
||||
CREATE POLICY {ISOLATION_POLICY} ON "{table}"
|
||||
FOR ALL
|
||||
USING (
|
||||
org_id::text = NULLIF(current_setting('{CONTEXT_VARIABLE}', true), '')
|
||||
);
|
||||
|
||||
-- Create insert check policy
|
||||
CREATE POLICY {INSERT_POLICY} ON "{table}"
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
org_id::text = NULLIF(current_setting('{CONTEXT_VARIABLE}', true), '')
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def get_disable_policy_sql(table):
|
||||
"""
|
||||
Returns SQL to disable RLS on a table.
|
||||
|
||||
Args:
|
||||
table: Table name
|
||||
|
||||
Returns:
|
||||
SQL string to execute
|
||||
"""
|
||||
return f"""
|
||||
-- Drop policies
|
||||
DROP POLICY IF EXISTS {ISOLATION_POLICY} ON "{table}";
|
||||
DROP POLICY IF EXISTS {INSERT_POLICY} ON "{table}";
|
||||
|
||||
-- Disable RLS
|
||||
ALTER TABLE "{table}" DISABLE ROW LEVEL SECURITY;
|
||||
"""
|
||||
|
||||
|
||||
def get_update_policy_sql(table):
|
||||
"""
|
||||
Returns SQL to update RLS policies with secure defaults.
|
||||
|
||||
This fixes the security issue where empty context allowed all data.
|
||||
Uses NULLIF() to ensure no rows are returned when context is missing.
|
||||
|
||||
Args:
|
||||
table: Table name
|
||||
|
||||
Returns:
|
||||
SQL string to execute
|
||||
"""
|
||||
return f"""
|
||||
-- Drop existing policies
|
||||
DROP POLICY IF EXISTS {ISOLATION_POLICY} ON "{table}";
|
||||
DROP POLICY IF EXISTS {INSERT_POLICY} ON "{table}";
|
||||
|
||||
-- Create secure isolation policy
|
||||
-- NULLIF returns NULL when context is empty, causing comparison to fail
|
||||
CREATE POLICY {ISOLATION_POLICY} ON "{table}"
|
||||
FOR ALL
|
||||
USING (
|
||||
org_id::text = NULLIF(current_setting('{CONTEXT_VARIABLE}', true), '')
|
||||
);
|
||||
|
||||
-- Create secure insert check policy
|
||||
CREATE POLICY {INSERT_POLICY} ON "{table}"
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
org_id::text = NULLIF(current_setting('{CONTEXT_VARIABLE}', true), '')
|
||||
);
|
||||
"""
|
||||
682
backend/common/serializer.py
Normal file
682
backend/common/serializer.py
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
import re
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.utils.http import urlsafe_base64_decode
|
||||
from rest_framework import serializers
|
||||
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
|
||||
|
||||
from common.models import (
|
||||
Activity,
|
||||
Address,
|
||||
APISettings,
|
||||
Attachments,
|
||||
Comment,
|
||||
Document,
|
||||
Org,
|
||||
Profile,
|
||||
Teams,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class OrgAwareRefreshToken(RefreshToken):
|
||||
"""
|
||||
Custom RefreshToken that includes org_id in the token payload.
|
||||
|
||||
This ensures the org context is cryptographically signed and cannot be
|
||||
forged by the client. The middleware should validate org_id from this
|
||||
token instead of trusting the org header.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def for_user_and_org(cls, user, org):
|
||||
"""
|
||||
Generate a refresh token for a user with org context.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
org: Org instance or org_id UUID
|
||||
|
||||
Returns:
|
||||
OrgAwareRefreshToken with org_id claim
|
||||
"""
|
||||
token = cls.for_user(user)
|
||||
|
||||
# Add org_id to the token payload
|
||||
if org:
|
||||
org_id = str(org.id) if hasattr(org, "id") else str(org)
|
||||
token["org_id"] = org_id
|
||||
|
||||
return token
|
||||
|
||||
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Org
|
||||
fields = ("id", "name", "api_key")
|
||||
|
||||
|
||||
class SocialLoginSerializer(serializers.Serializer):
|
||||
token = serializers.CharField()
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Comment model using ContentType"""
|
||||
|
||||
content_type = serializers.SlugRelatedField(slug_field="model", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = (
|
||||
"id",
|
||||
"comment",
|
||||
"commented_on",
|
||||
"commented_by",
|
||||
"content_type",
|
||||
"object_id",
|
||||
"org",
|
||||
)
|
||||
|
||||
|
||||
class CommentCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating comments with ContentType"""
|
||||
|
||||
content_type = serializers.CharField(write_only=True)
|
||||
object_id = serializers.UUIDField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = (
|
||||
"comment",
|
||||
"content_type",
|
||||
"object_id",
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
content_type_str = validated_data.pop("content_type")
|
||||
try:
|
||||
content_type = ContentType.objects.get(model=content_type_str.lower())
|
||||
except ContentType.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid content type: {content_type_str}"
|
||||
)
|
||||
|
||||
validated_data["content_type"] = content_type
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class LeadCommentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = (
|
||||
"id",
|
||||
"comment",
|
||||
"commented_on",
|
||||
"commented_by",
|
||||
)
|
||||
|
||||
|
||||
class OrgProfileCreateSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
It is for creating organization
|
||||
"""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
model = Org
|
||||
fields = ["name"]
|
||||
extra_kwargs = {"name": {"required": True}}
|
||||
|
||||
def validate_name(self, name):
|
||||
if bool(re.search(r"[~\!_.@#\$%\^&\*\ \(\)\+{}\":;'/\[\]]", name)):
|
||||
raise serializers.ValidationError(
|
||||
"organization name should not contain any special characters"
|
||||
)
|
||||
if Org.objects.filter(name=name).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Organization already exists with this name"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
class ShowOrganizationListSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
we are using it for show orjanization list
|
||||
"""
|
||||
|
||||
org = OrganizationSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = (
|
||||
"role",
|
||||
"alternate_phone",
|
||||
"has_sales_access",
|
||||
"has_marketing_access",
|
||||
"is_organization_admin",
|
||||
"org",
|
||||
)
|
||||
|
||||
|
||||
class BillingAddressSerializer(serializers.ModelSerializer):
|
||||
country = serializers.SerializerMethodField()
|
||||
|
||||
def get_country(self, obj):
|
||||
return obj.get_country_display()
|
||||
|
||||
class Meta:
|
||||
model = Address
|
||||
fields = ("address_line", "street", "city", "state", "postcode", "country")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
account_view = kwargs.pop("account", False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if account_view:
|
||||
self.fields["address_line"].required = True
|
||||
self.fields["street"].required = True
|
||||
self.fields["city"].required = True
|
||||
self.fields["state"].required = True
|
||||
self.fields["postcode"].required = True
|
||||
self.fields["country"].required = True
|
||||
|
||||
|
||||
class CreateUserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
"email",
|
||||
"profile_pic",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.org = kwargs.pop("org", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["email"].required = True
|
||||
|
||||
def validate_email(self, email):
|
||||
if self.instance:
|
||||
if self.instance.email != email:
|
||||
if not Profile.objects.filter(user__email=email, org=self.org).exists():
|
||||
return email
|
||||
raise serializers.ValidationError("Email already exists")
|
||||
return email
|
||||
if not Profile.objects.filter(user__email=email.lower(), org=self.org).exists():
|
||||
return email
|
||||
raise serializers.ValidationError("Given Email id already exists")
|
||||
|
||||
|
||||
class CreateProfileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = (
|
||||
"role",
|
||||
"phone",
|
||||
"alternate_phone",
|
||||
"has_sales_access",
|
||||
"has_marketing_access",
|
||||
"is_organization_admin",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["alternate_phone"].required = False
|
||||
self.fields["role"].required = True
|
||||
self.fields["phone"].required = True
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "profile_pic"]
|
||||
|
||||
|
||||
class ProfileSerializer(serializers.ModelSerializer):
|
||||
# address = BillingAddressSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = (
|
||||
"id",
|
||||
"user_details",
|
||||
"role",
|
||||
"address",
|
||||
"has_marketing_access",
|
||||
"has_sales_access",
|
||||
"phone",
|
||||
"date_of_joining",
|
||||
"is_active",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
|
||||
class AttachmentsSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Attachments model using ContentType"""
|
||||
|
||||
file_path = serializers.SerializerMethodField()
|
||||
content_type = serializers.SlugRelatedField(slug_field="model", read_only=True)
|
||||
|
||||
def get_file_path(self, obj):
|
||||
if obj.attachment:
|
||||
return obj.attachment.url
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
model = Attachments
|
||||
fields = [
|
||||
"id",
|
||||
"created_by",
|
||||
"file_name",
|
||||
"created_at",
|
||||
"file_path",
|
||||
"content_type",
|
||||
"object_id",
|
||||
"org",
|
||||
]
|
||||
|
||||
|
||||
class AttachmentsCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating attachments with ContentType"""
|
||||
|
||||
content_type = serializers.CharField(write_only=True)
|
||||
object_id = serializers.UUIDField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Attachments
|
||||
fields = (
|
||||
"file_name",
|
||||
"attachment",
|
||||
"content_type",
|
||||
"object_id",
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
content_type_str = validated_data.pop("content_type")
|
||||
try:
|
||||
content_type = ContentType.objects.get(model=content_type_str.lower())
|
||||
except ContentType.DoesNotExist:
|
||||
raise serializers.ValidationError(
|
||||
f"Invalid content type: {content_type_str}"
|
||||
)
|
||||
|
||||
validated_data["content_type"] = content_type
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class DocumentSerializer(serializers.ModelSerializer):
|
||||
shared_to = ProfileSerializer(read_only=True, many=True)
|
||||
teams = serializers.SerializerMethodField()
|
||||
created_by = UserSerializer()
|
||||
org = OrganizationSerializer()
|
||||
|
||||
def get_teams(self, obj):
|
||||
return obj.teams.all().values()
|
||||
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"document_file",
|
||||
"status",
|
||||
"shared_to",
|
||||
"teams",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"org",
|
||||
]
|
||||
|
||||
|
||||
class DocumentCreateSerializer(serializers.ModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
request_obj = kwargs.pop("request_obj", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["title"].required = True
|
||||
self.org = request_obj.profile.org
|
||||
|
||||
def validate_title(self, title):
|
||||
if self.instance:
|
||||
if (
|
||||
Document.objects.filter(title__iexact=title, org=self.org)
|
||||
.exclude(id=self.instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Document with this Title already exists"
|
||||
)
|
||||
if Document.objects.filter(title__iexact=title, org=self.org).exists():
|
||||
raise serializers.ValidationError("Document with this Title already exists")
|
||||
return title
|
||||
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = ["title", "document_file", "status", "org"]
|
||||
|
||||
|
||||
def find_urls(string):
|
||||
# website_regex = "^((http|https)://)?([A-Za-z0-9.-]+\.[A-Za-z]{2,63})?$" # (http(s)://)google.com or google.com
|
||||
# website_regex = "^https?://([A-Za-z0-9.-]+\.[A-Za-z]{2,63})?$" # (http(s)://)google.com
|
||||
# http(s)://google.com
|
||||
website_regex = r"^https?://[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$"
|
||||
# http(s)://google.com:8000
|
||||
website_regex_port = r"^https?://[A-Za-z0-9.-]+\.[A-Za-z]{2,63}:[0-9]{2,4}$"
|
||||
url = re.findall(website_regex, string)
|
||||
url_port = re.findall(website_regex_port, string)
|
||||
if url and url[0] != "":
|
||||
return url
|
||||
return url_port
|
||||
|
||||
|
||||
class APISettingsSerializer(serializers.ModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = APISettings
|
||||
fields = ("title", "website")
|
||||
|
||||
def validate_website(self, website):
|
||||
if website and not (
|
||||
website.startswith("http://") or website.startswith("https://")
|
||||
):
|
||||
raise serializers.ValidationError("Please provide valid schema")
|
||||
if not len(find_urls(website)) > 0:
|
||||
raise serializers.ValidationError(
|
||||
"Please provide a valid URL with schema and without trailing slash - Example: http://google.com"
|
||||
)
|
||||
return website
|
||||
|
||||
|
||||
class APISettingsListSerializer(serializers.ModelSerializer):
|
||||
created_by = UserSerializer()
|
||||
lead_assigned_to = ProfileSerializer(read_only=True, many=True)
|
||||
tags = serializers.SerializerMethodField()
|
||||
org = OrganizationSerializer()
|
||||
|
||||
def get_tags(self, obj):
|
||||
return obj.tags.all().values()
|
||||
|
||||
class Meta:
|
||||
model = APISettings
|
||||
fields = [
|
||||
"title",
|
||||
"apikey",
|
||||
"website",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"lead_assigned_to",
|
||||
"tags",
|
||||
"org",
|
||||
]
|
||||
|
||||
|
||||
class APISettingsSwaggerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = APISettings
|
||||
fields = [
|
||||
"title",
|
||||
"website",
|
||||
"lead_assigned_to",
|
||||
"tags",
|
||||
]
|
||||
|
||||
|
||||
class DocumentCreateSwaggerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = [
|
||||
"title",
|
||||
"document_file",
|
||||
"teams",
|
||||
"shared_to",
|
||||
]
|
||||
|
||||
|
||||
class DocumentEditSwaggerSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = ["title", "document_file", "teams", "shared_to", "status"]
|
||||
|
||||
|
||||
class UserCreateSwaggerSerializer(serializers.Serializer):
|
||||
"""
|
||||
It is swagger for creating or updating user
|
||||
"""
|
||||
|
||||
ROLE_CHOICES = ["ADMIN", "USER"]
|
||||
|
||||
email = serializers.CharField(max_length=1000, required=True)
|
||||
role = serializers.ChoiceField(choices=ROLE_CHOICES, required=True)
|
||||
phone = serializers.CharField(max_length=12)
|
||||
alternate_phone = serializers.CharField(max_length=12)
|
||||
address_line = serializers.CharField(max_length=10000, required=True)
|
||||
street = serializers.CharField(max_length=1000)
|
||||
city = serializers.CharField(max_length=1000)
|
||||
state = serializers.CharField(max_length=1000)
|
||||
pincode = serializers.CharField(max_length=1000)
|
||||
country = serializers.CharField(max_length=1000)
|
||||
|
||||
|
||||
class UserUpdateStatusSwaggerSerializer(serializers.Serializer):
|
||||
|
||||
STATUS_CHOICES = ["Active", "Inactive"]
|
||||
|
||||
status = serializers.ChoiceField(choices=STATUS_CHOICES, required=True)
|
||||
|
||||
|
||||
# JWT Authentication Serializers for SvelteKit Integration
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
"""Serializer for user login with email and password"""
|
||||
|
||||
email = serializers.EmailField(required=True)
|
||||
password = serializers.CharField(required=True, write_only=True)
|
||||
|
||||
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 email or password")
|
||||
if not user.is_active:
|
||||
raise serializers.ValidationError("User account is disabled")
|
||||
else:
|
||||
raise serializers.ValidationError('Must include "email" and "password"')
|
||||
|
||||
attrs["user"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user registration"""
|
||||
|
||||
password = serializers.CharField(write_only=True, required=True, min_length=8)
|
||||
confirm_password = serializers.CharField(write_only=True, required=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["email", "password", "confirm_password"]
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs["password"] != attrs["confirm_password"]:
|
||||
raise serializers.ValidationError(
|
||||
{"password": "Password fields didn't match."}
|
||||
)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop("confirm_password")
|
||||
user = User.objects.create_user(
|
||||
email=validated_data["email"],
|
||||
password=validated_data["password"],
|
||||
is_active=False, # User needs to activate account
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
class UserDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed user serializer with profile and organizations"""
|
||||
|
||||
organizations = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "profile_pic", "is_active", "organizations"]
|
||||
|
||||
def get_organizations(self, obj):
|
||||
"""Get all organizations the user belongs to"""
|
||||
profiles = Profile.objects.filter(user=obj, is_active=True)
|
||||
return [
|
||||
{
|
||||
"id": str(profile.org.id),
|
||||
"name": profile.org.name,
|
||||
"role": profile.role,
|
||||
"is_organization_admin": profile.is_organization_admin,
|
||||
"has_sales_access": profile.has_sales_access,
|
||||
"has_marketing_access": profile.has_marketing_access,
|
||||
}
|
||||
for profile in profiles
|
||||
]
|
||||
|
||||
|
||||
class ProfileDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detailed profile serializer for authenticated user"""
|
||||
|
||||
user = UserSerializer(read_only=True)
|
||||
org = OrganizationSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"org",
|
||||
"role",
|
||||
"is_organization_admin",
|
||||
"has_sales_access",
|
||||
"has_marketing_access",
|
||||
"phone",
|
||||
"date_of_joining",
|
||||
"is_active",
|
||||
]
|
||||
|
||||
|
||||
# Activity Serializers for Dashboard Recent Activities
|
||||
|
||||
|
||||
class ActivityUserSerializer(serializers.Serializer):
|
||||
"""Simplified user info for activity display"""
|
||||
|
||||
id = serializers.UUIDField(source="user.id")
|
||||
email = serializers.EmailField(source="user.email")
|
||||
name = serializers.SerializerMethodField()
|
||||
profile_pic = serializers.CharField(source="user.profile_pic", allow_null=True)
|
||||
|
||||
def get_name(self, obj):
|
||||
"""Get display name from email"""
|
||||
return obj.user.email.split("@")[0]
|
||||
|
||||
|
||||
class ActivitySerializer(serializers.ModelSerializer):
|
||||
"""Serializer for recent activities"""
|
||||
|
||||
user = ActivityUserSerializer(read_only=True)
|
||||
action_display = serializers.CharField(source="get_action_display", read_only=True)
|
||||
timestamp = serializers.DateTimeField(source="created_at", read_only=True)
|
||||
humanized_time = serializers.CharField(source="created_on_arrow", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Activity
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"action",
|
||||
"action_display",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"entity_name",
|
||||
"description",
|
||||
"timestamp",
|
||||
"humanized_time",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Teams Serializers (merged from teams app)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TeamsSerializer(serializers.ModelSerializer):
|
||||
users = ProfileSerializer(read_only=True, many=True)
|
||||
created_by = UserSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Teams
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"users",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"created_on_arrow",
|
||||
)
|
||||
|
||||
|
||||
class TeamCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
request_obj = kwargs.pop("request_obj", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.org = request_obj.profile.org
|
||||
|
||||
self.fields["name"].required = True
|
||||
self.fields["description"].required = False
|
||||
|
||||
def validate_name(self, name):
|
||||
if self.instance:
|
||||
if (
|
||||
Teams.objects.filter(name__iexact=name, org=self.org)
|
||||
.exclude(id=self.instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError("Team already exists with this name")
|
||||
else:
|
||||
if Teams.objects.filter(name__iexact=name).exists():
|
||||
raise serializers.ValidationError("Team already exists with this name")
|
||||
return name
|
||||
|
||||
class Meta:
|
||||
model = Teams
|
||||
fields = (
|
||||
"name",
|
||||
"description",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"created_on_arrow",
|
||||
"org",
|
||||
)
|
||||
|
||||
|
||||
class TeamswaggerCreateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Teams
|
||||
fields = (
|
||||
"name",
|
||||
"description",
|
||||
"users",
|
||||
)
|
||||
146
backend/common/signals.py
Normal file
146
backend/common/signals.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""
|
||||
Django signals for automatic activity tracking across CRM entities.
|
||||
|
||||
This module registers post_save and post_delete signals on all major CRM models
|
||||
to automatically create Activity records when entities are created, updated, or deleted.
|
||||
"""
|
||||
|
||||
from crum import get_current_request
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from common.models import Activity
|
||||
|
||||
|
||||
def get_entity_name(instance):
|
||||
"""Get a display name for an entity instance"""
|
||||
# Try common name attributes
|
||||
if hasattr(instance, "name") and instance.name:
|
||||
return str(instance.name)
|
||||
if hasattr(instance, "title") and instance.title:
|
||||
return str(instance.title)
|
||||
if hasattr(instance, "first_name"):
|
||||
name = f"{instance.first_name or ''} {instance.last_name or ''}".strip()
|
||||
if name:
|
||||
return name
|
||||
if hasattr(instance, "email") and instance.email:
|
||||
return str(instance.email)
|
||||
if hasattr(instance, "subject") and instance.subject:
|
||||
return str(instance.subject)
|
||||
# Fallback to string representation
|
||||
return str(instance)[:100]
|
||||
|
||||
|
||||
def create_activity(instance, action, entity_type):
|
||||
"""Create an activity record for a model instance"""
|
||||
request = get_current_request()
|
||||
|
||||
# Skip if no request context (e.g., management commands)
|
||||
if not request:
|
||||
return
|
||||
|
||||
# Get profile from request
|
||||
profile = getattr(request, "profile", None)
|
||||
if not profile:
|
||||
return
|
||||
|
||||
# Get org from instance or profile
|
||||
org = getattr(instance, "org", None)
|
||||
if not org:
|
||||
org = getattr(profile, "org", None)
|
||||
if not org:
|
||||
return
|
||||
|
||||
# Create activity record
|
||||
Activity.objects.create(
|
||||
user=profile,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=instance.id,
|
||||
entity_name=get_entity_name(instance),
|
||||
org=org,
|
||||
)
|
||||
|
||||
|
||||
# Account signals
|
||||
@receiver(post_save, sender="accounts.Account")
|
||||
def account_post_save(sender, instance, created, **kwargs):
|
||||
action = "CREATE" if created else "UPDATE"
|
||||
create_activity(instance, action, "Account")
|
||||
|
||||
|
||||
@receiver(post_delete, sender="accounts.Account")
|
||||
def account_post_delete(sender, instance, **kwargs):
|
||||
create_activity(instance, "DELETE", "Account")
|
||||
|
||||
|
||||
# Lead signals
|
||||
@receiver(post_save, sender="leads.Lead")
|
||||
def lead_post_save(sender, instance, created, **kwargs):
|
||||
action = "CREATE" if created else "UPDATE"
|
||||
create_activity(instance, action, "Lead")
|
||||
|
||||
|
||||
@receiver(post_delete, sender="leads.Lead")
|
||||
def lead_post_delete(sender, instance, **kwargs):
|
||||
create_activity(instance, "DELETE", "Lead")
|
||||
|
||||
|
||||
# Contact signals
|
||||
@receiver(post_save, sender="contacts.Contact")
|
||||
def contact_post_save(sender, instance, created, **kwargs):
|
||||
action = "CREATE" if created else "UPDATE"
|
||||
create_activity(instance, action, "Contact")
|
||||
|
||||
|
||||
@receiver(post_delete, sender="contacts.Contact")
|
||||
def contact_post_delete(sender, instance, **kwargs):
|
||||
create_activity(instance, "DELETE", "Contact")
|
||||
|
||||
|
||||
# Opportunity signals
|
||||
@receiver(post_save, sender="opportunity.Opportunity")
|
||||
def opportunity_post_save(sender, instance, created, **kwargs):
|
||||
action = "CREATE" if created else "UPDATE"
|
||||
create_activity(instance, action, "Opportunity")
|
||||
|
||||
|
||||
@receiver(post_delete, sender="opportunity.Opportunity")
|
||||
def opportunity_post_delete(sender, instance, **kwargs):
|
||||
create_activity(instance, "DELETE", "Opportunity")
|
||||
|
||||
|
||||
# Case signals
|
||||
@receiver(post_save, sender="cases.Case")
|
||||
def case_post_save(sender, instance, created, **kwargs):
|
||||
action = "CREATE" if created else "UPDATE"
|
||||
create_activity(instance, action, "Case")
|
||||
|
||||
|
||||
@receiver(post_delete, sender="cases.Case")
|
||||
def case_post_delete(sender, instance, **kwargs):
|
||||
create_activity(instance, "DELETE", "Case")
|
||||
|
||||
|
||||
# Task signals
|
||||
@receiver(post_save, sender="tasks.Task")
|
||||
def task_post_save(sender, instance, created, **kwargs):
|
||||
action = "CREATE" if created else "UPDATE"
|
||||
create_activity(instance, action, "Task")
|
||||
|
||||
|
||||
@receiver(post_delete, sender="tasks.Task")
|
||||
def task_post_delete(sender, instance, **kwargs):
|
||||
create_activity(instance, "DELETE", "Task")
|
||||
|
||||
|
||||
# Invoice signals
|
||||
@receiver(post_save, sender="invoices.Invoice")
|
||||
def invoice_post_save(sender, instance, created, **kwargs):
|
||||
action = "CREATE" if created else "UPDATE"
|
||||
create_activity(instance, action, "Invoice")
|
||||
|
||||
|
||||
@receiver(post_delete, sender="invoices.Invoice")
|
||||
def invoice_post_delete(sender, instance, **kwargs):
|
||||
create_activity(instance, "DELETE", "Invoice")
|
||||
|
|
@ -5,6 +5,7 @@ See RFC 2616 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
|
|||
And RFC 6585 - http://tools.ietf.org/html/rfc6585
|
||||
And RFC 4918 - https://tools.ietf.org/html/rfc4918
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
|
|
@ -11,9 +11,9 @@ organization_params = [
|
|||
|
||||
user_list_params = [
|
||||
organization_params_in_header,
|
||||
OpenApiParameter("email", OpenApiTypes.STR,OpenApiParameter.QUERY),
|
||||
OpenApiParameter("email", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter(
|
||||
"role", OpenApiTypes.STR, OpenApiParameter.QUERY,enum=["ADMIN", "USER"]
|
||||
"role", OpenApiTypes.STR, OpenApiParameter.QUERY, enum=["ADMIN", "USER"]
|
||||
),
|
||||
OpenApiParameter(
|
||||
"status",
|
||||
|
|
@ -25,13 +25,20 @@ user_list_params = [
|
|||
|
||||
document_get_params = [
|
||||
organization_params_in_header,
|
||||
OpenApiParameter("title", OpenApiTypes.STR,OpenApiParameter.QUERY),
|
||||
OpenApiParameter("title", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter(
|
||||
"status",
|
||||
OpenApiTypes.STR,
|
||||
OpenApiParameter.QUERY,
|
||||
enum=["Active", "In Active"],
|
||||
),
|
||||
OpenApiParameter("shared_to", OpenApiTypes.STR,OpenApiParameter.QUERY),
|
||||
OpenApiParameter("shared_to", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
]
|
||||
|
||||
# Teams params (merged from teams app)
|
||||
teams_list_get_params = [
|
||||
organization_params_in_header,
|
||||
OpenApiParameter("team_name", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("created_by", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("assigned_users", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
]
|
||||
|
|
@ -4,20 +4,37 @@ from celery import Celery
|
|||
from django.conf import settings
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.core.mail import EmailMessage
|
||||
from django.db import connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
|
||||
from common.models import Comment, Profile, User
|
||||
from common.models import Comment, Profile, Teams, User
|
||||
from common.token_generator import account_activation_token
|
||||
|
||||
app = Celery("redis://")
|
||||
|
||||
|
||||
def set_rls_context(org_id):
|
||||
"""
|
||||
Set RLS context for Celery tasks that query org-scoped tables.
|
||||
|
||||
Celery workers don't go through Django middleware, so RLS context
|
||||
must be set explicitly before querying org-scoped data.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID (string or UUID object)
|
||||
"""
|
||||
if org_id:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT set_config('app.current_org', %s, false)", [str(org_id)]
|
||||
)
|
||||
|
||||
|
||||
@app.task
|
||||
def send_email_to_new_user(user_id):
|
||||
|
||||
"""Send Mail To Users When their account is created"""
|
||||
user_obj = User.objects.filter(id=user_id).first()
|
||||
|
||||
|
|
@ -62,8 +79,12 @@ def send_email_to_new_user(user_id):
|
|||
def send_email_user_mentions(
|
||||
comment_id,
|
||||
called_from,
|
||||
org_id=None,
|
||||
):
|
||||
"""Send Mail To Mentioned Users In The Comment"""
|
||||
# Set RLS context for org-scoped queries
|
||||
set_rls_context(org_id)
|
||||
|
||||
comment = Comment.objects.filter(id=comment_id).first()
|
||||
if comment:
|
||||
comment_text = comment.comment
|
||||
|
|
@ -102,8 +123,6 @@ def send_email_user_mentions(
|
|||
subject = "New comment on Task. "
|
||||
elif called_from == "invoices":
|
||||
subject = "New comment on Invoice. "
|
||||
elif called_from == "events":
|
||||
subject = "New comment on Event. "
|
||||
if subject:
|
||||
context["url"] = settings.DOMAIN_NAME
|
||||
else:
|
||||
|
|
@ -269,3 +288,140 @@ def send_email_to_reset_password(user_email):
|
|||
)
|
||||
msg.content_subtype = "html"
|
||||
msg.send()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Teams Celery Tasks (merged from teams app)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@app.task
|
||||
def remove_users(removed_users_list, team_id, org_id=None):
|
||||
# Set RLS context for org-scoped queries
|
||||
set_rls_context(org_id)
|
||||
|
||||
removed_users_list = [i for i in removed_users_list if i.isdigit()]
|
||||
users_list = Profile.objects.filter(id__in=removed_users_list)
|
||||
if users_list.exists():
|
||||
team = Teams.objects.filter(id=team_id).first()
|
||||
if team:
|
||||
accounts = team.account_teams.all()
|
||||
for account in accounts:
|
||||
for user in users_list:
|
||||
account.assigned_to.remove(user)
|
||||
|
||||
# for contacts
|
||||
contacts = team.contact_teams.all()
|
||||
for contact in contacts:
|
||||
for user in users_list:
|
||||
contact.assigned_to.remove(user)
|
||||
|
||||
# for leads
|
||||
leads = team.lead_teams.all()
|
||||
for lead in leads:
|
||||
for user in users_list:
|
||||
lead.assigned_to.remove(user)
|
||||
|
||||
# for opportunities
|
||||
opportunities = team.oppurtunity_teams.all()
|
||||
for opportunity in opportunities:
|
||||
for user in users_list:
|
||||
opportunity.assigned_to.remove(user)
|
||||
|
||||
# for cases
|
||||
cases = team.cases_teams.all()
|
||||
for case in cases:
|
||||
for user in users_list:
|
||||
case.assigned_to.remove(user)
|
||||
|
||||
# for documents
|
||||
docs = team.document_teams.all()
|
||||
for doc in docs:
|
||||
for user in users_list:
|
||||
doc.shared_to.remove(user)
|
||||
|
||||
# for tasks
|
||||
tasks = team.tasks_teams.all()
|
||||
for task in tasks:
|
||||
for user in users_list:
|
||||
task.assigned_to.remove(user)
|
||||
|
||||
# for invoices
|
||||
invoices = team.invoices_teams.all()
|
||||
for invoice in invoices:
|
||||
for user in users_list:
|
||||
invoice.assigned_to.remove(user)
|
||||
|
||||
|
||||
@app.task
|
||||
def update_team_users(team_id, org_id=None):
|
||||
"""this function updates assigned_to field on all models when a team is updated"""
|
||||
# Set RLS context for org-scoped queries
|
||||
set_rls_context(org_id)
|
||||
|
||||
team = Teams.objects.filter(id=team_id).first()
|
||||
if team:
|
||||
teams_members = team.users.all()
|
||||
# for accounts
|
||||
accounts = team.account_teams.all()
|
||||
for account in accounts:
|
||||
account_assigned_to_users = account.assigned_to.all()
|
||||
for team_member in teams_members:
|
||||
if team_member not in account_assigned_to_users:
|
||||
account.assigned_to.add(team_member)
|
||||
|
||||
# for contacts
|
||||
contacts = team.contact_teams.all()
|
||||
for contact in contacts:
|
||||
contact_assigned_to_users = contact.assigned_to.all()
|
||||
for team_member in teams_members:
|
||||
if team_member not in contact_assigned_to_users:
|
||||
contact.assigned_to.add(team_member)
|
||||
|
||||
# for leads
|
||||
leads = team.lead_teams.all()
|
||||
for lead in leads:
|
||||
lead_assigned_to_users = lead.assigned_to.all()
|
||||
for team_member in teams_members:
|
||||
if team_member not in lead_assigned_to_users:
|
||||
lead.assigned_to.add(team_member)
|
||||
|
||||
# for opportunities
|
||||
opportunities = team.oppurtunity_teams.all()
|
||||
for opportunity in opportunities:
|
||||
opportunity_assigned_to_users = opportunity.assigned_to.all()
|
||||
for team_member in teams_members:
|
||||
if team_member not in opportunity_assigned_to_users:
|
||||
opportunity.assigned_to.add(team_member)
|
||||
|
||||
# for cases
|
||||
cases = team.cases_teams.all()
|
||||
for case in cases:
|
||||
case_assigned_to_users = case.assigned_to.all()
|
||||
for team_member in teams_members:
|
||||
if team_member not in case_assigned_to_users:
|
||||
case.assigned_to.add(team_member)
|
||||
|
||||
# for documents
|
||||
docs = team.document_teams.all()
|
||||
for doc in docs:
|
||||
doc_assigned_to_users = doc.shared_to.all()
|
||||
for team_member in teams_members:
|
||||
if team_member not in doc_assigned_to_users:
|
||||
doc.shared_to.add(team_member)
|
||||
|
||||
# for tasks
|
||||
tasks = team.tasks_teams.all()
|
||||
for task in tasks:
|
||||
task_assigned_to_users = task.assigned_to.all()
|
||||
for team_member in teams_members:
|
||||
if team_member not in task_assigned_to_users:
|
||||
task.assigned_to.add(team_member)
|
||||
|
||||
# for invoices
|
||||
invoices = team.invoices_teams.all()
|
||||
for invoice in invoices:
|
||||
invoice_assigned_to_users = invoice.assigned_to.all()
|
||||
for team_member in teams_members:
|
||||
if team_member not in invoice_assigned_to_users:
|
||||
invoice.assigned_to.add(team_member)
|
||||
616
backend/common/tests/test_multitenancy.py
Normal file
616
backend/common/tests/test_multitenancy.py
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
"""
|
||||
Multi-Tenancy Security Tests
|
||||
|
||||
These tests verify that the multi-tenancy implementation properly isolates
|
||||
data between organizations and prevents cross-org data access.
|
||||
|
||||
Run with: pytest common/tests/test_multitenancy.py -v
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.test import RequestFactory, TestCase
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.models import Account
|
||||
from common.middleware.get_company import GetProfileAndOrg
|
||||
from common.models import Address, Attachments, Comment, Org, Profile, Tags, Teams, User
|
||||
from common.serializer import OrgAwareRefreshToken
|
||||
from leads.models import Lead
|
||||
|
||||
|
||||
class MultiTenancyBaseTestCase(TestCase):
|
||||
"""Base test case with common setup for multi-tenancy tests"""
|
||||
|
||||
def setUp(self):
|
||||
# Create two organizations
|
||||
self.org_a = Org.objects.create(name="Organization A")
|
||||
self.org_b = Org.objects.create(name="Organization B")
|
||||
|
||||
# Create users
|
||||
self.user_a = User.objects.create_user(
|
||||
email="user_a@test.com", password="testpass123"
|
||||
)
|
||||
self.user_b = User.objects.create_user(
|
||||
email="user_b@test.com", password="testpass123"
|
||||
)
|
||||
|
||||
# Create profiles (user membership in orgs)
|
||||
self.profile_a = Profile.objects.create(
|
||||
user=self.user_a, org=self.org_a, role="ADMIN", is_active=True
|
||||
)
|
||||
self.profile_b = Profile.objects.create(
|
||||
user=self.user_b, org=self.org_b, role="ADMIN", is_active=True
|
||||
)
|
||||
|
||||
# API client
|
||||
self.client = APIClient()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
|
||||
class TestOrgAwareRefreshToken(MultiTenancyBaseTestCase):
|
||||
"""Test JWT token generation with org context"""
|
||||
|
||||
def test_token_includes_org_id(self):
|
||||
"""Token should include org_id in payload"""
|
||||
token = OrgAwareRefreshToken.for_user_and_org(self.user_a, self.org_a)
|
||||
|
||||
# Access token should have org_id
|
||||
access_token = token.access_token
|
||||
self.assertEqual(str(access_token["org_id"]), str(self.org_a.id))
|
||||
|
||||
def test_token_without_org(self):
|
||||
"""Token can be generated without org for initial login"""
|
||||
token = OrgAwareRefreshToken.for_user_and_org(self.user_a, None)
|
||||
|
||||
# Should not have org_id claim
|
||||
access_token = token.access_token
|
||||
self.assertNotIn("org_id", access_token)
|
||||
|
||||
def test_different_orgs_different_tokens(self):
|
||||
"""Different orgs should produce different token claims"""
|
||||
token_a = OrgAwareRefreshToken.for_user_and_org(self.user_a, self.org_a)
|
||||
token_b = OrgAwareRefreshToken.for_user_and_org(self.user_a, self.org_b)
|
||||
|
||||
self.assertNotEqual(
|
||||
str(token_a.access_token["org_id"]), str(token_b.access_token["org_id"])
|
||||
)
|
||||
|
||||
|
||||
class TestMiddlewareOrgValidation(MultiTenancyBaseTestCase):
|
||||
"""Test middleware extracts org from JWT, not headers"""
|
||||
|
||||
def test_org_extracted_from_jwt_not_header(self):
|
||||
"""Middleware should use org_id from JWT, ignoring org header"""
|
||||
# Generate token for org_a
|
||||
token = OrgAwareRefreshToken.for_user_and_org(self.user_a, self.org_a)
|
||||
|
||||
# Make request with JWT for org_a but header claiming org_b
|
||||
request = self.factory.get(
|
||||
"/api/leads/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token.access_token}",
|
||||
HTTP_ORG=str(self.org_b.id), # Attempt to spoof org
|
||||
)
|
||||
|
||||
middleware = GetProfileAndOrg(lambda r: r)
|
||||
middleware(request)
|
||||
|
||||
# Should use org from JWT (org_a), not header (org_b)
|
||||
self.assertEqual(request.profile.org, self.org_a)
|
||||
self.assertEqual(request.org, self.org_a)
|
||||
|
||||
def test_revoked_membership_denied(self):
|
||||
"""User whose membership was revoked should be denied"""
|
||||
# Generate token
|
||||
token = OrgAwareRefreshToken.for_user_and_org(self.user_a, self.org_a)
|
||||
|
||||
# Revoke membership (deactivate profile)
|
||||
self.profile_a.is_active = False
|
||||
self.profile_a.save()
|
||||
|
||||
# Make request
|
||||
request = self.factory.get(
|
||||
"/api/leads/", HTTP_AUTHORIZATION=f"Bearer {token.access_token}"
|
||||
)
|
||||
|
||||
middleware = GetProfileAndOrg(lambda r: r)
|
||||
middleware(request)
|
||||
|
||||
# Middleware should not set profile when membership is revoked
|
||||
self.assertIsNone(getattr(request, "profile", None))
|
||||
|
||||
def test_api_key_scoped_to_org(self):
|
||||
"""API key authentication should be scoped to specific org"""
|
||||
request = self.factory.get("/api/leads/", HTTP_TOKEN=self.org_a.api_key)
|
||||
|
||||
middleware = GetProfileAndOrg(lambda r: r)
|
||||
middleware(request)
|
||||
|
||||
self.assertEqual(request.org, self.org_a)
|
||||
|
||||
|
||||
class TestCrossOrgDataAccess(MultiTenancyBaseTestCase):
|
||||
"""Test that cross-org data access is prevented"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create data in each org
|
||||
self.account_a = Account.objects.create(name="Account in Org A", org=self.org_a)
|
||||
self.account_b = Account.objects.create(name="Account in Org B", org=self.org_b)
|
||||
|
||||
self.lead_a = Lead.objects.create(
|
||||
first_name="Lead",
|
||||
last_name="In Org A",
|
||||
email="lead_a@test.com",
|
||||
org=self.org_a,
|
||||
)
|
||||
self.lead_b = Lead.objects.create(
|
||||
first_name="Lead",
|
||||
last_name="In Org B",
|
||||
email="lead_b@test.com",
|
||||
org=self.org_b,
|
||||
)
|
||||
|
||||
def test_queryset_filters_by_org(self):
|
||||
"""QuerySet should only return data for the specified org"""
|
||||
# Filter for org_a
|
||||
accounts_a = Account.objects.filter(org=self.org_a)
|
||||
self.assertEqual(accounts_a.count(), 1)
|
||||
self.assertEqual(accounts_a.first().name, "Account in Org A")
|
||||
|
||||
# Filter for org_b
|
||||
accounts_b = Account.objects.filter(org=self.org_b)
|
||||
self.assertEqual(accounts_b.count(), 1)
|
||||
self.assertEqual(accounts_b.first().name, "Account in Org B")
|
||||
|
||||
def test_api_returns_only_org_data(self):
|
||||
"""API endpoints should only return data for user's org"""
|
||||
# Login as user_a
|
||||
token = OrgAwareRefreshToken.for_user_and_org(self.user_a, self.org_a)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
|
||||
|
||||
# Get accounts - should only see org_a data
|
||||
response = self.client.get("/api/accounts/")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# Verify all returned accounts belong to org_a
|
||||
if "results" in data:
|
||||
for account in data["results"]:
|
||||
self.assertEqual(account.get("org"), str(self.org_a.id))
|
||||
|
||||
|
||||
class TestGenericFKOrgValidation(MultiTenancyBaseTestCase):
|
||||
"""Test that Comment and Attachments validate org matches content object"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.lead_a = Lead.objects.create(
|
||||
first_name="Lead",
|
||||
last_name="In Org A",
|
||||
email="lead_a@test.com",
|
||||
org=self.org_a,
|
||||
)
|
||||
|
||||
def test_comment_org_must_match_content_object(self):
|
||||
"""Comment org must match the referenced object's org"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
lead_content_type = ContentType.objects.get_for_model(Lead)
|
||||
|
||||
# Try to create comment with different org than lead
|
||||
comment = Comment(
|
||||
content_type=lead_content_type,
|
||||
object_id=self.lead_a.id,
|
||||
comment="Cross-org comment attempt",
|
||||
org=self.org_b, # Different org than lead
|
||||
commented_by=self.profile_b,
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
comment.save()
|
||||
|
||||
self.assertIn("org", context.exception.message_dict)
|
||||
|
||||
def test_comment_same_org_succeeds(self):
|
||||
"""Comment with same org as content object should succeed"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
lead_content_type = ContentType.objects.get_for_model(Lead)
|
||||
|
||||
comment = Comment(
|
||||
content_type=lead_content_type,
|
||||
object_id=self.lead_a.id,
|
||||
comment="Same-org comment",
|
||||
org=self.org_a, # Same org as lead
|
||||
commented_by=self.profile_a,
|
||||
created_by=self.user_a,
|
||||
updated_by=self.user_a,
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
comment.save()
|
||||
self.assertIsNotNone(comment.id)
|
||||
|
||||
def test_attachment_org_must_match_content_object(self):
|
||||
"""Attachment org must match the referenced object's org"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
lead_content_type = ContentType.objects.get_for_model(Lead)
|
||||
|
||||
attachment = Attachments(
|
||||
content_type=lead_content_type,
|
||||
object_id=self.lead_a.id,
|
||||
file_name="test.txt",
|
||||
attachment=SimpleUploadedFile("test.txt", b"content"),
|
||||
org=self.org_b, # Different org than lead
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
attachment.save()
|
||||
|
||||
self.assertIn("org", context.exception.message_dict)
|
||||
|
||||
|
||||
class TestOrgSwitching(MultiTenancyBaseTestCase):
|
||||
"""Test organization switching functionality"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Give user_a access to both orgs
|
||||
self.profile_a_in_b = Profile.objects.create(
|
||||
user=self.user_a, org=self.org_b, role="USER", is_active=True
|
||||
)
|
||||
|
||||
def test_switch_org_success(self):
|
||||
"""User with access to multiple orgs can switch"""
|
||||
# Login to org_a first
|
||||
token = OrgAwareRefreshToken.for_user_and_org(self.user_a, self.org_a)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
|
||||
|
||||
# Switch to org_b
|
||||
response = self.client.post(
|
||||
"/api/auth/switch-org/", {"org_id": str(self.org_b.id)}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
# Should have new tokens with org_b
|
||||
self.assertIn("access_token", data)
|
||||
self.assertEqual(data["current_org"]["id"], str(self.org_b.id))
|
||||
|
||||
def test_switch_to_unauthorized_org_fails(self):
|
||||
"""User cannot switch to org they don't have access to"""
|
||||
# user_b only has access to org_b
|
||||
token = OrgAwareRefreshToken.for_user_and_org(self.user_b, self.org_b)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
|
||||
|
||||
# Try to switch to org_a
|
||||
response = self.client.post(
|
||||
"/api/auth/switch-org/", {"org_id": str(self.org_a.id)}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
class TestLoginWithOrgContext(MultiTenancyBaseTestCase):
|
||||
"""Test login endpoint includes org context"""
|
||||
|
||||
def test_login_returns_current_org(self):
|
||||
"""Login should return current_org in response"""
|
||||
response = self.client.post(
|
||||
"/api/auth/login/", {"email": "user_a@test.com", "password": "testpass123"}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertIn("current_org", data)
|
||||
self.assertEqual(data["current_org"]["id"], str(self.org_a.id))
|
||||
|
||||
def test_login_with_specific_org(self):
|
||||
"""Login can specify which org to use"""
|
||||
# Give user_a access to org_b
|
||||
Profile.objects.create(
|
||||
user=self.user_a, org=self.org_b, role="USER", is_active=True
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/auth/login/",
|
||||
{
|
||||
"email": "user_a@test.com",
|
||||
"password": "testpass123",
|
||||
"org_id": str(self.org_b.id),
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertEqual(data["current_org"]["id"], str(self.org_b.id))
|
||||
|
||||
def test_login_with_invalid_org_fails(self):
|
||||
"""Login with org user doesn't have access to should fail"""
|
||||
response = self.client.post(
|
||||
"/api/auth/login/",
|
||||
{
|
||||
"email": "user_a@test.com",
|
||||
"password": "testpass123",
|
||||
"org_id": str(self.org_b.id), # user_a doesn't have access
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
class TestBaseOrgModel(TestCase):
|
||||
"""Test BaseOrgModel validation"""
|
||||
|
||||
def test_org_required_on_save(self):
|
||||
"""BaseOrgModel descendants should require org on save"""
|
||||
from common.base import BaseOrgModel
|
||||
|
||||
# This is tested indirectly through models like Teams
|
||||
org = Org.objects.create(name="Test Org")
|
||||
|
||||
team = Teams(name="Test Team", description="Test Description", org=org)
|
||||
team.save() # Should succeed
|
||||
|
||||
self.assertIsNotNone(team.id)
|
||||
|
||||
|
||||
class TestOrgScopedQuerySet(MultiTenancyBaseTestCase):
|
||||
"""Test OrgScopedQuerySet helper methods"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create teams in each org
|
||||
self.team_a = Teams.objects.create(
|
||||
name="Team A", description="In Org A", org=self.org_a
|
||||
)
|
||||
self.team_b = Teams.objects.create(
|
||||
name="Team B", description="In Org B", org=self.org_b
|
||||
)
|
||||
|
||||
def test_for_org_filters_correctly(self):
|
||||
"""for_org() should filter by organization"""
|
||||
teams = Teams.objects.filter(org=self.org_a)
|
||||
self.assertEqual(teams.count(), 1)
|
||||
self.assertEqual(teams.first().name, "Team A")
|
||||
|
||||
def test_for_request_uses_profile_org(self):
|
||||
"""for_request() should use org from request.profile"""
|
||||
# Create mock request
|
||||
request = MagicMock()
|
||||
request.profile = self.profile_a
|
||||
|
||||
# This would be used with OrgScopedManager
|
||||
# Teams.objects.for_request(request)
|
||||
|
||||
|
||||
class TestNullOrgPrevention(MultiTenancyBaseTestCase):
|
||||
"""Test that models properly handle org field"""
|
||||
|
||||
def test_comment_requires_org(self):
|
||||
"""Comment should require org field"""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
lead = Lead.objects.create(
|
||||
first_name="Test", last_name="Lead", email="test@test.com", org=self.org_a
|
||||
)
|
||||
|
||||
lead_content_type = ContentType.objects.get_for_model(Lead)
|
||||
|
||||
# Try to create comment without org
|
||||
comment = Comment(
|
||||
content_type=lead_content_type,
|
||||
object_id=lead.id,
|
||||
comment="No org comment",
|
||||
# org not set
|
||||
)
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
comment.save()
|
||||
|
||||
|
||||
class TestRLSIntegration(MultiTenancyBaseTestCase):
|
||||
"""
|
||||
Test Row-Level Security at the database level.
|
||||
|
||||
These tests verify that PostgreSQL RLS policies properly isolate
|
||||
data between organizations. They require:
|
||||
- PostgreSQL database
|
||||
- Non-superuser database connection (superusers bypass RLS)
|
||||
- RLS migrations applied
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Check if RLS testing is possible."""
|
||||
super().setUpClass()
|
||||
from django.db import connection
|
||||
|
||||
# Check if using PostgreSQL
|
||||
cls.is_postgres = connection.vendor == "postgresql"
|
||||
|
||||
if cls.is_postgres:
|
||||
# Check if database user is a superuser (which bypasses RLS)
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT usesuper FROM pg_user WHERE usename = current_user"
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
cls.is_superuser = result[0] if result else True
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create test data in each org
|
||||
self.lead_a = Lead.objects.create(
|
||||
first_name="Lead", last_name="OrgA", email="lead_a@test.com", org=self.org_a
|
||||
)
|
||||
self.lead_b = Lead.objects.create(
|
||||
first_name="Lead", last_name="OrgB", email="lead_b@test.com", org=self.org_b
|
||||
)
|
||||
|
||||
self.account_a = Account.objects.create(
|
||||
name="Account A", email="account_a@test.com", org=self.org_a
|
||||
)
|
||||
self.account_b = Account.objects.create(
|
||||
name="Account B", email="account_b@test.com", org=self.org_b
|
||||
)
|
||||
|
||||
def test_empty_context_returns_no_rows(self):
|
||||
"""With empty RLS context, queries should return zero rows (fail-safe)."""
|
||||
if not self.is_postgres:
|
||||
self.skipTest("RLS requires PostgreSQL")
|
||||
|
||||
if self.is_superuser:
|
||||
self.skipTest(
|
||||
"RLS is bypassed for superusers - use non-superuser for testing"
|
||||
)
|
||||
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Clear the org context (empty string means no context)
|
||||
cursor.execute("SELECT set_config('app.current_org', '', true)")
|
||||
|
||||
# Query should return no rows due to NULLIF fail-safe
|
||||
cursor.execute("SELECT COUNT(*) FROM lead")
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
self.assertEqual(count, 0, "Empty context should return zero rows")
|
||||
|
||||
def test_rls_isolates_between_orgs(self):
|
||||
"""RLS should isolate data between organizations."""
|
||||
if not self.is_postgres:
|
||||
self.skipTest("RLS requires PostgreSQL")
|
||||
|
||||
if self.is_superuser:
|
||||
self.skipTest(
|
||||
"RLS is bypassed for superusers - use non-superuser for testing"
|
||||
)
|
||||
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Set context to org_a
|
||||
cursor.execute(
|
||||
"SELECT set_config('app.current_org', %s, true)", [str(self.org_a.id)]
|
||||
)
|
||||
|
||||
# Should only see org_a's lead
|
||||
cursor.execute("SELECT id, first_name FROM lead")
|
||||
leads = cursor.fetchall()
|
||||
|
||||
self.assertEqual(len(leads), 1, "Should only see one lead")
|
||||
self.assertEqual(str(leads[0][0]), str(self.lead_a.id))
|
||||
|
||||
# Now switch to org_b
|
||||
cursor.execute(
|
||||
"SELECT set_config('app.current_org', %s, true)", [str(self.org_b.id)]
|
||||
)
|
||||
|
||||
cursor.execute("SELECT id, first_name FROM lead")
|
||||
leads = cursor.fetchall()
|
||||
|
||||
self.assertEqual(len(leads), 1, "Should only see one lead")
|
||||
self.assertEqual(str(leads[0][0]), str(self.lead_b.id))
|
||||
|
||||
def test_rls_prevents_insert_to_wrong_org(self):
|
||||
"""RLS should prevent inserting records with wrong org_id."""
|
||||
if not self.is_postgres:
|
||||
self.skipTest("RLS requires PostgreSQL")
|
||||
|
||||
if self.is_superuser:
|
||||
self.skipTest(
|
||||
"RLS is bypassed for superusers - use non-superuser for testing"
|
||||
)
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Set context to org_a
|
||||
cursor.execute(
|
||||
"SELECT set_config('app.current_org', %s, true)", [str(self.org_a.id)]
|
||||
)
|
||||
|
||||
# Try to insert lead with org_b's ID (should fail due to WITH CHECK)
|
||||
new_id = uuid.uuid4()
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO lead (id, first_name, last_name, email, org_id, created_at, updated_at)
|
||||
VALUES (%s, 'Test', 'Wrong Org', 'wrong@test.com', %s, NOW(), NOW())
|
||||
""",
|
||||
[str(new_id), str(self.org_b.id)],
|
||||
)
|
||||
self.fail("Should not be able to insert record with wrong org_id")
|
||||
except Exception:
|
||||
# Expected - RLS should prevent this insert
|
||||
pass
|
||||
|
||||
def test_celery_task_rls_context(self):
|
||||
"""Celery tasks should be able to set RLS context."""
|
||||
if not self.is_postgres:
|
||||
self.skipTest("RLS requires PostgreSQL")
|
||||
|
||||
if self.is_superuser:
|
||||
self.skipTest(
|
||||
"RLS is bypassed for superusers - use non-superuser for testing"
|
||||
)
|
||||
|
||||
from django.db import connection
|
||||
|
||||
from common.tasks import set_rls_context
|
||||
|
||||
# Simulate Celery task setting RLS context
|
||||
set_rls_context(str(self.org_a.id))
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT current_setting('app.current_org', true)")
|
||||
context = cursor.fetchone()[0]
|
||||
|
||||
self.assertEqual(context, str(self.org_a.id))
|
||||
|
||||
def test_rls_status_check(self):
|
||||
"""Verify RLS is enabled on key tables."""
|
||||
if not self.is_postgres:
|
||||
self.skipTest("RLS requires PostgreSQL")
|
||||
|
||||
from django.db import connection
|
||||
|
||||
from common.rls import RLS_CONFIG
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
for table in ["lead", "accounts", "contacts"]:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT relrowsecurity, relforcerowsecurity
|
||||
FROM pg_class
|
||||
WHERE relname = %s AND relnamespace = 'public'::regnamespace
|
||||
""",
|
||||
[table],
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
rls_enabled, rls_forced = result
|
||||
# Just check that RLS is enabled
|
||||
if not rls_enabled:
|
||||
self.fail(f"RLS not enabled on {table}")
|
||||
|
||||
|
||||
# Run tests with: pytest common/tests/test_multitenancy.py -v
|
||||
|
|
@ -15,7 +15,6 @@ from common.tasks import (
|
|||
)
|
||||
from common.tests import ObjectsCreation
|
||||
from contacts.tests import ContactObjectsCreation
|
||||
from events.tests import EventObjectTest
|
||||
from invoices.tests import InvoiceCreateTest
|
||||
from leads.tests import TestLeadModel
|
||||
from opportunity.tests import OpportunityModel
|
||||
|
|
@ -105,6 +104,7 @@ class TestUserMentionsForAccountComments(AccountCreateTest, TestCase):
|
|||
(
|
||||
self.comment.id,
|
||||
"accounts",
|
||||
str(self.comment.org.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
|
|
@ -134,6 +134,7 @@ class TestUserMentionsForContactsComments(ContactObjectsCreation, TestCase):
|
|||
(
|
||||
self.comment.id,
|
||||
"contacts",
|
||||
str(self.comment.org.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
|
|
@ -163,6 +164,7 @@ class TestUserMentionsForLeadsComments(TestLeadModel, TestCase):
|
|||
(
|
||||
self.comment.id,
|
||||
"leads",
|
||||
str(self.comment.org.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
|
|
@ -192,6 +194,7 @@ class TestUserMentionsForOpportunityComments(OpportunityModel, TestCase):
|
|||
(
|
||||
self.comment.id,
|
||||
"opportunity",
|
||||
str(self.comment.org.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
|
|
@ -221,6 +224,7 @@ class TestUserMentionsForCasesComments(CaseCreation, TestCase):
|
|||
(
|
||||
self.comment.id,
|
||||
"cases",
|
||||
str(self.comment.org.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
|
|
@ -250,6 +254,7 @@ class TestUserMentionsForTasksComments(TaskCreateTest, TestCase):
|
|||
(
|
||||
self.comment.id,
|
||||
"tasks",
|
||||
str(self.comment.org.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
|
|
@ -279,35 +284,7 @@ class TestUserMentionsForInvoiceComments(InvoiceCreateTest, TestCase):
|
|||
(
|
||||
self.comment.id,
|
||||
"invoices",
|
||||
),
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
|
||||
|
||||
class TestUserMentionsForEventsComments(EventObjectTest, TestCase):
|
||||
@override_settings(
|
||||
CELERY_EAGER_PROPAGATES_EXCEPTIONS=True,
|
||||
CELERY_ALWAYS_EAGER=True,
|
||||
BROKER_BACKEND="memory",
|
||||
)
|
||||
def test_user_mentions_for_events_comments(self):
|
||||
self.user_comment = User.objects.create(
|
||||
first_name="johnComment",
|
||||
username="johnDoeComment",
|
||||
email="johnDoeComment@example.com",
|
||||
role="ADMIN",
|
||||
)
|
||||
self.user_comment.set_password("password")
|
||||
self.user_comment.save()
|
||||
|
||||
self.comment.comment = "content @{}".format(self.user_comment.username)
|
||||
self.comment.event = self.event
|
||||
self.comment.save()
|
||||
|
||||
task = send_email_user_mentions.apply(
|
||||
(
|
||||
self.comment.id,
|
||||
"events",
|
||||
str(self.comment.org.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("SUCCESS", task.state)
|
||||
44
backend/common/urls.py
Normal file
44
backend/common/urls.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from django.urls import path
|
||||
from rest_framework_simplejwt import views as jwt_views
|
||||
|
||||
from common import views
|
||||
|
||||
app_name = "api_common"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("dashboard/", views.ApiHomeView.as_view()),
|
||||
# JWT Authentication endpoints for SvelteKit integration
|
||||
path("auth/login/", views.LoginView.as_view(), name="login"),
|
||||
path("auth/register/", views.RegisterView.as_view(), name="register"),
|
||||
path(
|
||||
"auth/refresh-token/",
|
||||
views.OrgAwareTokenRefreshView.as_view(),
|
||||
name="token_refresh",
|
||||
),
|
||||
path("auth/me/", views.MeView.as_view(), name="me"),
|
||||
path("auth/profile/", views.ProfileDetailView.as_view(), name="profile_detail"),
|
||||
path("auth/switch-org/", views.OrgSwitchView.as_view(), name="switch_org"),
|
||||
# Google OAuth callback with PKCE (secure implementation)
|
||||
path("auth/google/callback/", views.GoogleOAuthCallbackView.as_view()),
|
||||
# Organization and profile management
|
||||
path("org/", views.OrgProfileCreateView.as_view()),
|
||||
path("org/<str:pk>/", views.OrgUpdateView.as_view()),
|
||||
path("profile/", views.ProfileView.as_view()),
|
||||
# User management
|
||||
path("users/get-teams-and-users/", views.GetTeamsAndUsersView.as_view()),
|
||||
path("users/", views.UsersListView.as_view()),
|
||||
path("user/<str:pk>/", views.UserDetailView.as_view()),
|
||||
path("user/<str:pk>/status/", views.UserStatusView.as_view()),
|
||||
# Documents
|
||||
path("documents/", views.DocumentListView.as_view()),
|
||||
path("documents/<str:pk>/", views.DocumentDetailView.as_view()),
|
||||
# API Settings
|
||||
path("api-settings/", views.DomainList.as_view()),
|
||||
path("api-settings/<str:pk>/", views.DomainDetailView.as_view()),
|
||||
# Activities (for dashboard recent activities)
|
||||
path("activities/", views.ActivityListView.as_view(), name="activities"),
|
||||
# Teams (merged from teams app)
|
||||
path("teams/", views.TeamsListView.as_view()),
|
||||
path("teams/<str:pk>/", views.TeamsDetailView.as_view()),
|
||||
]
|
||||
|
|
@ -108,15 +108,20 @@ PRIORITY_CHOICE = (
|
|||
CASE_TYPE = (("Question", "Question"), ("Incident", "Incident"), ("Problem", "Problem"))
|
||||
|
||||
STAGES = (
|
||||
("QUALIFICATION", "QUALIFICATION"),
|
||||
("NEEDS ANALYSIS", "NEEDS ANALYSIS"),
|
||||
("VALUE PROPOSITION", "VALUE PROPOSITION"),
|
||||
("ID.DECISION MAKERS", "ID.DECISION MAKERS"),
|
||||
("PERCEPTION ANALYSIS", "PERCEPTION ANALYSIS"),
|
||||
("PROPOSAL/PRICE QUOTE", "PROPOSAL/PRICE QUOTE"),
|
||||
("NEGOTIATION/REVIEW", "NEGOTIATION/REVIEW"),
|
||||
("CLOSED WON", "CLOSED WON"),
|
||||
("CLOSED LOST", "CLOSED LOST"),
|
||||
("PROSPECTING", "Prospecting"),
|
||||
("QUALIFICATION", "Qualification"),
|
||||
("PROPOSAL", "Proposal"),
|
||||
("NEGOTIATION", "Negotiation"),
|
||||
("CLOSED_WON", "Closed Won"),
|
||||
("CLOSED_LOST", "Closed Lost"),
|
||||
)
|
||||
|
||||
OPPORTUNITY_TYPES = (
|
||||
("NEW_BUSINESS", "New Business"),
|
||||
("EXISTING_BUSINESS", "Existing Business"),
|
||||
("RENEWAL", "Renewal"),
|
||||
("UPSELL", "Upsell"),
|
||||
("CROSS_SELL", "Cross-sell"),
|
||||
)
|
||||
|
||||
SOURCES = (
|
||||
1755
backend/common/views.py
Normal file
1755
backend/common/views.py
Normal file
File diff suppressed because it is too large
Load diff
438
backend/contacts/migrations/0001_initial.py
Normal file
438
backend/contacts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
# Generated by Django 5.2.8 on 2025-11-27 12:17
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("common", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Contact",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(max_length=255, verbose_name="First name"),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(max_length=255, verbose_name="Last name"),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, null=True, verbose_name="Email"
|
||||
),
|
||||
),
|
||||
(
|
||||
"phone",
|
||||
phonenumber_field.modelfields.PhoneNumberField(
|
||||
blank=True,
|
||||
max_length=128,
|
||||
null=True,
|
||||
region=None,
|
||||
verbose_name="Phone",
|
||||
),
|
||||
),
|
||||
(
|
||||
"organization",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="Company"
|
||||
),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="Job Title"
|
||||
),
|
||||
),
|
||||
(
|
||||
"department",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="Department"
|
||||
),
|
||||
),
|
||||
(
|
||||
"do_not_call",
|
||||
models.BooleanField(default=False, verbose_name="Do Not Call"),
|
||||
),
|
||||
(
|
||||
"linked_in_url",
|
||||
models.URLField(blank=True, null=True, verbose_name="LinkedIn URL"),
|
||||
),
|
||||
(
|
||||
"address_line",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="Address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"city",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="City"
|
||||
),
|
||||
),
|
||||
(
|
||||
"state",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="State"
|
||||
),
|
||||
),
|
||||
(
|
||||
"postcode",
|
||||
models.CharField(
|
||||
blank=True, max_length=64, null=True, verbose_name="Postal Code"
|
||||
),
|
||||
),
|
||||
(
|
||||
"country",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("GB", "United Kingdom"),
|
||||
("AF", "Afghanistan"),
|
||||
("AX", "Aland Islands"),
|
||||
("AL", "Albania"),
|
||||
("DZ", "Algeria"),
|
||||
("AS", "American Samoa"),
|
||||
("AD", "Andorra"),
|
||||
("AO", "Angola"),
|
||||
("AI", "Anguilla"),
|
||||
("AQ", "Antarctica"),
|
||||
("AG", "Antigua and Barbuda"),
|
||||
("AR", "Argentina"),
|
||||
("AM", "Armenia"),
|
||||
("AW", "Aruba"),
|
||||
("AU", "Australia"),
|
||||
("AT", "Austria"),
|
||||
("AZ", "Azerbaijan"),
|
||||
("BS", "Bahamas"),
|
||||
("BH", "Bahrain"),
|
||||
("BD", "Bangladesh"),
|
||||
("BB", "Barbados"),
|
||||
("BY", "Belarus"),
|
||||
("BE", "Belgium"),
|
||||
("BZ", "Belize"),
|
||||
("BJ", "Benin"),
|
||||
("BM", "Bermuda"),
|
||||
("BT", "Bhutan"),
|
||||
("BO", "Bolivia"),
|
||||
("BA", "Bosnia and Herzegovina"),
|
||||
("BW", "Botswana"),
|
||||
("BV", "Bouvet Island"),
|
||||
("BR", "Brazil"),
|
||||
("IO", "British Indian Ocean Territory"),
|
||||
("BN", "Brunei Darussalam"),
|
||||
("BG", "Bulgaria"),
|
||||
("BF", "Burkina Faso"),
|
||||
("BI", "Burundi"),
|
||||
("KH", "Cambodia"),
|
||||
("CM", "Cameroon"),
|
||||
("CA", "Canada"),
|
||||
("CV", "Cape Verde"),
|
||||
("KY", "Cayman Islands"),
|
||||
("CF", "Central African Republic"),
|
||||
("TD", "Chad"),
|
||||
("CL", "Chile"),
|
||||
("CN", "China"),
|
||||
("CX", "Christmas Island"),
|
||||
("CC", "Cocos (Keeling) Islands"),
|
||||
("CO", "Colombia"),
|
||||
("KM", "Comoros"),
|
||||
("CG", "Congo"),
|
||||
("CD", "Congo, The Democratic Republic of the"),
|
||||
("CK", "Cook Islands"),
|
||||
("CR", "Costa Rica"),
|
||||
("CI", "Cote d'Ivoire"),
|
||||
("HR", "Croatia"),
|
||||
("CU", "Cuba"),
|
||||
("CY", "Cyprus"),
|
||||
("CZ", "Czech Republic"),
|
||||
("DK", "Denmark"),
|
||||
("DJ", "Djibouti"),
|
||||
("DM", "Dominica"),
|
||||
("DO", "Dominican Republic"),
|
||||
("EC", "Ecuador"),
|
||||
("EG", "Egypt"),
|
||||
("SV", "El Salvador"),
|
||||
("GQ", "Equatorial Guinea"),
|
||||
("ER", "Eritrea"),
|
||||
("EE", "Estonia"),
|
||||
("ET", "Ethiopia"),
|
||||
("FK", "Falkland Islands (Malvinas)"),
|
||||
("FO", "Faroe Islands"),
|
||||
("FJ", "Fiji"),
|
||||
("FI", "Finland"),
|
||||
("FR", "France"),
|
||||
("GF", "French Guiana"),
|
||||
("PF", "French Polynesia"),
|
||||
("TF", "French Southern Territories"),
|
||||
("GA", "Gabon"),
|
||||
("GM", "Gambia"),
|
||||
("GE", "Georgia"),
|
||||
("DE", "Germany"),
|
||||
("GH", "Ghana"),
|
||||
("GI", "Gibraltar"),
|
||||
("GR", "Greece"),
|
||||
("GL", "Greenland"),
|
||||
("GD", "Grenada"),
|
||||
("GP", "Guadeloupe"),
|
||||
("GU", "Guam"),
|
||||
("GT", "Guatemala"),
|
||||
("GG", "Guernsey"),
|
||||
("GN", "Guinea"),
|
||||
("GW", "Guinea-Bissau"),
|
||||
("GY", "Guyana"),
|
||||
("HT", "Haiti"),
|
||||
("HM", "Heard Island and McDonald Islands"),
|
||||
("VA", "Holy See (Vatican City State)"),
|
||||
("HN", "Honduras"),
|
||||
("HK", "Hong Kong"),
|
||||
("HU", "Hungary"),
|
||||
("IS", "Iceland"),
|
||||
("IN", "India"),
|
||||
("ID", "Indonesia"),
|
||||
("IR", "Iran, Islamic Republic of"),
|
||||
("IQ", "Iraq"),
|
||||
("IE", "Ireland"),
|
||||
("IM", "Isle of Man"),
|
||||
("IL", "Israel"),
|
||||
("IT", "Italy"),
|
||||
("JM", "Jamaica"),
|
||||
("JP", "Japan"),
|
||||
("JE", "Jersey"),
|
||||
("JO", "Jordan"),
|
||||
("KZ", "Kazakhstan"),
|
||||
("KE", "Kenya"),
|
||||
("KI", "Kiribati"),
|
||||
("KP", "Korea, Democratic People's Republic of"),
|
||||
("KR", "Korea, Republic of"),
|
||||
("KW", "Kuwait"),
|
||||
("KG", "Kyrgyzstan"),
|
||||
("LA", "Lao People's Democratic Republic"),
|
||||
("LV", "Latvia"),
|
||||
("LB", "Lebanon"),
|
||||
("LS", "Lesotho"),
|
||||
("LR", "Liberia"),
|
||||
("LY", "Libyan Arab Jamahiriya"),
|
||||
("LI", "Liechtenstein"),
|
||||
("LT", "Lithuania"),
|
||||
("LU", "Luxembourg"),
|
||||
("MO", "Macao"),
|
||||
("MK", "Macedonia, The Former Yugoslav Republic of"),
|
||||
("MG", "Madagascar"),
|
||||
("MW", "Malawi"),
|
||||
("MY", "Malaysia"),
|
||||
("MV", "Maldives"),
|
||||
("ML", "Mali"),
|
||||
("MT", "Malta"),
|
||||
("MH", "Marshall Islands"),
|
||||
("MQ", "Martinique"),
|
||||
("MR", "Mauritania"),
|
||||
("MU", "Mauritius"),
|
||||
("YT", "Mayotte"),
|
||||
("MX", "Mexico"),
|
||||
("FM", "Micronesia, Federated States of"),
|
||||
("MD", "Moldova"),
|
||||
("MC", "Monaco"),
|
||||
("MN", "Mongolia"),
|
||||
("ME", "Montenegro"),
|
||||
("MS", "Montserrat"),
|
||||
("MA", "Morocco"),
|
||||
("MZ", "Mozambique"),
|
||||
("MM", "Myanmar"),
|
||||
("NA", "Namibia"),
|
||||
("NR", "Nauru"),
|
||||
("NP", "Nepal"),
|
||||
("NL", "Netherlands"),
|
||||
("AN", "Netherlands Antilles"),
|
||||
("NC", "New Caledonia"),
|
||||
("NZ", "New Zealand"),
|
||||
("NI", "Nicaragua"),
|
||||
("NE", "Niger"),
|
||||
("NG", "Nigeria"),
|
||||
("NU", "Niue"),
|
||||
("NF", "Norfolk Island"),
|
||||
("MP", "Northern Mariana Islands"),
|
||||
("NO", "Norway"),
|
||||
("OM", "Oman"),
|
||||
("PK", "Pakistan"),
|
||||
("PW", "Palau"),
|
||||
("PS", "Palestinian Territory, Occupied"),
|
||||
("PA", "Panama"),
|
||||
("PG", "Papua New Guinea"),
|
||||
("PY", "Paraguay"),
|
||||
("PE", "Peru"),
|
||||
("PH", "Philippines"),
|
||||
("PN", "Pitcairn"),
|
||||
("PL", "Poland"),
|
||||
("PT", "Portugal"),
|
||||
("PR", "Puerto Rico"),
|
||||
("QA", "Qatar"),
|
||||
("RE", "Reunion"),
|
||||
("RO", "Romania"),
|
||||
("RU", "Russian Federation"),
|
||||
("RW", "Rwanda"),
|
||||
("BL", "Saint Barthelemy"),
|
||||
("SH", "Saint Helena"),
|
||||
("KN", "Saint Kitts and Nevis"),
|
||||
("LC", "Saint Lucia"),
|
||||
("MF", "Saint Martin"),
|
||||
("PM", "Saint Pierre and Miquelon"),
|
||||
("VC", "Saint Vincent and the Grenadines"),
|
||||
("WS", "Samoa"),
|
||||
("SM", "San Marino"),
|
||||
("ST", "Sao Tome and Principe"),
|
||||
("SA", "Saudi Arabia"),
|
||||
("SN", "Senegal"),
|
||||
("RS", "Serbia"),
|
||||
("SC", "Seychelles"),
|
||||
("SL", "Sierra Leone"),
|
||||
("SG", "Singapore"),
|
||||
("SK", "Slovakia"),
|
||||
("SI", "Slovenia"),
|
||||
("SB", "Solomon Islands"),
|
||||
("SO", "Somalia"),
|
||||
("ZA", "South Africa"),
|
||||
("GS", "South Georgia and the South Sandwich Islands"),
|
||||
("ES", "Spain"),
|
||||
("LK", "Sri Lanka"),
|
||||
("SD", "Sudan"),
|
||||
("SR", "Suriname"),
|
||||
("SJ", "Svalbard and Jan Mayen"),
|
||||
("SZ", "Swaziland"),
|
||||
("SE", "Sweden"),
|
||||
("CH", "Switzerland"),
|
||||
("SY", "Syrian Arab Republic"),
|
||||
("TW", "Taiwan, Province of China"),
|
||||
("TJ", "Tajikistan"),
|
||||
("TZ", "Tanzania, United Republic of"),
|
||||
("TH", "Thailand"),
|
||||
("TL", "Timor-Leste"),
|
||||
("TG", "Togo"),
|
||||
("TK", "Tokelau"),
|
||||
("TO", "Tonga"),
|
||||
("TT", "Trinidad and Tobago"),
|
||||
("TN", "Tunisia"),
|
||||
("TR", "Turkey"),
|
||||
("TM", "Turkmenistan"),
|
||||
("TC", "Turks and Caicos Islands"),
|
||||
("TV", "Tuvalu"),
|
||||
("UG", "Uganda"),
|
||||
("UA", "Ukraine"),
|
||||
("AE", "United Arab Emirates"),
|
||||
("US", "United States"),
|
||||
("UM", "United States Minor Outlying Islands"),
|
||||
("UY", "Uruguay"),
|
||||
("UZ", "Uzbekistan"),
|
||||
("VU", "Vanuatu"),
|
||||
("VE", "Venezuela"),
|
||||
("VN", "Viet Nam"),
|
||||
("VG", "Virgin Islands, British"),
|
||||
("VI", "Virgin Islands, U.S."),
|
||||
("WF", "Wallis and Futuna"),
|
||||
("EH", "Western Sahara"),
|
||||
("YE", "Yemen"),
|
||||
("ZM", "Zambia"),
|
||||
("ZW", "Zimbabwe"),
|
||||
],
|
||||
max_length=3,
|
||||
null=True,
|
||||
verbose_name="Country",
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, null=True, verbose_name="Notes"),
|
||||
),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
(
|
||||
"assigned_to",
|
||||
models.ManyToManyField(
|
||||
related_name="contact_assigned_users", to="common.profile"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"org",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="contacts",
|
||||
to="common.org",
|
||||
),
|
||||
),
|
||||
("tags", models.ManyToManyField(blank=True, to="common.tags")),
|
||||
(
|
||||
"teams",
|
||||
models.ManyToManyField(
|
||||
related_name="contact_teams", to="common.teams"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Contact",
|
||||
"verbose_name_plural": "Contacts",
|
||||
"db_table": "contacts",
|
||||
"ordering": ("-created_at",),
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["organization"], name="contacts_organiz_8cebf0_idx"
|
||||
),
|
||||
models.Index(
|
||||
fields=["org", "-created_at"], name="contacts_org_id_f280a0_idx"
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
76
backend/contacts/models.py
Normal file
76
backend/contacts/models.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import arrow
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from common.base import AssignableMixin, BaseModel
|
||||
from common.models import Org, Profile, Tags, Teams
|
||||
from common.utils import COUNTRIES
|
||||
|
||||
|
||||
class Contact(AssignableMixin, BaseModel):
|
||||
"""
|
||||
Contact model for CRM - Streamlined for modern sales workflow
|
||||
Based on Twenty CRM and Salesforce patterns
|
||||
"""
|
||||
|
||||
# Core Contact Information
|
||||
first_name = models.CharField(_("First name"), max_length=255)
|
||||
last_name = models.CharField(_("Last name"), max_length=255)
|
||||
email = models.EmailField(_("Email"), blank=True, null=True)
|
||||
phone = PhoneNumberField(_("Phone"), null=True, blank=True)
|
||||
|
||||
# Professional Information
|
||||
organization = models.CharField(_("Company"), max_length=255, blank=True, null=True)
|
||||
title = models.CharField(_("Job Title"), max_length=255, blank=True, null=True)
|
||||
department = models.CharField(
|
||||
_("Department"), max_length=255, blank=True, null=True
|
||||
)
|
||||
|
||||
# Communication Preferences
|
||||
do_not_call = models.BooleanField(_("Do Not Call"), default=False)
|
||||
linked_in_url = models.URLField(_("LinkedIn URL"), blank=True, null=True)
|
||||
|
||||
# Address (flat fields like Lead model)
|
||||
address_line = models.CharField(_("Address"), max_length=255, blank=True, null=True)
|
||||
city = models.CharField(_("City"), max_length=255, blank=True, null=True)
|
||||
state = models.CharField(_("State"), max_length=255, blank=True, null=True)
|
||||
postcode = models.CharField(_("Postal Code"), max_length=64, blank=True, null=True)
|
||||
country = models.CharField(
|
||||
_("Country"), max_length=3, choices=COUNTRIES, blank=True, null=True
|
||||
)
|
||||
|
||||
# Assignment
|
||||
assigned_to = models.ManyToManyField(Profile, related_name="contact_assigned_users")
|
||||
teams = models.ManyToManyField(Teams, related_name="contact_teams")
|
||||
|
||||
# Tags
|
||||
tags = models.ManyToManyField(Tags, blank=True)
|
||||
|
||||
# Notes
|
||||
description = models.TextField(_("Notes"), blank=True, null=True)
|
||||
|
||||
# System Fields
|
||||
is_active = models.BooleanField(default=True)
|
||||
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="contacts")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Contact"
|
||||
verbose_name_plural = "Contacts"
|
||||
db_table = "contacts"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["organization"]),
|
||||
models.Index(fields=["org", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.first_name
|
||||
|
||||
@property
|
||||
def created_on_arrow(self):
|
||||
return arrow.get(self.created_at).humanize()
|
||||
|
||||
@property
|
||||
def created_on(self):
|
||||
return self.created_at
|
||||
|
|
@ -2,23 +2,22 @@ from rest_framework import serializers
|
|||
|
||||
from common.serializer import (
|
||||
AttachmentsSerializer,
|
||||
BillingAddressSerializer,
|
||||
OrganizationSerializer,
|
||||
ProfileSerializer,
|
||||
TeamsSerializer,
|
||||
)
|
||||
from contacts.models import Contact
|
||||
from teams.serializer import TeamsSerializer
|
||||
|
||||
|
||||
class ContactSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for reading Contact data"""
|
||||
|
||||
teams = TeamsSerializer(read_only=True, many=True)
|
||||
assigned_to = ProfileSerializer(read_only=True, many=True)
|
||||
address = BillingAddressSerializer(read_only=True)
|
||||
get_team_users = ProfileSerializer(read_only=True, many=True)
|
||||
get_team_and_assigned_users = ProfileSerializer(read_only=True, many=True)
|
||||
get_assigned_users_not_in_teams = ProfileSerializer(read_only=True, many=True)
|
||||
contact_attachment = AttachmentsSerializer(read_only=True, many=True)
|
||||
date_of_birth = serializers.DateField()
|
||||
org = OrganizationSerializer()
|
||||
country = serializers.SerializerMethodField()
|
||||
|
||||
|
|
@ -29,87 +28,94 @@ class ContactSerializer(serializers.ModelSerializer):
|
|||
model = Contact
|
||||
fields = (
|
||||
"id",
|
||||
"salutation",
|
||||
# Core Contact Information
|
||||
"first_name",
|
||||
"last_name",
|
||||
"date_of_birth",
|
||||
"email",
|
||||
"phone",
|
||||
# Professional Information
|
||||
"organization",
|
||||
"title",
|
||||
"primary_email",
|
||||
"secondary_email",
|
||||
"mobile_number",
|
||||
"secondary_number",
|
||||
"department",
|
||||
"country",
|
||||
"language",
|
||||
# Communication Preferences
|
||||
"do_not_call",
|
||||
"address",
|
||||
"description",
|
||||
"linked_in_url",
|
||||
"facebook_url",
|
||||
"twitter_username",
|
||||
"contact_attachment",
|
||||
# Address
|
||||
"address_line",
|
||||
"city",
|
||||
"state",
|
||||
"postcode",
|
||||
"country",
|
||||
# Assignment
|
||||
"assigned_to",
|
||||
"teams",
|
||||
# Tags
|
||||
"tags",
|
||||
# Notes
|
||||
"description",
|
||||
# System
|
||||
"created_by",
|
||||
"created_at",
|
||||
"is_active",
|
||||
"teams",
|
||||
"org",
|
||||
"created_on_arrow",
|
||||
"get_team_users",
|
||||
"get_team_and_assigned_users",
|
||||
"get_assigned_users_not_in_teams",
|
||||
"org",
|
||||
"contact_attachment",
|
||||
)
|
||||
|
||||
|
||||
class CreateContactSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating/updating Contact data"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
request_obj = kwargs.pop("request_obj", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if request_obj:
|
||||
self.org = request_obj.profile.org
|
||||
|
||||
def validate_first_name(self, first_name):
|
||||
if self.instance:
|
||||
if (
|
||||
Contact.objects.filter(first_name__iexact=first_name, org=self.org)
|
||||
.exclude(id=self.instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Contact already exists with this name"
|
||||
)
|
||||
|
||||
else:
|
||||
if Contact.objects.filter(
|
||||
first_name__iexact=first_name, org=self.org
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Contact already exists with this name"
|
||||
)
|
||||
return first_name
|
||||
def validate_email(self, email):
|
||||
if email:
|
||||
if self.instance:
|
||||
if (
|
||||
Contact.objects.filter(email__iexact=email, org=self.org)
|
||||
.exclude(id=self.instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"Contact already exists with this email"
|
||||
)
|
||||
else:
|
||||
if Contact.objects.filter(email__iexact=email, org=self.org).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Contact already exists with this email"
|
||||
)
|
||||
return email
|
||||
|
||||
class Meta:
|
||||
model = Contact
|
||||
fields = (
|
||||
"salutation",
|
||||
# Core Contact Information
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"phone",
|
||||
# Professional Information
|
||||
"organization",
|
||||
"title",
|
||||
"primary_email",
|
||||
"secondary_email",
|
||||
"mobile_number",
|
||||
"secondary_number",
|
||||
"department",
|
||||
"country",
|
||||
"language",
|
||||
# Communication Preferences
|
||||
"do_not_call",
|
||||
"address",
|
||||
"description",
|
||||
"linked_in_url",
|
||||
"facebook_url",
|
||||
"twitter_username",
|
||||
# Address
|
||||
"address_line",
|
||||
"city",
|
||||
"state",
|
||||
"postcode",
|
||||
"country",
|
||||
# Notes
|
||||
"description",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -117,5 +123,6 @@ class ContactDetailEditSwaggerSerializer(serializers.Serializer):
|
|||
comment = serializers.CharField()
|
||||
contact_attachment = serializers.FileField()
|
||||
|
||||
|
||||
class ContactCommentEditSwaggerSerializer(serializers.Serializer):
|
||||
comment = serializers.CharField()
|
||||
47
backend/contacts/swagger_params.py
Normal file
47
backend/contacts/swagger_params.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter
|
||||
|
||||
organization_params_in_header = organization_params_in_header = OpenApiParameter(
|
||||
"org", OpenApiTypes.STR, OpenApiParameter.HEADER
|
||||
)
|
||||
|
||||
organization_params = [
|
||||
organization_params_in_header,
|
||||
]
|
||||
|
||||
contact_list_get_params = [
|
||||
organization_params_in_header,
|
||||
OpenApiParameter("name", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("city", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("assigned_to", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
]
|
||||
|
||||
contact_create_post_params = [
|
||||
organization_params_in_header,
|
||||
OpenApiParameter("salutation", OpenApiParameter.QUERY, OpenApiTypes.STR),
|
||||
OpenApiParameter("first_name", OpenApiParameter.QUERY, OpenApiTypes.STR),
|
||||
OpenApiParameter("last_name", OpenApiParameter.QUERY, OpenApiTypes.STR),
|
||||
OpenApiParameter("date_of_birth", OpenApiParameter.QUERY, OpenApiTypes.STR),
|
||||
OpenApiParameter("organization", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("title", OpenApiParameter.QUERY, OpenApiTypes.STR),
|
||||
OpenApiParameter("email", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("secondary_email", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("phone", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("secondary_number", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("department", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("language", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("do_not_call", OpenApiParameter.QUERY, OpenApiTypes.BOOL),
|
||||
OpenApiParameter("address_line", OpenApiParameter.QUERY, OpenApiTypes.STR),
|
||||
OpenApiParameter("street", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("city", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("state", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("pincode", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("country", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("description", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("linked_in_url", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("facebook_url", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("twitter_username", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("teams", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("assigned_to", OpenApiTypes.STR, OpenApiParameter.QUERY),
|
||||
OpenApiParameter("contact_attachment", OpenApiParameter.QUERY, OpenApiTypes.BINARY),
|
||||
]
|
||||
|
|
@ -10,4 +10,3 @@ urlpatterns = [
|
|||
path("comment/<str:pk>/", views.ContactCommentView.as_view()),
|
||||
path("attachment/<str:pk>/", views.ContactAttachmentView.as_view()),
|
||||
]
|
||||
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema
|
||||
|
|
@ -9,31 +10,28 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.models import Attachments, Comment, Profile
|
||||
from common.serializer import (
|
||||
AttachmentsSerializer,
|
||||
BillingAddressSerializer,
|
||||
CommentSerializer,
|
||||
)
|
||||
from common.models import Attachments, Comment, Profile, Teams
|
||||
from common.serializer import AttachmentsSerializer, CommentSerializer
|
||||
from common.utils import COUNTRIES
|
||||
|
||||
#from common.external_auth import CustomDualAuthentication
|
||||
from contacts import swagger_params1
|
||||
# from common.external_auth import CustomDualAuthentication
|
||||
from contacts import swagger_params
|
||||
from contacts.models import Contact, Profile
|
||||
from contacts.serializer import *
|
||||
from contacts.tasks import send_email_to_assigned_user
|
||||
from tasks.serializer import TaskSerializer
|
||||
from teams.models import Teams
|
||||
|
||||
|
||||
class ContactsListView(APIView, LimitOffsetPagination):
|
||||
#authentication_classes = (CustomDualAuthentication,)
|
||||
# authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
model = Contact
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
params = self.request.query_params
|
||||
queryset = self.model.objects.filter(org=self.request.profile.org).order_by("-id")
|
||||
queryset = self.model.objects.filter(org=self.request.profile.org).order_by(
|
||||
"-id"
|
||||
)
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
queryset = queryset.filter(
|
||||
Q(assigned_to__in=[self.request.profile])
|
||||
|
|
@ -46,9 +44,9 @@ class ContactsListView(APIView, LimitOffsetPagination):
|
|||
if params.get("city"):
|
||||
queryset = queryset.filter(address__city__icontains=params.get("city"))
|
||||
if params.get("phone"):
|
||||
queryset = queryset.filter(mobile_number__icontains=params.get("phone"))
|
||||
queryset = queryset.filter(phone__icontains=params.get("phone"))
|
||||
if params.get("email"):
|
||||
queryset = queryset.filter(primary_email__icontains=params.get("email"))
|
||||
queryset = queryset.filter(email__icontains=params.get("email"))
|
||||
if params.getlist("assigned_to"):
|
||||
queryset = queryset.filter(
|
||||
assigned_to__id__in=params.get("assigned_to")
|
||||
|
|
@ -68,45 +66,42 @@ class ContactsListView(APIView, LimitOffsetPagination):
|
|||
context["per_page"] = 10
|
||||
page_number = (int(self.offset / 10) + 1,)
|
||||
context["page_number"] = page_number
|
||||
context.update({"contacts_count": self.count, "offset": offset})
|
||||
# Standard DRF pagination format for frontend compatibility
|
||||
context["count"] = self.count
|
||||
context["results"] = contacts
|
||||
# Legacy format for backwards compatibility
|
||||
context["contacts_count"] = self.count
|
||||
context["offset"] = offset
|
||||
context["contact_obj_list"] = contacts
|
||||
context["countries"] = COUNTRIES
|
||||
users = Profile.objects.filter(is_active=True, org=self.request.profile.org).values(
|
||||
"id", "user__email"
|
||||
)
|
||||
users = Profile.objects.filter(
|
||||
is_active=True, org=self.request.profile.org
|
||||
).values("id", "user__email")
|
||||
context["users"] = users
|
||||
|
||||
return context
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.contact_list_get_params
|
||||
)
|
||||
@extend_schema(tags=["contacts"], parameters=swagger_params.contact_list_get_params)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data(**kwargs)
|
||||
return Response(context)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.organization_params,request=CreateContactSerializer
|
||||
tags=["contacts"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=CreateContactSerializer,
|
||||
)
|
||||
def post(self, request, *args, **kwargs):
|
||||
params = request.data
|
||||
contact_serializer = CreateContactSerializer(data=params, request_obj=request)
|
||||
address_serializer = BillingAddressSerializer(data=params)
|
||||
|
||||
data = {}
|
||||
if not contact_serializer.is_valid():
|
||||
data["contact_errors"] = contact_serializer.errors
|
||||
if not address_serializer.is_valid():
|
||||
data["address_errors"] = (address_serializer.errors,)
|
||||
if data:
|
||||
return Response(
|
||||
{"error": True, "errors": data},
|
||||
{"error": True, "errors": contact_serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# if contact_serializer.is_valid() and address_serializer.is_valid():
|
||||
address_obj = address_serializer.save()
|
||||
contact_obj = contact_serializer.save(date_of_birth=params.get("date_of_birth"))
|
||||
contact_obj.address = address_obj
|
||||
# Contact model uses flat address fields, no separate Address object needed
|
||||
contact_obj = contact_serializer.save()
|
||||
contact_obj.org = request.profile.org
|
||||
contact_obj.save()
|
||||
|
||||
|
|
@ -117,7 +112,9 @@ class ContactsListView(APIView, LimitOffsetPagination):
|
|||
|
||||
if params.get("assigned_to"):
|
||||
assinged_to_list = params.get("assigned_to")
|
||||
profiles = Profile.objects.filter(id__in=assinged_to_list, org=request.profile.org)
|
||||
profiles = Profile.objects.filter(
|
||||
id__in=assinged_to_list, org=request.profile.org
|
||||
)
|
||||
contact_obj.assigned_to.add(*profiles)
|
||||
|
||||
recipients = list(contact_obj.assigned_to.all().values_list("id", flat=True))
|
||||
|
|
@ -145,15 +142,16 @@ class ContactDetailView(APIView):
|
|||
model = Contact
|
||||
|
||||
def get_object(self, pk):
|
||||
return get_object_or_404(Contact, pk=pk)
|
||||
return get_object_or_404(Contact, pk=pk, org=self.request.profile.org)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.contact_create_post_params,request=CreateContactSerializer
|
||||
tags=["contacts"],
|
||||
parameters=swagger_params.contact_create_post_params,
|
||||
request=CreateContactSerializer,
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
data = request.data
|
||||
contact_obj = self.get_object(pk=pk)
|
||||
address_obj = contact_obj.address
|
||||
if contact_obj.org != request.profile.org:
|
||||
return Response(
|
||||
{"error": True, "errors": "User company doesnot match with header...."},
|
||||
|
|
@ -162,84 +160,65 @@ class ContactDetailView(APIView):
|
|||
contact_serializer = CreateContactSerializer(
|
||||
data=data, instance=contact_obj, request_obj=request
|
||||
)
|
||||
address_serializer = BillingAddressSerializer(data=data, instance=address_obj)
|
||||
data = {}
|
||||
if not contact_serializer.is_valid():
|
||||
data["contact_errors"] = contact_serializer.errors
|
||||
if not address_serializer.is_valid():
|
||||
data["address_errors"] = (address_serializer.errors,)
|
||||
if data:
|
||||
data["error"] = True
|
||||
return Response(
|
||||
data,
|
||||
{"error": True, "errors": contact_serializer.errors},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if contact_serializer.is_valid():
|
||||
if (
|
||||
self.request.profile.role != "ADMIN"
|
||||
and not self.request.profile.is_admin
|
||||
if self.request.profile.role != "ADMIN" and not self.request.profile.is_admin:
|
||||
if not (
|
||||
(self.request.profile == contact_obj.created_by)
|
||||
or (self.request.profile in contact_obj.assigned_to.all())
|
||||
):
|
||||
if not (
|
||||
(self.request.profile == contact_obj.created_by)
|
||||
or (self.request.profile in contact_obj.assigned_to.all())
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": True,
|
||||
"errors": "You do not have Permission to perform this action",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
address_obj = address_serializer.save()
|
||||
contact_obj = contact_serializer.save(
|
||||
date_of_birth=data.get("date_of_birth")
|
||||
)
|
||||
contact_obj.address = address_obj
|
||||
contact_obj.save()
|
||||
contact_obj = contact_serializer.save()
|
||||
contact_obj.teams.clear()
|
||||
if data.get("teams"):
|
||||
teams_list = json.loads(data.get("teams"))
|
||||
teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org)
|
||||
contact_obj.teams.add(*teams)
|
||||
|
||||
contact_obj.assigned_to.clear()
|
||||
if data.get("assigned_to"):
|
||||
assinged_to_list = json.loads(data.get("assigned_to"))
|
||||
profiles = Profile.objects.filter(
|
||||
id__in=assinged_to_list, org=request.profile.org
|
||||
return Response(
|
||||
{
|
||||
"error": True,
|
||||
"errors": "You do not have Permission to perform this action",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
contact_obj.assigned_to.add(*profiles)
|
||||
|
||||
previous_assigned_to_users = list(
|
||||
contact_obj.assigned_to.all().values_list("id", flat=True)
|
||||
)
|
||||
contact_obj = contact_serializer.save()
|
||||
contact_obj.teams.clear()
|
||||
if data.get("teams"):
|
||||
teams_list = json.loads(data.get("teams"))
|
||||
teams = Teams.objects.filter(id__in=teams_list, org=request.profile.org)
|
||||
contact_obj.teams.add(*teams)
|
||||
|
||||
assigned_to_list = list(
|
||||
contact_obj.assigned_to.all().values_list("id", flat=True)
|
||||
)
|
||||
recipients = list(set(assigned_to_list) - set(previous_assigned_to_users))
|
||||
send_email_to_assigned_user.delay(
|
||||
recipients,
|
||||
contact_obj.id,
|
||||
)
|
||||
if request.FILES.get("contact_attachment"):
|
||||
attachment = Attachments()
|
||||
attachment.created_by = request.profile.user
|
||||
attachment.file_name = request.FILES.get("contact_attachment").name
|
||||
attachment.contact = contact_obj
|
||||
attachment.attachment = request.FILES.get("contact_attachment")
|
||||
attachment.save()
|
||||
return Response(
|
||||
{"error": False, "message": "Contact Updated Successfully"},
|
||||
status=status.HTTP_200_OK,
|
||||
contact_obj.assigned_to.clear()
|
||||
if data.get("assigned_to"):
|
||||
assinged_to_list = json.loads(data.get("assigned_to"))
|
||||
profiles = Profile.objects.filter(
|
||||
id__in=assinged_to_list, org=request.profile.org
|
||||
)
|
||||
contact_obj.assigned_to.add(*profiles)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.organization_params
|
||||
)
|
||||
previous_assigned_to_users = list(
|
||||
contact_obj.assigned_to.all().values_list("id", flat=True)
|
||||
)
|
||||
|
||||
assigned_to_list = list(
|
||||
contact_obj.assigned_to.all().values_list("id", flat=True)
|
||||
)
|
||||
recipients = list(set(assigned_to_list) - set(previous_assigned_to_users))
|
||||
send_email_to_assigned_user.delay(
|
||||
recipients,
|
||||
contact_obj.id,
|
||||
)
|
||||
if request.FILES.get("contact_attachment"):
|
||||
attachment = Attachments()
|
||||
attachment.created_by = request.profile.user
|
||||
attachment.file_name = request.FILES.get("contact_attachment").name
|
||||
attachment.contact = contact_obj
|
||||
attachment.attachment = request.FILES.get("contact_attachment")
|
||||
attachment.save()
|
||||
return Response(
|
||||
{"error": False, "message": "Contact Updated Successfully"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@extend_schema(tags=["contacts"], parameters=swagger_params.organization_params)
|
||||
def get(self, request, pk, format=None):
|
||||
context = {}
|
||||
contact_obj = self.get_object(pk)
|
||||
|
|
@ -287,16 +266,30 @@ class ContactDetailView(APIView):
|
|||
if request.profile == contact_obj.created_by:
|
||||
user_assgn_list.append(self.request.profile.id)
|
||||
|
||||
context["address_obj"] = BillingAddressSerializer(contact_obj.address).data
|
||||
# Address is now flat fields on Contact model
|
||||
context["address_obj"] = {
|
||||
"address_line": contact_obj.address_line,
|
||||
"city": contact_obj.city,
|
||||
"state": contact_obj.state,
|
||||
"postcode": contact_obj.postcode,
|
||||
"country": contact_obj.country,
|
||||
}
|
||||
context["countries"] = COUNTRIES
|
||||
contact_content_type = ContentType.objects.get_for_model(Contact)
|
||||
comments = Comment.objects.filter(
|
||||
content_type=contact_content_type,
|
||||
object_id=contact_obj.id,
|
||||
org=request.profile.org,
|
||||
).order_by("-id")
|
||||
attachments = Attachments.objects.filter(
|
||||
content_type=contact_content_type,
|
||||
object_id=contact_obj.id,
|
||||
org=request.profile.org,
|
||||
).order_by("-id")
|
||||
context.update(
|
||||
{
|
||||
"comments": CommentSerializer(
|
||||
contact_obj.contact_comments.all(), many=True
|
||||
).data,
|
||||
"attachments": AttachmentsSerializer(
|
||||
contact_obj.contact_attachment.all(), many=True
|
||||
).data,
|
||||
"comments": CommentSerializer(comments, many=True).data,
|
||||
"attachments": AttachmentsSerializer(attachments, many=True).data,
|
||||
"assigned_data": assigned_data,
|
||||
"tasks": TaskSerializer(
|
||||
contact_obj.contacts_tasks.all(), many=True
|
||||
|
|
@ -306,9 +299,7 @@ class ContactDetailView(APIView):
|
|||
)
|
||||
return Response(context)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.organization_params
|
||||
)
|
||||
@extend_schema(tags=["contacts"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
if self.object.org != request.profile.org:
|
||||
|
|
@ -337,7 +328,9 @@ class ContactDetailView(APIView):
|
|||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.organization_params,request=ContactDetailEditSwaggerSerializer
|
||||
tags=["contacts"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=ContactDetailEditSwaggerSerializer,
|
||||
)
|
||||
def post(self, request, pk, **kwargs):
|
||||
params = request.data
|
||||
|
|
@ -372,11 +365,16 @@ class ContactDetailView(APIView):
|
|||
attachment.attachment = self.request.FILES.get("contact_attachment")
|
||||
attachment.save()
|
||||
|
||||
comments = Comment.objects.filter(contact__id=self.contact_obj.id).order_by(
|
||||
"-id"
|
||||
)
|
||||
contact_content_type = ContentType.objects.get_for_model(Contact)
|
||||
comments = Comment.objects.filter(
|
||||
content_type=contact_content_type,
|
||||
object_id=self.contact_obj.id,
|
||||
org=self.request.profile.org,
|
||||
).order_by("-id")
|
||||
attachments = Attachments.objects.filter(
|
||||
contact__id=self.contact_obj.id
|
||||
content_type=contact_content_type,
|
||||
object_id=self.contact_obj.id,
|
||||
org=self.request.profile.org,
|
||||
).order_by("-id")
|
||||
context.update(
|
||||
{
|
||||
|
|
@ -394,10 +392,12 @@ class ContactCommentView(APIView):
|
|||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get_object(self, pk):
|
||||
return self.model.objects.get(pk=pk)
|
||||
return self.model.objects.get(pk=pk, org=self.request.profile.org)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.organization_params,request=ContactCommentEditSwaggerSerializer
|
||||
tags=["contacts"],
|
||||
parameters=swagger_params.organization_params,
|
||||
request=ContactCommentEditSwaggerSerializer,
|
||||
)
|
||||
def put(self, request, pk, format=None):
|
||||
params = request.data
|
||||
|
|
@ -426,9 +426,7 @@ class ContactCommentView(APIView):
|
|||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.organization_params
|
||||
)
|
||||
@extend_schema(tags=["contacts"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.get_object(pk)
|
||||
if (
|
||||
|
|
@ -455,9 +453,7 @@ class ContactAttachmentView(APIView):
|
|||
# #authentication_classes = (CustomDualAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["contacts"], parameters=swagger_params1.organization_params
|
||||
)
|
||||
@extend_schema(tags=["contacts"], parameters=swagger_params.organization_params)
|
||||
def delete(self, request, pk, format=None):
|
||||
self.object = self.model.objects.get(pk=pk)
|
||||
if (
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue