refactor: implement module-driven permissions and relocate business logic

File Relocations:
- Delete app/config/ folder (empty after menu_registry removal)
- Move feature_gate.py → app/modules/billing/dependencies/
- Move theme_presets.py → app/modules/cms/services/

Module-Driven Permissions System:
- Add PermissionDefinition dataclass to app/modules/base.py
- Create PermissionDiscoveryService in tenancy module
- Update module definitions to declare their own permissions:
  - core: dashboard.view, settings.*
  - catalog: products.*
  - orders: orders.*
  - inventory: stock.*
  - customers: customers.*
  - tenancy: team.*
- Update app/core/permissions.py to use discovery service
- Role presets (owner, manager, staff, etc.) now use module permissions

This follows the same pattern as module-driven menus:
- Each module defines its permissions in definition.py
- PermissionDiscoveryService aggregates all permissions at runtime
- Tenancy module handles role-to-permission assignment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:42:13 +01:00
parent 31e3d0fcba
commit 03395a9dfa
16 changed files with 749 additions and 121 deletions

View File

@@ -16,7 +16,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db, require_module_access
from app.core.feature_gate import RequireFeature
from app.modules.billing.dependencies.feature_gate import RequireFeature
from app.modules.analytics.services import stats_service
from app.modules.analytics.schemas import (
VendorAnalyticsCatalog,

View File

@@ -134,6 +134,48 @@ class MenuSectionDefinition:
is_collapsible: bool = True
# =============================================================================
# Permission Definitions
# =============================================================================
@dataclass
class PermissionDefinition:
"""
Definition of a permission that a module exposes.
Permissions are granular capabilities that can be assigned to roles/users.
Each module defines its own permissions, which are then discovered and
aggregated by the tenancy module for role assignment.
Attributes:
id: Unique identifier in format "resource.action" (e.g., "products.view")
label_key: i18n key for the permission label
description_key: i18n key for permission description
category: Grouping category for UI organization (e.g., "products", "orders")
is_owner_only: If True, only vendor owners can have this permission
Example:
PermissionDefinition(
id="products.view",
label_key="catalog.permissions.products_view",
description_key="catalog.permissions.products_view_desc",
category="products",
)
"""
id: str
label_key: str
description_key: str = ""
category: str = "general"
is_owner_only: bool = False
# =============================================================================
# Scheduled Task Definitions
# =============================================================================
@dataclass
class ScheduledTask:
"""
@@ -275,7 +317,7 @@ class ModuleDefinition:
# =========================================================================
features: list[str] = field(default_factory=list)
menu_items: dict[FrontendType, list[str]] = field(default_factory=dict)
permissions: list[str] = field(default_factory=list)
permissions: list[PermissionDefinition] = field(default_factory=list)
# =========================================================================
# Menu Definitions (Module-Driven Menus)
@@ -400,9 +442,13 @@ class ModuleDefinition:
"""Check if this module provides a specific feature."""
return feature_code in self.features
def has_permission(self, permission_code: str) -> bool:
def has_permission(self, permission_id: str) -> bool:
"""Check if this module defines a specific permission."""
return permission_code in self.permissions
return any(p.id == permission_id for p in self.permissions)
def get_permission_ids(self) -> set[str]:
"""Get all permission IDs defined by this module."""
return {p.id for p in self.permissions}
# =========================================================================
# Dependency Methods

View File

@@ -0,0 +1,14 @@
# app/modules/billing/dependencies/__init__.py
"""FastAPI dependencies for the billing module."""
from .feature_gate import (
require_feature,
RequireFeature,
FeatureNotAvailableError,
)
__all__ = [
"require_feature",
"RequireFeature",
"FeatureNotAvailableError",
]

View File

@@ -0,0 +1,254 @@
# app/core/feature_gate.py
"""
Feature gating decorator and dependencies for tier-based access control.
Provides:
- @require_feature decorator for endpoints
- RequireFeature dependency for flexible usage
- FeatureNotAvailableError exception with upgrade info
Usage:
# As decorator (simple)
@router.get("/analytics")
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
def get_analytics(...):
...
# As dependency (more control)
@router.get("/analytics")
def get_analytics(
_: None = Depends(RequireFeature(FeatureCode.ANALYTICS_DASHBOARD)),
...
):
...
# Multiple features (any one required)
@require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS)
def get_reports(...):
...
"""
import functools
import logging
from typing import Callable
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.modules.billing.services.feature_service import feature_service
from app.modules.billing.models import FeatureCode
from app.modules.tenancy.models import User
logger = logging.getLogger(__name__)
class FeatureNotAvailableError(HTTPException):
"""
Exception raised when a feature is not available for the vendor's tier.
Includes upgrade information for the frontend to display.
"""
def __init__(
self,
feature_code: str,
feature_name: str | None = None,
required_tier_code: str | None = None,
required_tier_name: str | None = None,
required_tier_price_cents: int | None = None,
):
detail = {
"error": "feature_not_available",
"message": f"This feature requires an upgrade to access.",
"feature_code": feature_code,
"feature_name": feature_name,
"upgrade": {
"tier_code": required_tier_code,
"tier_name": required_tier_name,
"price_monthly_cents": required_tier_price_cents,
}
if required_tier_code
else None,
}
super().__init__(status_code=403, detail=detail)
class RequireFeature:
"""
Dependency class that checks if vendor has access to a feature.
Can be used as a FastAPI dependency:
@router.get("/analytics")
def get_analytics(
_: None = Depends(RequireFeature("analytics_dashboard")),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
...
Args:
*feature_codes: One or more feature codes. Access granted if ANY is available.
"""
def __init__(self, *feature_codes: str):
if not feature_codes:
raise ValueError("At least one feature code is required")
self.feature_codes = feature_codes
def __call__(
self,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
) -> None:
"""Check if vendor has access to any of the required features."""
vendor_id = current_user.token_vendor_id
# Check if vendor has ANY of the required features
for feature_code in self.feature_codes:
if feature_service.has_feature(db, vendor_id, feature_code):
return None
# None of the features are available - get upgrade info for first one
feature_code = self.feature_codes[0]
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
raise FeatureNotAvailableError(
feature_code=feature_code,
feature_name=upgrade_info.feature_name,
required_tier_code=upgrade_info.required_tier_code,
required_tier_name=upgrade_info.required_tier_name,
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
)
else:
# Feature not found in registry
raise FeatureNotAvailableError(feature_code=feature_code)
def require_feature(*feature_codes: str) -> Callable:
"""
Decorator to require one or more features for an endpoint.
The decorated endpoint will return 403 with upgrade info if the vendor
doesn't have access to ANY of the specified features.
Args:
*feature_codes: One or more feature codes. Access granted if ANY is available.
Example:
@router.get("/analytics/dashboard")
@require_feature(FeatureCode.ANALYTICS_DASHBOARD)
async def get_analytics_dashboard(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
...
# Multiple features (any one is sufficient)
@router.get("/reports")
@require_feature(FeatureCode.ANALYTICS_DASHBOARD, FeatureCode.BASIC_REPORTS)
async def get_reports(...):
...
"""
if not feature_codes:
raise ValueError("At least one feature code is required")
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
# Extract dependencies from kwargs
db = kwargs.get("db")
current_user = kwargs.get("current_user")
if not db or not current_user:
# Try to get from request if not in kwargs
request = kwargs.get("request")
if request and hasattr(request, "state"):
db = getattr(request.state, "db", None)
current_user = getattr(request.state, "user", None)
if not db or not current_user:
raise HTTPException(
status_code=500,
detail="Feature check failed: missing db or current_user dependency",
)
vendor_id = current_user.token_vendor_id
# Check if vendor has ANY of the required features
for feature_code in feature_codes:
if feature_service.has_feature(db, vendor_id, feature_code):
return await func(*args, **kwargs)
# None available - raise with upgrade info
feature_code = feature_codes[0]
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
raise FeatureNotAvailableError(
feature_code=feature_code,
feature_name=upgrade_info.feature_name,
required_tier_code=upgrade_info.required_tier_code,
required_tier_name=upgrade_info.required_tier_name,
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
)
else:
raise FeatureNotAvailableError(feature_code=feature_code)
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
# Extract dependencies from kwargs
db = kwargs.get("db")
current_user = kwargs.get("current_user")
if not db or not current_user:
raise HTTPException(
status_code=500,
detail="Feature check failed: missing db or current_user dependency",
)
vendor_id = current_user.token_vendor_id
# Check if vendor has ANY of the required features
for feature_code in feature_codes:
if feature_service.has_feature(db, vendor_id, feature_code):
return func(*args, **kwargs)
# None available - raise with upgrade info
feature_code = feature_codes[0]
upgrade_info = feature_service.get_feature_upgrade_info(db, feature_code)
if upgrade_info:
raise FeatureNotAvailableError(
feature_code=feature_code,
feature_name=upgrade_info.feature_name,
required_tier_code=upgrade_info.required_tier_code,
required_tier_name=upgrade_info.required_tier_name,
required_tier_price_cents=upgrade_info.required_tier_price_monthly_cents,
)
else:
raise FeatureNotAvailableError(feature_code=feature_code)
# Return appropriate wrapper based on whether func is async
import asyncio
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
# ============================================================================
# Convenience Exports
# ============================================================================
__all__ = [
"require_feature",
"RequireFeature",
"FeatureNotAvailableError",
"FeatureCode",
]

View File

@@ -1,7 +1,12 @@
# app/modules/catalog/definition.py
"""Catalog module definition."""
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from app.modules.base import (
MenuItemDefinition,
MenuSectionDefinition,
ModuleDefinition,
PermissionDefinition,
)
from app.modules.enums import FrontendType
module = ModuleDefinition(
@@ -11,7 +16,46 @@ module = ModuleDefinition(
version="1.0.0",
is_self_contained=True,
requires=["inventory"],
# New module-driven menu definitions
# Module-driven permissions
permissions=[
PermissionDefinition(
id="products.view",
label_key="catalog.permissions.products_view",
description_key="catalog.permissions.products_view_desc",
category="products",
),
PermissionDefinition(
id="products.create",
label_key="catalog.permissions.products_create",
description_key="catalog.permissions.products_create_desc",
category="products",
),
PermissionDefinition(
id="products.edit",
label_key="catalog.permissions.products_edit",
description_key="catalog.permissions.products_edit_desc",
category="products",
),
PermissionDefinition(
id="products.delete",
label_key="catalog.permissions.products_delete",
description_key="catalog.permissions.products_delete_desc",
category="products",
),
PermissionDefinition(
id="products.import",
label_key="catalog.permissions.products_import",
description_key="catalog.permissions.products_import_desc",
category="products",
),
PermissionDefinition(
id="products.export",
label_key="catalog.permissions.products_export",
description_key="catalog.permissions.products_export_desc",
category="products",
),
],
# Module-driven menu definitions
menus={
FrontendType.VENDOR: [
MenuSectionDefinition(

View File

@@ -0,0 +1,249 @@
# app/core/theme_presets.py
"""
Theme presets for vendor shops.
Presets define default color schemes, fonts, and layouts that vendors can choose from.
Each preset provides a complete theme configuration that can be customized further.
"""
from app.modules.cms.models import VendorTheme
THEME_PRESETS = {
"default": {
"colors": {
"primary": "#6366f1", # Indigo
"secondary": "#8b5cf6", # Purple
"accent": "#ec4899", # Pink
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#e5e7eb", # Gray-200
},
"fonts": {"heading": "Inter, sans-serif", "body": "Inter, sans-serif"},
"layout": {"style": "grid", "header": "fixed", "product_card": "modern"},
},
"modern": {
"colors": {
"primary": "#6366f1", # Indigo - Modern tech look
"secondary": "#8b5cf6", # Purple
"accent": "#ec4899", # Pink
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#e5e7eb", # Gray-200
},
"fonts": {"heading": "Inter, sans-serif", "body": "Inter, sans-serif"},
"layout": {"style": "grid", "header": "fixed", "product_card": "modern"},
},
"classic": {
"colors": {
"primary": "#1e40af", # Dark blue - Traditional
"secondary": "#7c3aed", # Purple
"accent": "#dc2626", # Red
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#d1d5db", # Gray-300
},
"fonts": {"heading": "Georgia, serif", "body": "Arial, sans-serif"},
"layout": {"style": "list", "header": "static", "product_card": "classic"},
},
"minimal": {
"colors": {
"primary": "#000000", # Black - Ultra minimal
"secondary": "#404040", # Dark gray
"accent": "#666666", # Medium gray
"background": "#ffffff", # White
"text": "#000000", # Black
"border": "#e5e7eb", # Light gray
},
"fonts": {"heading": "Helvetica, sans-serif", "body": "Helvetica, sans-serif"},
"layout": {"style": "grid", "header": "transparent", "product_card": "minimal"},
},
"vibrant": {
"colors": {
"primary": "#f59e0b", # Orange - Bold & energetic
"secondary": "#ef4444", # Red
"accent": "#8b5cf6", # Purple
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#fbbf24", # Yellow
},
"fonts": {"heading": "Poppins, sans-serif", "body": "Open Sans, sans-serif"},
"layout": {"style": "masonry", "header": "fixed", "product_card": "modern"},
},
"elegant": {
"colors": {
"primary": "#6b7280", # Gray - Sophisticated
"secondary": "#374151", # Dark gray
"accent": "#d97706", # Amber
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#e5e7eb", # Gray-200
},
"fonts": {"heading": "Playfair Display, serif", "body": "Lato, sans-serif"},
"layout": {"style": "grid", "header": "fixed", "product_card": "classic"},
},
"nature": {
"colors": {
"primary": "#059669", # Green - Natural & eco
"secondary": "#10b981", # Emerald
"accent": "#f59e0b", # Amber
"background": "#ffffff", # White
"text": "#1f2937", # Gray-800
"border": "#d1fae5", # Light green
},
"fonts": {"heading": "Montserrat, sans-serif", "body": "Open Sans, sans-serif"},
"layout": {"style": "grid", "header": "fixed", "product_card": "modern"},
},
}
def get_preset(preset_name: str) -> dict:
"""
Get a theme preset by name.
Args:
preset_name: Name of the preset (e.g., 'modern', 'classic')
Returns:
dict: Theme configuration
Raises:
ValueError: If preset name is unknown
"""
if preset_name not in THEME_PRESETS:
available = ", ".join(THEME_PRESETS.keys())
raise ValueError(f"Unknown preset: {preset_name}. Available: {available}")
return THEME_PRESETS[preset_name]
def apply_preset(theme: VendorTheme, preset_name: str) -> VendorTheme:
"""
Apply a preset to a vendor theme.
Args:
theme: VendorTheme instance to update
preset_name: Name of the preset to apply
Returns:
VendorTheme: Updated theme instance
Raises:
ValueError: If preset name is unknown
Example:
theme = VendorTheme(vendor_id=1)
apply_preset(theme, "modern")
db.add(theme)
db.commit()
"""
preset = get_preset(preset_name)
# Set theme name
theme.theme_name = preset_name
# Apply colors (all of them!)
theme.colors = preset["colors"]
# Apply fonts
theme.font_family_heading = preset["fonts"]["heading"]
theme.font_family_body = preset["fonts"]["body"]
# Apply layout settings
theme.layout_style = preset["layout"]["style"]
theme.header_style = preset["layout"]["header"]
theme.product_card_style = preset["layout"]["product_card"]
# Mark as active
theme.is_active = True
return theme
def get_available_presets() -> list[str]:
"""
Get list of available preset names.
Returns:
list: Available preset names
"""
return list(THEME_PRESETS.keys())
def get_preset_preview(preset_name: str) -> dict:
"""
Get preview information for a preset (for UI display).
Args:
preset_name: Name of the preset
Returns:
dict: Preview info with colors, fonts, description
"""
preset = get_preset(preset_name)
descriptions = {
"default": "Clean and professional - perfect for getting started",
"modern": "Contemporary tech-inspired design with vibrant colors",
"classic": "Traditional and trustworthy with serif typography",
"minimal": "Ultra-clean black and white aesthetic",
"vibrant": "Bold and energetic with bright accent colors",
"elegant": "Sophisticated gray tones with refined typography",
"nature": "Fresh and eco-friendly green color palette",
}
return {
"name": preset_name,
"description": descriptions.get(preset_name, ""),
"primary_color": preset["colors"]["primary"],
"secondary_color": preset["colors"]["secondary"],
"accent_color": preset["colors"]["accent"],
"heading_font": preset["fonts"]["heading"],
"body_font": preset["fonts"]["body"],
"layout_style": preset["layout"]["style"],
}
def create_custom_preset(
colors: dict, fonts: dict, layout: dict, name: str = "custom"
) -> dict:
"""
Create a custom preset from provided settings.
Args:
colors: Dict with primary, secondary, accent, background, text, border
fonts: Dict with heading and body fonts
layout: Dict with style, header, product_card
name: Name for the custom preset
Returns:
dict: Custom preset configuration
Example:
custom = create_custom_preset(
colors={"primary": "#ff0000", "secondary": "#00ff00", ...},
fonts={"heading": "Arial", "body": "Arial"},
layout={"style": "grid", "header": "fixed", "product_card": "modern"},
name="my_custom_theme"
)
"""
# Validate colors
required_colors = ["primary", "secondary", "accent", "background", "text", "border"]
for color_key in required_colors:
if color_key not in colors:
colors[color_key] = THEME_PRESETS["default"]["colors"][color_key]
# Validate fonts
if "heading" not in fonts:
fonts["heading"] = "Inter, sans-serif"
if "body" not in fonts:
fonts["body"] = "Inter, sans-serif"
# Validate layout
if "style" not in layout:
layout["style"] = "grid"
if "header" not in layout:
layout["header"] = "fixed"
if "product_card" not in layout:
layout["product_card"] = "modern"
return {"colors": colors, "fonts": fonts, "layout": layout}

View File

@@ -11,7 +11,7 @@ import re
from sqlalchemy.orm import Session
from app.core.theme_presets import (
from app.modules.cms.services.theme_presets import (
THEME_PRESETS,
apply_preset,
get_available_presets,

View File

@@ -6,7 +6,12 @@ Dashboard, settings, and profile management.
Required for basic operation - cannot be disabled.
"""
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from app.modules.base import (
MenuItemDefinition,
MenuSectionDefinition,
ModuleDefinition,
PermissionDefinition,
)
from app.modules.enums import FrontendType
core_module = ModuleDefinition(
@@ -16,6 +21,40 @@ core_module = ModuleDefinition(
version="1.0.0",
is_core=True,
is_self_contained=True,
# Module-driven permissions
permissions=[
PermissionDefinition(
id="dashboard.view",
label_key="core.permissions.dashboard_view",
description_key="core.permissions.dashboard_view_desc",
category="dashboard",
),
PermissionDefinition(
id="settings.view",
label_key="core.permissions.settings_view",
description_key="core.permissions.settings_view_desc",
category="settings",
),
PermissionDefinition(
id="settings.edit",
label_key="core.permissions.settings_edit",
description_key="core.permissions.settings_edit_desc",
category="settings",
),
PermissionDefinition(
id="settings.theme",
label_key="core.permissions.settings_theme",
description_key="core.permissions.settings_theme_desc",
category="settings",
),
PermissionDefinition(
id="settings.domains",
label_key="core.permissions.settings_domains",
description_key="core.permissions.settings_domains_desc",
category="settings",
is_owner_only=True, # Only owners can manage domains
),
],
features=[
"dashboard",
"settings",

View File

@@ -6,7 +6,12 @@ Defines the customers module including its features, menu items,
route configurations, and self-contained module settings.
"""
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from app.modules.base import (
MenuItemDefinition,
MenuSectionDefinition,
ModuleDefinition,
PermissionDefinition,
)
from app.modules.enums import FrontendType
@@ -30,6 +35,33 @@ customers_module = ModuleDefinition(
name="Customer Management",
description="Customer database, profiles, addresses, and segmentation.",
version="1.0.0",
# Module-driven permissions
permissions=[
PermissionDefinition(
id="customers.view",
label_key="customers.permissions.customers_view",
description_key="customers.permissions.customers_view_desc",
category="customers",
),
PermissionDefinition(
id="customers.edit",
label_key="customers.permissions.customers_edit",
description_key="customers.permissions.customers_edit_desc",
category="customers",
),
PermissionDefinition(
id="customers.delete",
label_key="customers.permissions.customers_delete",
description_key="customers.permissions.customers_delete_desc",
category="customers",
),
PermissionDefinition(
id="customers.export",
label_key="customers.permissions.customers_export",
description_key="customers.permissions.customers_export_desc",
category="customers",
),
],
features=[
"customer_view", # View customer profiles
"customer_export", # Export customer data

View File

@@ -6,7 +6,12 @@ Defines the inventory module including its features, menu items,
route configurations, and self-contained module settings.
"""
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from app.modules.base import (
MenuItemDefinition,
MenuSectionDefinition,
ModuleDefinition,
PermissionDefinition,
)
from app.modules.enums import FrontendType
@@ -33,6 +38,27 @@ inventory_module = ModuleDefinition(
"transaction history, and bulk imports."
),
version="1.0.0",
# Module-driven permissions
permissions=[
PermissionDefinition(
id="stock.view",
label_key="inventory.permissions.stock_view",
description_key="inventory.permissions.stock_view_desc",
category="stock",
),
PermissionDefinition(
id="stock.edit",
label_key="inventory.permissions.stock_edit",
description_key="inventory.permissions.stock_edit_desc",
category="stock",
),
PermissionDefinition(
id="stock.transfer",
label_key="inventory.permissions.stock_transfer",
description_key="inventory.permissions.stock_transfer_desc",
category="stock",
),
],
features=[
"inventory_basic", # Basic stock tracking
"inventory_locations", # Multiple warehouse locations

View File

@@ -6,7 +6,12 @@ Defines the orders module including its features, menu items,
route configurations, and self-contained module settings.
"""
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from app.modules.base import (
MenuItemDefinition,
MenuSectionDefinition,
ModuleDefinition,
PermissionDefinition,
)
from app.modules.enums import FrontendType
@@ -34,6 +39,33 @@ orders_module = ModuleDefinition(
),
version="1.0.0",
requires=["payments"], # Depends on payments module for checkout
# Module-driven permissions
permissions=[
PermissionDefinition(
id="orders.view",
label_key="orders.permissions.orders_view",
description_key="orders.permissions.orders_view_desc",
category="orders",
),
PermissionDefinition(
id="orders.edit",
label_key="orders.permissions.orders_edit",
description_key="orders.permissions.orders_edit_desc",
category="orders",
),
PermissionDefinition(
id="orders.cancel",
label_key="orders.permissions.orders_cancel",
description_key="orders.permissions.orders_cancel_desc",
category="orders",
),
PermissionDefinition(
id="orders.refund",
label_key="orders.permissions.orders_refund",
description_key="orders.permissions.orders_refund_desc",
category="orders",
),
],
features=[
"order_management", # Basic order CRUD
"order_bulk_actions", # Bulk status updates

View File

@@ -33,7 +33,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, require_module_access
from app.core.database import get_db
from app.core.feature_gate import RequireFeature
from app.modules.billing.dependencies.feature_gate import RequireFeature
from app.modules.orders.exceptions import (
InvoicePDFNotFoundException,
)

View File

@@ -6,7 +6,12 @@ Platform, company, vendor, and admin user management.
Required for multi-tenant operation - cannot be disabled.
"""
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from app.modules.base import (
MenuItemDefinition,
MenuSectionDefinition,
ModuleDefinition,
PermissionDefinition,
)
from app.modules.enums import FrontendType
tenancy_module = ModuleDefinition(
@@ -16,6 +21,36 @@ tenancy_module = ModuleDefinition(
version="1.0.0",
is_core=True,
is_self_contained=True,
# Module-driven permissions
permissions=[
PermissionDefinition(
id="team.view",
label_key="tenancy.permissions.team_view",
description_key="tenancy.permissions.team_view_desc",
category="team",
),
PermissionDefinition(
id="team.invite",
label_key="tenancy.permissions.team_invite",
description_key="tenancy.permissions.team_invite_desc",
category="team",
is_owner_only=True,
),
PermissionDefinition(
id="team.edit",
label_key="tenancy.permissions.team_edit",
description_key="tenancy.permissions.team_edit_desc",
category="team",
is_owner_only=True,
),
PermissionDefinition(
id="team.remove",
label_key="tenancy.permissions.team_remove",
description_key="tenancy.permissions.team_remove_desc",
category="team",
is_owner_only=True,
),
],
features=[
"platform_management",
"company_management",

View File

@@ -0,0 +1,407 @@
# app/modules/tenancy/services/permission_discovery_service.py
"""
Permission Discovery Service - Discovers and aggregates permissions from all modules.
This service implements the module-driven permission system where each module
defines its own permissions through PermissionDefinition in its definition.py file.
Key Features:
- Discovers permission definitions from all loaded modules
- Groups permissions by category for UI organization
- Provides role preset mappings (owner, manager, staff, etc.)
- Supports permission checking and validation
Usage:
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service
)
# Get all permissions
all_perms = permission_discovery_service.get_all_permissions()
# Get permissions grouped by category
grouped = permission_discovery_service.get_permissions_by_category()
# Get permission IDs for a role preset
staff_perms = permission_discovery_service.get_preset_permissions("staff")
# Check if a permission ID is valid
is_valid = permission_discovery_service.is_valid_permission("products.view")
"""
import logging
from dataclasses import dataclass, field
from app.modules.base import PermissionDefinition
logger = logging.getLogger(__name__)
@dataclass
class DiscoveredPermission:
"""
A permission discovered from a module, enriched with module info.
Extends PermissionDefinition with module context for tracking
which module provides each permission.
"""
id: str
label_key: str
description_key: str
category: str
is_owner_only: bool
module_code: str
@dataclass
class PermissionCategory:
"""
A category grouping related permissions for UI organization.
"""
id: str
label_key: str
permissions: list[DiscoveredPermission] = field(default_factory=list)
class PermissionDiscoveryService:
"""
Service to discover and aggregate permissions from all modules.
This service:
1. Collects permission definitions from all module definition.py files
2. Groups permissions by category for UI display
3. Provides role preset definitions (owner, manager, staff, etc.)
4. Validates permission IDs
"""
# =========================================================================
# Role Presets
# =========================================================================
# Role presets define default permission sets. These reference permission IDs
# that should be defined by modules. The service validates these at runtime.
ROLE_PRESETS = {
"owner": None, # Special: owners get ALL permissions
"manager": {
# Dashboard
"dashboard.view",
# Products
"products.view",
"products.create",
"products.edit",
"products.delete",
"products.import",
"products.export",
# Stock/Inventory
"stock.view",
"stock.edit",
"stock.transfer",
# Orders
"orders.view",
"orders.edit",
"orders.cancel",
"orders.refund",
# Customers
"customers.view",
"customers.edit",
"customers.export",
# Marketing
"marketing.view",
"marketing.create",
"marketing.send",
# Reports
"reports.view",
"reports.financial",
"reports.export",
# Settings (limited)
"settings.view",
"settings.theme",
# Imports
"imports.view",
"imports.create",
"imports.cancel",
},
"staff": {
# Dashboard
"dashboard.view",
# Products
"products.view",
"products.create",
"products.edit",
# Stock
"stock.view",
"stock.edit",
# Orders
"orders.view",
"orders.edit",
# Customers
"customers.view",
"customers.edit",
},
"support": {
# Dashboard
"dashboard.view",
# Products (view only)
"products.view",
# Orders
"orders.view",
"orders.edit",
# Customers
"customers.view",
"customers.edit",
},
"viewer": {
# Read-only access
"dashboard.view",
"products.view",
"stock.view",
"orders.view",
"customers.view",
"reports.view",
},
"marketing": {
# Marketing-focused role
"dashboard.view",
"customers.view",
"customers.export",
"marketing.view",
"marketing.create",
"marketing.send",
"reports.view",
},
}
# Category labels for UI grouping
CATEGORY_LABELS = {
"dashboard": "tenancy.permissions.category.dashboard",
"products": "tenancy.permissions.category.products",
"stock": "tenancy.permissions.category.stock",
"orders": "tenancy.permissions.category.orders",
"customers": "tenancy.permissions.category.customers",
"marketing": "tenancy.permissions.category.marketing",
"reports": "tenancy.permissions.category.reports",
"settings": "tenancy.permissions.category.settings",
"team": "tenancy.permissions.category.team",
"imports": "tenancy.permissions.category.imports",
"general": "tenancy.permissions.category.general",
}
def get_all_permissions(self) -> list[DiscoveredPermission]:
"""
Discover all permissions from all loaded modules.
Returns:
List of DiscoveredPermission objects from all modules
"""
from app.modules.registry import MODULES
permissions = []
for module_code, module_def in MODULES.items():
for perm in module_def.permissions:
discovered = DiscoveredPermission(
id=perm.id,
label_key=perm.label_key,
description_key=perm.description_key,
category=perm.category,
is_owner_only=perm.is_owner_only,
module_code=module_code,
)
permissions.append(discovered)
return sorted(permissions, key=lambda p: (p.category, p.id))
def get_all_permission_ids(self) -> set[str]:
"""
Get set of all permission IDs from all modules.
Returns:
Set of permission ID strings
"""
from app.modules.registry import MODULES
ids = set()
for module_def in MODULES.values():
ids.update(module_def.get_permission_ids())
return ids
def get_permission(self, permission_id: str) -> DiscoveredPermission | None:
"""
Get a specific permission by ID.
Args:
permission_id: Permission ID to look up
Returns:
DiscoveredPermission if found, None otherwise
"""
from app.modules.registry import MODULES
for module_code, module_def in MODULES.items():
for perm in module_def.permissions:
if perm.id == permission_id:
return DiscoveredPermission(
id=perm.id,
label_key=perm.label_key,
description_key=perm.description_key,
category=perm.category,
is_owner_only=perm.is_owner_only,
module_code=module_code,
)
return None
def get_permissions_by_category(self) -> list[PermissionCategory]:
"""
Get all permissions grouped by category.
Returns:
List of PermissionCategory objects with permissions
"""
all_perms = self.get_all_permissions()
# Group by category
categories_map: dict[str, list[DiscoveredPermission]] = {}
for perm in all_perms:
if perm.category not in categories_map:
categories_map[perm.category] = []
categories_map[perm.category].append(perm)
# Build category objects
categories = []
for cat_id, perms in sorted(categories_map.items()):
label_key = self.CATEGORY_LABELS.get(
cat_id, f"tenancy.permissions.category.{cat_id}"
)
categories.append(
PermissionCategory(
id=cat_id,
label_key=label_key,
permissions=perms,
)
)
return categories
def get_permissions_for_module(
self, module_code: str
) -> list[DiscoveredPermission]:
"""
Get all permissions defined by a specific module.
Args:
module_code: Module code to get permissions for
Returns:
List of DiscoveredPermission from the specified module
"""
from app.modules.registry import MODULES
module_def = MODULES.get(module_code)
if not module_def:
return []
return [
DiscoveredPermission(
id=perm.id,
label_key=perm.label_key,
description_key=perm.description_key,
category=perm.category,
is_owner_only=perm.is_owner_only,
module_code=module_code,
)
for perm in module_def.permissions
]
def is_valid_permission(self, permission_id: str) -> bool:
"""
Check if a permission ID is valid (defined by some module).
Args:
permission_id: Permission ID to validate
Returns:
True if permission exists, False otherwise
"""
return permission_id in self.get_all_permission_ids()
def get_preset_permissions(self, preset_name: str) -> set[str]:
"""
Get permission IDs for a role preset.
Args:
preset_name: Name of preset (owner, manager, staff, support, viewer, marketing)
Returns:
Set of permission IDs for the preset, or empty set if not found.
For "owner", returns all permission IDs.
"""
preset = self.ROLE_PRESETS.get(preset_name.lower())
if preset_name.lower() == "owner":
# Owners get all permissions
return self.get_all_permission_ids()
if preset is None:
logger.warning(f"Unknown role preset: {preset_name}")
return set()
# Filter to only permissions that actually exist
all_perms = self.get_all_permission_ids()
return preset & all_perms
def get_available_presets(self) -> list[str]:
"""
Get list of available role preset names.
Returns:
List of preset names
"""
return list(self.ROLE_PRESETS.keys())
def validate_permissions(self, permission_ids: list[str]) -> list[str]:
"""
Validate a list of permission IDs.
Args:
permission_ids: List of permission IDs to validate
Returns:
List of invalid permission IDs (empty if all valid)
"""
all_perms = self.get_all_permission_ids()
return [pid for pid in permission_ids if pid not in all_perms]
def get_owner_only_permissions(self) -> set[str]:
"""
Get permission IDs that are owner-only.
These permissions cannot be assigned to team members.
Returns:
Set of owner-only permission IDs
"""
from app.modules.registry import MODULES
owner_only = set()
for module_def in MODULES.values():
for perm in module_def.permissions:
if perm.is_owner_only:
owner_only.add(perm.id)
return owner_only
# Singleton instance
permission_discovery_service = PermissionDiscoveryService()
__all__ = [
"permission_discovery_service",
"PermissionDiscoveryService",
"DiscoveredPermission",
"PermissionCategory",
]