nordabiz/utils/decorators.py
Maciej Pienczyn 4181a2e760 refactor: Migrate access control from is_admin to role-based system
Replace ~170 manual `if not current_user.is_admin` checks with:
- @role_required(SystemRole.ADMIN) for user management, security, ZOPK
- @role_required(SystemRole.OFFICE_MANAGER) for content management
- current_user.can_access_admin_panel() for admin UI access
- current_user.can_moderate_forum() for forum moderation
- current_user.can_edit_company(id) for company permissions

Add @office_manager_required decorator shortcut.
Add SQL migration to sync existing users' role field.

Role hierarchy: UNAFFILIATED(10) < MEMBER(20) < EMPLOYEE(30) < MANAGER(40) < OFFICE_MANAGER(50) < ADMIN(100)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:05:22 +01:00

325 lines
9.9 KiB
Python

"""
Custom Decorators
=================
Reusable decorators for access control and validation.
Role-based decorators (new system):
- @role_required(SystemRole.OFFICE_MANAGER) - Minimum role check
- @company_permission('edit') - Company-specific permissions
- @member_required - Shortcut for role >= MEMBER
Legacy decorators (backward compatibility):
- @admin_required - Alias for @role_required(SystemRole.ADMIN)
- @company_owner_or_admin - Uses new can_edit_company() method
"""
from functools import wraps
from flask import abort, flash, redirect, url_for, request
from flask_login import current_user
# Import role enums (lazy import to avoid circular dependencies)
def _get_system_role():
from database import SystemRole
return SystemRole
# ============================================================
# NEW ROLE-BASED DECORATORS
# ============================================================
def role_required(min_role):
"""
Decorator that requires user to have at least the specified role.
Args:
min_role: Minimum SystemRole required (e.g., SystemRole.OFFICE_MANAGER)
Usage:
@bp.route('/admin/companies')
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_companies():
...
Note: Always use @login_required BEFORE @role_required
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.has_role(min_role):
SystemRole = _get_system_role()
role_names = {
SystemRole.MEMBER: 'członka Izby',
SystemRole.EMPLOYEE: 'pracownika firmy',
SystemRole.MANAGER: 'kadry zarządzającej',
SystemRole.OFFICE_MANAGER: 'kierownika biura',
SystemRole.ADMIN: 'administratora',
}
role_name = role_names.get(min_role, 'wyższych uprawnień')
flash(f'Ta strona wymaga uprawnień {role_name}.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
return decorator
def member_required(f):
"""
Decorator that requires user to be at least a MEMBER.
Shortcut for @role_required(SystemRole.MEMBER).
Usage:
@bp.route('/forum')
@login_required
@member_required
def forum_index():
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
SystemRole = _get_system_role()
if not current_user.has_role(SystemRole.MEMBER):
flash('Ta strona jest dostępna tylko dla członków Izby NORDA.', 'warning')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
def company_permission(permission_type='edit'):
"""
Decorator that checks user's permission for a company.
Args:
permission_type: 'view', 'edit', or 'manage'
The company_id is extracted from:
1. URL parameter 'company_id'
2. Query parameter 'company_id'
3. User's own company_id
Usage:
@bp.route('/company/<int:company_id>/edit')
@login_required
@company_permission('edit')
def edit_company(company_id):
...
@bp.route('/company/<int:company_id>/users')
@login_required
@company_permission('manage')
def manage_company_users(company_id):
...
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
# Get company_id from various sources
company_id = (
kwargs.get('company_id') or
request.args.get('company_id', type=int) or
current_user.company_id
)
if company_id is None:
flash('Nie określono firmy.', 'error')
return redirect(url_for('public.index'))
# Check permission based on type
has_permission = False
if permission_type == 'view':
# Anyone can view public companies, but for dashboard check company membership
has_permission = (
current_user.can_access_admin_panel() or
current_user.company_id == company_id
)
elif permission_type == 'edit':
has_permission = current_user.can_edit_company(company_id)
elif permission_type == 'manage':
has_permission = current_user.can_manage_company(company_id)
else:
abort(500, f"Unknown permission type: {permission_type}")
if not has_permission:
flash('Nie masz uprawnień do tej operacji.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
return decorator
def office_manager_required(f):
"""
Decorator that requires user to be at least OFFICE_MANAGER.
Shortcut for @role_required(SystemRole.OFFICE_MANAGER).
Usage:
@bp.route('/admin/companies')
@login_required
@office_manager_required
def admin_companies():
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
SystemRole = _get_system_role()
if not current_user.has_role(SystemRole.OFFICE_MANAGER):
flash('Ta strona wymaga uprawnień kierownika biura.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
def forum_access_required(f):
"""
Decorator that requires user to have forum access.
Members and above can access the forum.
Usage:
@bp.route('/forum/topic/<int:topic_id>')
@login_required
@forum_access_required
def view_topic(topic_id):
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.can_access_forum():
flash('Forum jest dostępne tylko dla członków Izby NORDA.', 'warning')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
def moderator_required(f):
"""
Decorator that requires forum moderator permissions.
OFFICE_MANAGER and ADMIN roles have this permission.
Usage:
@bp.route('/forum/topic/<int:topic_id>/delete')
@login_required
@moderator_required
def delete_topic(topic_id):
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.can_moderate_forum():
flash('Ta akcja wymaga uprawnień moderatora.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
# ============================================================
# LEGACY DECORATORS (backward compatibility)
# ============================================================
def admin_required(f):
"""
Decorator that requires user to be logged in AND be an admin.
DEPRECATED: Use @role_required(SystemRole.ADMIN) instead.
Usage:
@bp.route('/admin/users')
@login_required
@admin_required
def admin_users():
...
Note: Always use @login_required BEFORE @admin_required
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
# Use new role system, fallback to is_admin for backward compatibility
SystemRole = _get_system_role()
if not (current_user.has_role(SystemRole.ADMIN) or current_user.is_admin):
flash('Brak uprawnień administratora.', 'error')
return redirect(url_for('public.index'))
return f(*args, **kwargs)
return decorated_function
def verified_required(f):
"""
Decorator that requires user to have verified email.
Usage:
@bp.route('/forum/new')
@login_required
@verified_required
def new_topic():
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.is_verified:
flash('Musisz zweryfikować swój email, aby wykonać tę akcję.', 'warning')
return redirect(url_for('auth.resend_verification'))
return f(*args, **kwargs)
return decorated_function
def company_owner_or_admin(f):
"""
Decorator for routes that accept company_id.
Allows access only if user is admin OR owns the company.
DEPRECATED: Use @company_permission('edit') instead.
Usage:
@bp.route('/company/<int:company_id>/edit')
@login_required
@company_owner_or_admin
def edit_company(company_id):
...
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
company_id = kwargs.get('company_id')
if company_id is None:
abort(400)
# Use new permission system
if current_user.can_edit_company(company_id):
return f(*args, **kwargs)
flash('Nie masz uprawnień do tej firmy.', 'error')
return redirect(url_for('public.index'))
return decorated_function