HRM API - Multi-tenant Human Resource Management System
This project is a multi-tenant Human Resource Management System API built with Django 5.2.7 and django-tenants. Each tenant (company) operates in its own schema, and data is completely isolated.
Core Technologies:
- Django 5.2.7 + Django REST Framework
- django-tenants (multi-tenancy)
- PostgreSQL (tenant-per-schema)
- JWT Authentication (djangorestframework-simplejwt)
- Celery + Redis (async tasks)
- Docker + docker-compose
- Python 3.13
Project Language Standards:
- Code & Docstrings: English
- Comments: English
- verbose_name: English with Django translation (_("text"))
- i18n Support: Multi-language via Django's translation system
# Clone the project and copy the environment file
cp .env.example .env
# Start docker containers
docker-compose up -d
# Run database migrations
docker-compose exec web uv run python manage.py migrate_schemas --shared
docker-compose exec web uv run python manage.py migrate_schemas
# Create public tenant (first-time setup)
docker-compose exec web uv run python manage.py shell
>>> from tenant_users.tenants.utils import create_public_tenant
>>> create_public_tenant(domain_url="localhost", owner_email="admin@localhost.com")
>>> exit()
# Development server starts automatically (port 8000)# Install dependencies with uv
uv sync --extra dev
# Make sure PostgreSQL and Redis are running
# Configure .env file
# Run migrations
uv run python manage.py migrate_schemas --shared
uv run python manage.py migrate_schemas
# Start development server
uv run python manage.py runserver
# Start Celery worker (separate terminal)
uv run celery -A config worker -l info# Run all tests
uv run python manage.py test
# Run tests for specific module
uv run python manage.py test employees
uv run python manage.py test auth
# Run tests with coverage
uv run coverage run --source=employees manage.py test employees
uv run coverage report
# Generate HTML coverage report
uv run coverage html
# open htmlcov/index.html
# Parallel module tests like CI pipeline (manual on local)
uv run coverage run --source=auth manage.py test auth
uv run coverage run --source=employees manage.py test employees
uv run coverage run --source=titles manage.py test titles# Format code (Black + isort run automatically)
uv run black .
uv run isort .
# Or use uv format (recommended)
uv format
# Check formatting (runs in CI)
uv format --checkCRITICAL: All tenant-aware operations must be performed within tenant_context().
from django_tenants.utils import tenant_context
from tenants.models import Client
tenant = Client.objects.get(schema_name="evilcorp")
with tenant_context(tenant):
# All ORM queries within this block run in the tenant's schema
employees = Employee.objects.all()Schema Types:
- Public Schema: Shared apps (tenants, users)
- Tenant Schema: Each tenant has its own schema (employees, titles)
Domain Routing: CustomTenantMiddleware provides domain-based routing.
All business logic should be in the Service layer. Views only handle HTTP.
# ✅ CORRECT: Service layer usage
from utils.interfaces import BaseService
class EmployeeService(BaseService):
def create_object(self, user, first_name, last_name, **kwargs):
"""Creates a new employee."""
employee = Employee.objects.create(
user=user,
first_name=first_name,
last_name=last_name,
**kwargs
)
# Business logic here
return employee
def update_object(self, instance, **kwargs):
"""Updates employee information."""
for attr, value in kwargs.items():
setattr(instance, attr, value)
instance.save()
return instance
def delete_object(self, instance, force=False):
"""Deletes employee or performs soft delete."""
if force:
instance.delete()
else:
instance.is_active = False
instance.save()
return instance# Using service in view
class EmployeeViewSet(viewsets.ModelViewSet):
service_class = EmployeeService()
def perform_create(self, serializer):
serializer.instance = self.service_class.create_object(
**serializer.validated_data
)All models should extend BaseModel and use English verbose_name with Django translation.
from django.db import models
from django.utils.translation import gettext_lazy as _
from utils.models import BaseModel
class Employee(BaseModel): # BaseModel: created_on, updated_on, attributes
user = models.OneToOneField(
"users.User",
on_delete=models.CASCADE,
verbose_name=_("user")
)
first_name = models.CharField(_("first name"), max_length=255)
last_name = models.CharField(_("last name"), max_length=255)
role = models.CharField(
_("role"),
max_length=20,
choices=EmployeeRole.choices,
default=EmployeeRole.employee,
)
is_active = models.BooleanField(_("is active"), default=True)
class Meta:
verbose_name = _("employee")
verbose_name_plural = _("employees")
ordering = ["-created_on"]
def __str__(self):
return f"{self.first_name} {self.last_name}"Test classes should use TenantTestCaseMixin.
from django_tenants.test.cases import TenantTestCase
from model_bakery import baker
from utils.tests.mixins import TenantTestCaseMixin, AuthenticatedTenantTestMixin
# Service tests
class EmployeeServiceTestCase(TenantTestCaseMixin, TenantTestCase):
def setUp(self):
super().setUp()
self.service = EmployeeService()
def test_create_object_success(self):
user = baker.make("users.User")
employee = self.service.create_object(
user=user,
first_name="Jane",
last_name="Doe",
)
self.assertIsNotNone(employee)
self.assertEqual(employee.first_name, "Jane")
# View tests (authentication required)
class EmployeeViewSetTestCase(AuthenticatedTenantTestMixin, TenantTestCase):
def setUp(self):
super().setUp()
self.authenticate_client(role=EmployeeRole.manager)
def test_list_success(self):
response = self.get("/api/employees/")
self.assertEqual(response.status_code, 200)- Black: line-length=88, target-version=py313
- isort: profile="black"
- Type hints: Use as much as possible (Python 3.13+ compatible)
- Docstrings: Google style, English
- Line length: Maximum 88 characters
# Classes: PascalCase
class EmployeeService:
pass
# Functions and variables: snake_case
def create_employee(first_name, last_name):
employee_data = {...}
return employee_data
# Constants: UPPER_CASE
MAX_UPLOAD_SIZE = 5 * 1024 * 1024
# Private: _prefix
def _internal_helper():
pass# 1. Standard library
import os
from datetime import datetime
# 2. Django
from django.db import models
from django.utils.translation import gettext_lazy as _
# 3. Third-party
from rest_framework import viewsets
from django_tenants.utils import tenant_context
# 4. First-party (local apps)
from auth.serializers import RegisterSerializer
from employees.models import Employee
from tenants.models import Client
from users.models import User
from utils.interfaces import BaseService
# 5. Local folder
from .models import Title
from .serializers import TitleSerializerdef create_employee(user, first_name, last_name, role=EmployeeRole.employee):
"""
Creates a new employee record.
Args:
user (User): User object
first_name (str): Employee's first name
last_name (str): Employee's last name
role (EmployeeRole, optional): Employee's role. Default: employee
Returns:
Employee: Created employee object
Raises:
ValidationError: On invalid data input
EmployeeAlreadyExistsException: If user is already an employee
Example:
>>> user = User.objects.get(email="john@example.com")
>>> employee = create_employee(user, "John", "Doe", EmployeeRole.manager)
"""
# Implementation
pass# 1. Create model file (example: employees/models.py)
# 2. Extend from BaseModel and use English verbose_name with _()
# 3. Create migration
uv run python manage.py makemigrations employees
# 4. Apply migration (tenant-aware)
uv run python manage.py migrate_schemas# 1. Create serializer (employees/serializers.py)
from rest_framework import serializers
class EmployeeSerializer(serializers.ModelSerializer):
class Meta:
model = Employee
fields = ["id", "first_name", "last_name", "role", "is_active"]
# 2. Create service (employees/services.py)
class EmployeeService(BaseService):
def create_object(self, **kwargs):
return Employee.objects.create(**kwargs)
# ... other methods
# 3. Create ViewSet (employees/views.py)
from rest_framework import viewsets
from drf_spectacular.utils import extend_schema
class EmployeeViewSet(viewsets.ModelViewSet):
queryset = Employee.objects.all()
serializer_class = EmployeeSerializer
service_class = EmployeeService()
@extend_schema(
summary="List employees",
description="Returns all active employees for the tenant"
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
# 4. Add URL (employees/urls.py or config/urls.py)
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register(r'employees', EmployeeViewSet)# employees/tests/test_services.py
from django_tenants.test.cases import TenantTestCase
from model_bakery import baker
from utils.tests.mixins import TenantTestCaseMixin
from employees.services import EmployeeService
class EmployeeServiceTestCase(TenantTestCaseMixin, TenantTestCase):
def setUp(self):
super().setUp()
self.service = EmployeeService()
def test_create_object_success(self):
user = baker.make("users.User")
employee = self.service.create_object(
user=user,
first_name="Test",
last_name="User"
)
self.assertIsNotNone(employee)
self.assertEqual(employee.user, user)
# Run test
uv run python manage.py test employees.tests.test_services# auth/tasks.py
from celery import shared_task
from django.core.mail import send_mail
@shared_task
def send_verification_email(user_email, verification_link):
"""
Sends verification email to user.
Args:
user_email (str): User email address
verification_link (str): Verification link
Returns:
bool: True if successful
"""
send_mail(
subject="Email Verification",
message=f"Verification link: {verification_link}",
from_email="noreply@example.com",
recipient_list=[user_email],
)
return True
# Usage
from auth.tasks import send_verification_email
send_verification_email.delay("user@example.com", "http://...")# Create new tenant
from users.models import User
from tenant_users.tenants.tasks import provision_tenant
owner = User.objects.create_user(
email="owner@company.com",
password="secure_password",
is_staff=True
)
tenant, domain = provision_tenant("Company Name", "company", owner)
# Add user to tenant
from tenants.models import Client
tenant = Client.objects.get(schema_name="company")
user = User.objects.get(email="employee@company.com")
tenant.add_user(user)
# Perform operation in tenant context
from django_tenants.utils import tenant_context
with tenant_context(tenant):
employees = Employee.objects.filter(is_active=True)
# All ORM queries run in this tenant's schemaMinimum coverage rates for each module:
- auth: 85%
- employees: 80%
- tenants: 80%
- users: 75%
- utils: 90%
- titles: 95%
# Run a single test file
uv run python manage.py test employees.tests.test_services.EmployeeServiceTestCase
# Run a specific test method
uv run python manage.py test employees.tests.test_services.EmployeeServiceTestCase.test_create_object_success
# Run with coverage and get report
uv run coverage run --source=employees manage.py test employees
uv run coverage report -m
# Generate HTML coverage report (open in browser)
uv run coverage html
open htmlcov/index.html- Each test should be isolated: Use
setUp()andtearDown() - Use Model Bakery: Avoid unnecessary fixtures
- Tenant context: Use
TenantTestCaseMixin - Descriptive names: Test names should be explanatory
- AAA Pattern: Arrange, Act, Assert
def test_delete_employee_soft_delete(self):
# Arrange (Setup)
employee = baker.make("employees.Employee", is_active=True)
# Act (Action)
self.service.delete_object(employee, force=False)
# Assert (Verification)
employee.refresh_from_db()
self.assertFalse(employee.is_active)- JWT tokens: Access token (60 min), Refresh token (24 hours)
- Token rotation: New token generated on each refresh
- Password hashing: bcrypt is used
- Permission classes: IsAuthenticated, IsMinimumManagerOrReadOnly
- Schema separation: Each tenant operates in its own schema
- Middleware check: CustomTenantMiddleware checks every request
- Cross-tenant queries: Never perform these, use
tenant_context()
# ❌ WRONG: Hardcoded sensitive data
API_KEY = "sk-1234567890abcdef"
# ✅ CORRECT: Use environment variable
import os
API_KEY = os.getenv("API_KEY")# Validation in serializer
class EmployeeSerializer(serializers.ModelSerializer):
def validate_email(self, value):
"""Checks email format."""
if not value or "@" not in value:
raise serializers.ValidationError("Enter a valid email address")
return valuePipeline runs in 3 stages:
- Formatting Check: Code formatting check (parallel)
- Setup & Cache: Create dependency cache (parallel)
- Module Tests: Each module tested in parallel
# Steps running in CI
uv format --check # Format check
uv run coverage run --source=auth manage.py test auth
uv run coverage run --source=employees manage.py test employees
uv run coverage run --source=titles manage.py test titlesPre-commit checklist:
# 1. Format code
uv format
# 2. Run tests
uv run python manage.py test
# 3. Coverage check
uv run coverage run manage.py test
uv run coverage report
# 4. Migration check
uv run python manage.py makemigrations --check --dry-run
# 5. Commit
git add .
git commit -m "feat: add new feature"
git push origin feature/branch-name# Production build
docker-compose -f docker-compose.prod.yml build
# Start containers
docker-compose -f docker-compose.prod.yml up -d
# Run migrations
docker-compose exec web uv run python manage.py migrate_schemas --shared
docker-compose exec web uv run python manage.py migrate_schemas
# Collect static files
docker-compose exec web uv run python manage.py collectstatic --noinputRequired variables in .env file:
SECRET_KEY=your-secret-key-here
DEBUG=False
DB_NAME=hrm_db
DB_USER=hrm_user
DB_PASSWORD=secure_password
DB_HOST=db
DB_PORT=5432
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
CORS_ALLOWED_ORIGINS=https://yourdomain.com# ❌ WRONG: N+1 query problem
employees = Employee.objects.all()
for emp in employees:
print(emp.user.email) # New query on each iteration
# ✅ CORRECT: Use select_related
employees = Employee.objects.select_related('user').all()
for emp in employees:
print(emp.user.email) # Single query# ❌ WRONG: Accessing tenant record in public schema
employee = Employee.objects.get(id=1) # DoesNotExist error
# ✅ CORRECT: Use tenant context
from django_tenants.utils import tenant_context
from tenants.models import Client
tenant = Client.objects.get(schema_name="company")
with tenant_context(tenant):
employee = Employee.objects.get(id=1)# ❌ WRONG: Normal migrate
uv run python manage.py migrate
# ✅ CORRECT: Tenant-aware migrate
uv run python manage.py migrate_schemas --shared # First shared apps
uv run python manage.py migrate_schemas # Then tenant apps# ❌ WRONG: Importing service in model
# models.py
from .services import EmployeeService # Circular import
# ✅ CORRECT: Import in function or be careful with structure
# views.py
from .services import EmployeeService # Import in view# Enter Django shell
docker-compose exec web uv run python manage.py shell
# or locally
uv run python manage.py shell# Debug in tenant context
from django_tenants.utils import tenant_context
from tenants.models import Client
from employees.models import Employee
tenant = Client.objects.get(schema_name="company")
with tenant_context(tenant):
employees = Employee.objects.all()
print(f"Total employees: {employees.count()}")import logging
logger = logging.getLogger(__name__)
def create_employee(**kwargs):
logger.info(f"Creating new employee: {kwargs}")
# ...
logger.debug(f"Employee created: {employee.id}")# Automatically active when DEBUG=True
# http://localhost:8000/__debug__/API documentation is automatically generated (drf-spectacular):
# Swagger UI
http://localhost:8000/api/schema/swagger-ui/
# ReDoc
http://localhost:8000/api/schema/redoc/
# OpenAPI JSON
http://localhost:8000/api/schema/from drf_spectacular.utils import extend_schema, OpenApiParameter
@extend_schema(
summary="Create employee",
description="Creates a new employee record",
request=EmployeeSerializer,
responses={201: EmployeeSerializer},
parameters=[
OpenApiParameter(
name="role",
description="Employee role (employee, manager, owner)",
required=False,
type=str
)
]
)
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)- Setup:
docs/setup.md- Setup steps - Tenants:
docs/tenantlar.md- Multi-tenant architecture - Migrations:
docs/migrationlar.md- Migration strategies - Docker:
docs/docker.md- Docker deployment - CI Pipeline:
docs/ci-pipeline.md- CI/CD configuration - Create Company Flow:
docs/flows/create_company.md- Company creation flow
- Django Tenants: https://django-tenants.readthedocs.io/
- Django REST Framework: https://www.django-rest-framework.org/
- Celery: https://docs.celeryq.dev/
- drf-spectacular: https://drf-spectacular.readthedocs.io/
As an AI agent, when generating code:
- Always use English documentation: Docstrings, comments, and verbose_name in English
- Use Django's translation system: verbose_name with _("text") for i18n support
- Be tenant-aware: Don't forget multi-tenant context
- Use service layer: Business logic in services, not views
- Write tests: Add tests for every new function/class
- Add type hints: Use Python 3.13+ type hints
- Black formatting: Code formatting must follow Black standards
- Use BaseModel/BaseService: Follow inheritance patterns
- Security first: Sensitive data in .env, always validate
# Common commands
uv run python manage.py runserver # Dev server
uv run python manage.py shell # Django shell
uv run python manage.py makemigrations # Create migration
uv run python manage.py migrate_schemas # Apply migration
uv run python manage.py test <app> # Run tests
uv run coverage run manage.py test # Test with coverage
uv format # Format code
docker-compose up -d # Start Docker
docker-compose logs -f web # View logs
docker-compose exec web bash # Enter containerLast Updated: 2025-10-26 Project Version: 0.1.0 Python Version: 3.13 Django Version: 5.2.7