Multi-Company Support¶
Netforce provides comprehensive multi-company (multi-tenant) support, allowing a single installation to serve multiple organizations with complete data isolation and company-specific configurations.
Overview¶
The multi-company system provides:
- Data Isolation - Each company's data is completely separated
- Shared Configuration - Common settings and templates across companies
- Company Context - Automatic filtering based on active company
- User Access Control - Users can access specific companies
- Cross-Company Operations - When explicitly needed
Core Concepts¶
Company Model¶
The company model is the foundation of multi-company support:
class Company(Model):
_name = "company"
_fields = {
"name": fields.Char("Company Name", required=True),
"code": fields.Char("Company Code", required=True),
"logo": fields.File("Logo"),
"website": fields.Char("Website"),
"email": fields.Char("Email"),
"phone": fields.Char("Phone"),
"address": fields.Text("Address"),
"currency_id": fields.Many2One("currency", "Base Currency"),
"active": fields.Boolean("Active"),
}
Multi-Company Models¶
Enable multi-company support by adding the _multi_company flag:
class Invoice(Model):
_name = "account.invoice"
_multi_company = True # Enable multi-company support
_fields = {
"number": fields.Char("Number", required=True),
"customer_id": fields.Many2One("contact", "Customer"),
"company_id": fields.Many2One("company", "Company", required=True),
# ... other fields
}
_defaults = {
"company_id": lambda *a: get_active_company(),
}
Implementation Patterns¶
1. Basic Multi-Company Model¶
from netforce.access import get_active_company, set_active_company
class Product(Model):
_name = "product.product"
_multi_company = True
_content_search = True # Also supports multi-company search
_fields = {
"code": fields.Char("Product Code", required=True, search=True),
"name": fields.Char("Product Name", required=True, search=True),
"price": fields.Decimal("Price"),
"company_id": fields.Many2One("company", "Company", required=True),
# Note: company_id is automatically added when _multi_company = True
}
# Unique constraint per company
_key = ["code", "company_id"]
_defaults = {
"company_id": lambda *a: get_active_company(),
}
def search(self, condition=None, context={}):
"""Search automatically filters by active company"""
# Framework automatically adds company filter
# condition becomes: condition + [["company_id", "=", active_company]]
return super().search(condition, context)
2. Company Context Management¶
def get_company_data(self, company_ids=None):
"""Get data for specific companies"""
if not company_ids:
company_ids = [get_active_company()]
results = {}
for company_id in company_ids:
# Switch company context
original_company = get_active_company()
try:
set_active_company(company_id)
# Data will be filtered by this company
invoices = get_model("account.invoice").search_browse([
["state", "=", "draft"]
])
results[company_id] = {
"draft_invoices": len(invoices),
"total_amount": sum(inv.amount_total for inv in invoices)
}
finally:
# Always restore original company
set_active_company(original_company)
return results
3. Cross-Company Operations¶
For operations that need to work across companies:
def get_all_companies_data(self):
"""Get data from all companies (admin operation)"""
# Temporarily disable company filtering
original_company = get_active_company()
try:
# Switch to admin context
set_active_company(None) # No company filtering
# Now searches return data from all companies
all_invoices = get_model("account.invoice").search_browse([])
# Group by company
by_company = {}
for invoice in all_invoices:
company_id = invoice.company_id.id
if company_id not in by_company:
by_company[company_id] = []
by_company[company_id].append(invoice)
return by_company
finally:
set_active_company(original_company)
def consolidate_reports(self, company_ids):
"""Create consolidated report across companies"""
consolidated_data = {}
for company_id in company_ids:
set_active_company(company_id)
# Get company-specific data
company_data = self.get_financial_data()
consolidated_data[company_id] = company_data
# Process consolidated data
return self.merge_company_data(consolidated_data)
User Access Control¶
Company-User Relationships¶
Users can have access to multiple companies:
class User(Model):
_name = "base.user"
_fields = {
"login": fields.Char("Login", required=True),
"name": fields.Char("Name", required=True),
"companies": fields.Many2Many("company", "User Companies"),
"company_id": fields.Many2One("company", "Default Company"),
# ... other fields
}
def get_accessible_companies(self, user_id):
"""Get companies user can access"""
user = self.browse(user_id)
return [comp.id for comp in user.companies]
def switch_company(self, user_id, company_id):
"""Switch user's active company"""
user = self.browse(user_id)
# Check if user has access to this company
company_ids = [comp.id for comp in user.companies]
if company_id not in company_ids:
raise Exception("User does not have access to this company")
# Update user's default company
user.write({"company_id": company_id})
# Set active company in session
set_active_company(company_id)
Permission Filtering¶
Implement company-based permissions:
def check_company_access(self, user_id, company_id):
"""Check if user can access company data"""
user = get_model("base.user").browse(user_id)
accessible_companies = [comp.id for comp in user.companies]
return company_id in accessible_companies
@company_access_required
def sensitive_operation(self, ids, context={}):
"""Operation that requires company access validation"""
user_id = get_active_user()
company_id = get_active_company()
if not self.check_company_access(user_id, company_id):
raise Exception("Insufficient company access")
# Proceed with operation
return super().sensitive_operation(ids, context)
Configuration Management¶
Shared vs Company-Specific Settings¶
class Settings(Model):
_name = "settings"
_multi_company = True
_fields = {
# Company-specific settings
"company_id": fields.Many2One("company", "Company"),
"invoice_sequence_id": fields.Many2One("sequence", "Invoice Sequence"),
"default_currency_id": fields.Many2One("currency", "Default Currency"),
"logo": fields.File("Company Logo"),
# Shared settings (same across all companies)
"system_name": fields.Char("System Name"),
"maintenance_mode": fields.Boolean("Maintenance Mode"),
}
def get_setting(self, setting_name, company_id=None):
"""Get setting value with company context"""
if company_id:
settings = self.search_browse([["company_id", "=", company_id]])
else:
settings = self.search_browse([])
if settings:
return getattr(settings[0], setting_name, None)
return None
# Global settings (not multi-company)
class GlobalSettings(Model):
_name = "global.settings"
_multi_company = False # Explicitly disable multi-company
_fields = {
"backup_frequency": fields.Selection([
["daily", "Daily"],
["weekly", "Weekly"]
], "Backup Frequency"),
"max_file_size": fields.Integer("Max File Size (MB)"),
}
Database Schema Considerations¶
Company Field Indexing¶
class MultiCompanyModel(Model):
_name = "my.model"
_multi_company = True
# Add database indexes for company-based queries
_sql_constraints = [
("company_code_uniq", "unique (company_id, code)",
"Code must be unique per company"),
]
# Add indexes for performance
def _create_indexes(self):
"""Create database indexes for multi-company queries"""
database.execute("""
CREATE INDEX IF NOT EXISTS my_model_company_state_idx
ON my_model (company_id, state)
""")
database.execute("""
CREATE INDEX IF NOT EXISTS my_model_company_date_idx
ON my_model (company_id, date DESC)
""")
Data Migration Between Companies¶
def migrate_data_between_companies(self, from_company_id, to_company_id, record_ids):
"""Migrate records from one company to another"""
# Validate companies exist
from_company = get_model("company").browse(from_company_id)
to_company = get_model("company").browse(to_company_id)
migrated_records = []
for record_id in record_ids:
try:
# Switch to source company context
set_active_company(from_company_id)
source_record = self.browse(record_id)
# Read record data
record_data = source_record.read()[0]
# Remove system fields
system_fields = ['id', 'create_time', 'create_uid', 'write_time', 'write_uid']
for field in system_fields:
record_data.pop(field, None)
# Update company
record_data['company_id'] = to_company_id
# Switch to target company context
set_active_company(to_company_id)
# Create record in target company
new_record_id = self.create(record_data)
migrated_records.append(new_record_id)
# Optional: Archive original record
set_active_company(from_company_id)
source_record.write({"active": False})
except Exception as e:
print(f"Failed to migrate record {record_id}: {str(e)}")
return migrated_records
API Integration¶
Company-Aware API Endpoints¶
# JSON-RPC with company context
def execute_with_company(model_name, method, args, company_id, context={}):
"""Execute method in specific company context"""
original_company = get_active_company()
try:
set_active_company(company_id)
# Execute method with company context
result = get_model(model_name).call(method, args, context)
return result
finally:
set_active_company(original_company)
# REST API example
class CompanyAPIController(Controller):
_path = "/api/v1/companies/{company_id}"
def get(self, company_id):
"""Get data for specific company"""
# Validate company access
user_id = get_active_user()
if not self.check_company_access(user_id, int(company_id)):
return {"error": "Access denied"}, 403
# Set company context
set_active_company(int(company_id))
# Return company data
return {
"company": get_model("company").browse(int(company_id)).read()[0],
"stats": self.get_company_stats()
}
Frontend Integration¶
Company Switching UI¶
// Company selector component
class CompanySelector extends React.Component {
state = {
companies: [],
activeCompany: null
};
componentDidMount() {
this.loadCompanies();
}
loadCompanies() {
rpc.execute("base.user", "get_user_companies", [], {}, (err, companies) => {
this.setState({companies});
});
}
switchCompany(companyId) {
rpc.execute("base.user", "switch_company", [companyId], {}, (err, result) => {
if (!err) {
// Refresh page to load new company data
window.location.reload();
}
});
}
render() {
return (
<div className="company-selector">
<select onChange={e => this.switchCompany(parseInt(e.target.value))}>
{this.state.companies.map(company => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</div>
);
}
}
Company-Aware Components¶
// Automatic company filtering in components
class InvoiceList extends React.Component {
loadData() {
// Data automatically filtered by active company
rpc.execute("account.invoice", "search_read", [
[["state", "=", "draft"]] // Company filter added automatically
], {}, (err, invoices) => {
this.setState({invoices});
});
}
// Cross-company search (admin only)
loadAllCompaniesData() {
rpc.execute("account.invoice", "search_read", [
[] // No filters
], {
company_id: null // Disable company filtering
}, (err, invoices) => {
// Groups invoices by company
const byCompany = invoices.reduce((acc, inv) => {
const companyId = inv.company_id[0];
if (!acc[companyId]) acc[companyId] = [];
acc[companyId].push(inv);
return acc;
}, {});
this.setState({invoicesByCompany: byCompany});
});
}
}
Performance Optimization¶
Company-Based Caching¶
# Cache data per company
_company_cache = {}
def get_company_cache(cache_key, company_id=None):
"""Get cached data for specific company"""
if company_id is None:
company_id = get_active_company()
full_key = f"{company_id}:{cache_key}"
return _company_cache.get(full_key)
def set_company_cache(cache_key, data, company_id=None):
"""Cache data for specific company"""
if company_id is None:
company_id = get_active_company()
full_key = f"{company_id}:{cache_key}"
_company_cache[full_key] = data
def clear_company_cache(company_id=None):
"""Clear cache for specific company"""
if company_id is None:
company_id = get_active_company()
keys_to_remove = [k for k in _company_cache.keys() if k.startswith(f"{company_id}:")]
for key in keys_to_remove:
del _company_cache[key]
Query Optimization¶
# Efficient company-based queries
def get_company_summary(self, company_ids):
"""Get summary data for multiple companies efficiently"""
summaries = {}
# Single query for all companies
sql = """
SELECT
company_id,
COUNT(*) as total_records,
SUM(amount_total) as total_amount,
AVG(amount_total) as avg_amount
FROM account_invoice
WHERE company_id IN %s
GROUP BY company_id
"""
results = database.get(sql, [tuple(company_ids)])
for row in results:
summaries[row[0]] = {
"total_records": row[1],
"total_amount": row[2],
"avg_amount": row[3]
}
return summaries
Best Practices¶
1. Always Include Company Context¶
# Good: Always consider company context
def create_invoice(self, customer_id, lines):
company_id = get_active_company()
return get_model("account.invoice").create({
"customer_id": customer_id,
"company_id": company_id,
"lines": lines
})
# Bad: Forgetting company context
def create_invoice_bad(self, customer_id, lines):
# Missing company_id - will fail or use wrong company
return get_model("account.invoice").create({
"customer_id": customer_id,
"lines": lines
})
2. Handle Company Switching Safely¶
def safe_company_operation(self, target_company_id, operation_func):
"""Safely execute operation in different company context"""
original_company = get_active_company()
try:
set_active_company(target_company_id)
return operation_func()
except Exception as e:
# Log error with company context
self.log_error(f"Company {target_company_id} operation failed: {str(e)}")
raise
finally:
# Always restore original company
set_active_company(original_company)
3. Validate Company Access¶
def validate_company_access(self, user_id, company_id, operation="read"):
"""Validate user has required access to company"""
user = get_model("base.user").browse(user_id)
# Check if user has access to company
user_companies = [comp.id for comp in user.companies]
if company_id not in user_companies:
raise Exception(f"User {user_id} does not have access to company {company_id}")
# Additional operation-specific checks
if operation == "admin":
if not user.is_admin:
raise Exception("Admin access required")
Troubleshooting¶
Common Issues¶
- Data Leakage Between Companies
- Always check
_multi_company = Trueon models - Verify company filters in custom queries
-
Use company context properly
-
Permission Errors
- Check user-company relationships
- Validate company access in API calls
-
Ensure proper company switching
-
Performance Issues
- Add company indexes to frequently queried tables
- Use company-specific caching
- Optimize cross-company operations
Debugging¶
def debug_company_context():
"""Debug current company context"""
current_company = get_active_company()
current_user = get_active_user()
user = get_model("base.user").browse(current_user)
user_companies = [comp.id for comp in user.companies]
print(f"Current Company: {current_company}")
print(f"Current User: {current_user}")
print(f"User Companies: {user_companies}")
print(f"Has Access: {current_company in user_companies}")
Next Steps¶
- Learn about Security for company-based permissions
- Explore Sequences for company-specific numbering
- Check Workflows for company-aware approval processes
- Review Advanced Patterns for complex multi-company scenarios