revamp and upgrade to modern stack (#562)

revamp and upgrade to modern stack
This commit is contained in:
Ashwin Kumar 2025-11-29 10:07:50 +05:30 committed by GitHub
parent 4ce7a5a2cc
commit fc05248c28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
804 changed files with 42869 additions and 76402 deletions

69
.gitignore vendored
View file

@ -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
View file

@ -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

View file

@ -1,4 +1,4 @@
FROM ubuntu:20.04
FROM ubuntu:24.04
# invalidate cache
ARG APP_NAME

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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}"

View file

@ -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
View 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.

View 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",),
},
),
]

View 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
View 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)

View file

@ -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",
)

View file

@ -15,5 +15,3 @@ account_get_params = [
OpenApiParameter("city", OpenApiTypes.STR, OpenApiParameter.QUERY),
OpenApiParameter("tags", OpenApiTypes.STR, OpenApiParameter.QUERY),
]

View file

@ -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"

View file

@ -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:

View 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",),
},
),
]

View 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
View 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()

View file

@ -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()

View 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

View 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
)

View file

@ -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
View 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",
),
]

View file

@ -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
View 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"

View file

@ -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
View 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
View 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
View 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)

View 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

View file

@ -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))

View file

@ -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

View 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."
)

View 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")
)

File diff suppressed because it is too large Load diff

View file

@ -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)

View 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")

View 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)
"""

File diff suppressed because it is too large Load diff

View 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
View 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

View file

@ -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

View 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")

View 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), '')
);
"""

View 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
View 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")

View file

@ -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

View file

@ -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),
]

View file

@ -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)

View 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

View file

@ -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
View 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()),
]

View file

@ -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

File diff suppressed because it is too large Load diff

View 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"
),
],
},
),
]

View 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

View file

@ -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()

View 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),
]

View file

@ -10,4 +10,3 @@ urlpatterns = [
path("comment/<str:pk>/", views.ContactCommentView.as_view()),
path("attachment/<str:pk>/", views.ContactAttachmentView.as_view()),
]

View file

@ -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