diff --git a/database.py b/database.py index 61ec9f6..049eba5 100644 --- a/database.py +++ b/database.py @@ -27,12 +27,102 @@ Updated: 2026-01-11 (AI Usage Tracking) import os import json from datetime import datetime -from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, Numeric, Date, Time, TypeDecorator, UniqueConstraint +from enum import IntEnum +from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, Numeric, Date, Time, TypeDecorator, UniqueConstraint, Enum from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY, JSONB as PG_JSONB from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, relationship from flask_login import UserMixin + +# ============================================================ +# ROLE SYSTEM ENUMS +# ============================================================ + +class SystemRole(IntEnum): + """ + System-wide user roles with hierarchical access levels. + Higher value = more permissions. + + Role hierarchy: + - UNAFFILIATED (10): Firma spoza Izby - tylko publiczne profile (bez kontaktów) + - MEMBER (20): Członek Norda bez firmy - pełny dostęp do treści + - EMPLOYEE (30): Pracownik firmy członkowskiej - może edytować dane firmy + - MANAGER (40): Kadra zarządzająca - pełna kontrola firmy + zarządzanie użytkownikami + - OFFICE_MANAGER (50): Kierownik biura Norda - panel admina (bez użytkowników) + - ADMIN (100): Administrator portalu - pełne prawa + """ + UNAFFILIATED = 10 # Niezrzeszony (firma spoza Izby) + MEMBER = 20 # Członek Norda bez firmy + EMPLOYEE = 30 # Pracownik firmy członkowskiej + MANAGER = 40 # Kadra zarządzająca firmy + OFFICE_MANAGER = 50 # Kierownik biura Norda + ADMIN = 100 # Administrator portalu + + @classmethod + def choices(cls): + """Return list of (value, label) tuples for forms.""" + labels = { + cls.UNAFFILIATED: 'Niezrzeszony', + cls.MEMBER: 'Członek', + cls.EMPLOYEE: 'Pracownik', + cls.MANAGER: 'Kadra Zarządzająca', + cls.OFFICE_MANAGER: 'Kierownik Biura', + cls.ADMIN: 'Administrator', + } + return [(role.value, labels[role]) for role in cls] + + @classmethod + def from_string(cls, value: str) -> 'SystemRole': + """Convert string to SystemRole enum.""" + mapping = { + 'UNAFFILIATED': cls.UNAFFILIATED, + 'MEMBER': cls.MEMBER, + 'EMPLOYEE': cls.EMPLOYEE, + 'MANAGER': cls.MANAGER, + 'OFFICE_MANAGER': cls.OFFICE_MANAGER, + 'ADMIN': cls.ADMIN, + } + return mapping.get(value.upper(), cls.UNAFFILIATED) + + +class CompanyRole(IntEnum): + """ + User's role within their assigned company. + Determines what actions they can perform on company data. + + - NONE (0): Brak powiązania z firmą + - VIEWER (10): Może przeglądać dashboard firmy + - EMPLOYEE (20): Może edytować dane firmy (opis, usługi, kompetencje) + - MANAGER (30): Pełna kontrola + zarządzanie użytkownikami firmy + """ + NONE = 0 # Brak powiązania z firmą + VIEWER = 10 # Może przeglądać dashboard firmy + EMPLOYEE = 20 # Może edytować dane firmy + MANAGER = 30 # Pełna kontrola + zarządzanie użytkownikami firmy + + @classmethod + def choices(cls): + """Return list of (value, label) tuples for forms.""" + labels = { + cls.NONE: 'Brak', + cls.VIEWER: 'Podgląd', + cls.EMPLOYEE: 'Pracownik', + cls.MANAGER: 'Zarządzający', + } + return [(role.value, labels[role]) for role in cls] + + @classmethod + def from_string(cls, value: str) -> 'CompanyRole': + """Convert string to CompanyRole enum.""" + mapping = { + 'NONE': cls.NONE, + 'VIEWER': cls.VIEWER, + 'EMPLOYEE': cls.EMPLOYEE, + 'MANAGER': cls.MANAGER, + } + return mapping.get(value.upper(), cls.NONE) + # Database configuration # WARNING: The fallback DATABASE_URL uses a placeholder password. # Production credentials MUST be set via the DATABASE_URL environment variable. @@ -173,7 +263,7 @@ Base = declarative_base() # ============================================================ class User(Base, UserMixin): - """User accounts""" + """User accounts with role-based access control.""" __tablename__ = 'users' id = Column(Integer, primary_key=True) @@ -187,10 +277,16 @@ class User(Base, UserMixin): person = relationship('Person', backref='users', lazy='joined') # Link to Person (KRS data) phone = Column(String(50)) - # Status + # === ROLE SYSTEM (added 2026-02-01) === + # System-wide role determining overall access level + role = Column(String(20), default='UNAFFILIATED', nullable=False) + # Role within assigned company (if any) + company_role = Column(String(20), default='NONE', nullable=False) + + # Status (is_admin kept for backward compatibility, synced with role) is_active = Column(Boolean, default=True) is_verified = Column(Boolean, default=False) - is_admin = Column(Boolean, default=False) + is_admin = Column(Boolean, default=False) # Deprecated: use role == ADMIN instead is_norda_member = Column(Boolean, default=False) # Timestamps @@ -231,8 +327,255 @@ class User(Base, UserMixin): forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumReply.author_id') forum_subscriptions = relationship('ForumTopicSubscription', back_populates='user', cascade='all, delete-orphan') + # === ROLE SYSTEM HELPER METHODS === + + @property + def system_role(self) -> SystemRole: + """Get the user's SystemRole enum value.""" + return SystemRole.from_string(self.role or 'UNAFFILIATED') + + @property + def company_role_enum(self) -> CompanyRole: + """Get the user's CompanyRole enum value.""" + return CompanyRole.from_string(self.company_role or 'NONE') + + def has_role(self, required_role: SystemRole) -> bool: + """ + Check if user has at least the required role level. + + Args: + required_role: Minimum required SystemRole + + Returns: + True if user's role >= required_role + + Example: + if user.has_role(SystemRole.OFFICE_MANAGER): + # User is Office Manager or Admin + """ + return self.system_role >= required_role + + def can_view_contacts(self) -> bool: + """ + Check if user can view contact information (email, phone) of other members. + Requires at least MEMBER role. + """ + return self.has_role(SystemRole.MEMBER) + + def can_access_forum(self) -> bool: + """ + Check if user can read and write on the forum. + Requires at least MEMBER role. + """ + return self.has_role(SystemRole.MEMBER) + + def can_access_chat(self) -> bool: + """ + Check if user can use NordaGPT chat with full features. + UNAFFILIATED users get limited access. + """ + return self.has_role(SystemRole.MEMBER) + + def can_edit_company(self, company_id: int = None) -> bool: + """ + Check if user can edit a company's profile. + + Args: + company_id: Company to check. If None, checks user's own company. + + Returns: + True if user can edit the company. + """ + # Admins and Office Managers can edit any company + if self.has_role(SystemRole.OFFICE_MANAGER): + return True + + # Check user's own company + target_company = company_id or self.company_id + if not target_company or self.company_id != target_company: + return False + + # EMPLOYEE or MANAGER of the company can edit + return self.company_role_enum >= CompanyRole.EMPLOYEE + + def can_manage_company(self, company_id: int = None) -> bool: + """ + Check if user can manage a company (including user management). + + Args: + company_id: Company to check. If None, checks user's own company. + + Returns: + True if user has full management rights. + """ + # Admins can manage any company + if self.has_role(SystemRole.ADMIN): + return True + + # Check user's own company + target_company = company_id or self.company_id + if not target_company or self.company_id != target_company: + return False + + # Only MANAGER of the company can manage users + return self.company_role_enum >= CompanyRole.MANAGER + + def can_manage_users(self) -> bool: + """ + Check if user can manage all portal users. + Only ADMIN role has this permission. + """ + return self.has_role(SystemRole.ADMIN) + + def can_access_admin_panel(self) -> bool: + """ + Check if user can access the admin panel. + OFFICE_MANAGER and ADMIN roles have access. + """ + return self.has_role(SystemRole.OFFICE_MANAGER) + + def can_moderate_forum(self) -> bool: + """ + Check if user can moderate forum content. + OFFICE_MANAGER and ADMIN roles have this permission. + """ + return self.has_role(SystemRole.OFFICE_MANAGER) + + def has_delegated_permission(self, permission: str, session=None) -> bool: + """ + Check if user has a specific delegated permission for their company. + + This checks UserCompanyPermissions table for fine-grained permissions + granted by a MANAGER. + + Args: + permission: One of: 'edit_description', 'edit_services', 'edit_contacts', + 'edit_social', 'manage_classifieds', 'post_forum', 'view_analytics' + session: SQLAlchemy session (optional, uses relationship if available) + + Returns: + True if user has this specific permission + """ + # Managers have all permissions by default + if self.company_role_enum >= CompanyRole.MANAGER: + return True + + # Check delegated permissions + if self.company_permissions: + for perm in self.company_permissions: + if perm.company_id == self.company_id: + attr_name = f'can_{permission}' + return getattr(perm, attr_name, False) + + return False + + def can_edit_company_field(self, field_category: str) -> bool: + """ + Check if user can edit a specific category of company fields. + + Args: + field_category: 'description', 'services', 'contacts', or 'social' + + Returns: + True if user can edit fields in this category + """ + # Admins and Office Managers can edit everything + if self.has_role(SystemRole.OFFICE_MANAGER): + return True + + # Must have company + if not self.company_id: + return False + + # Managers can edit everything in their company + if self.company_role_enum >= CompanyRole.MANAGER: + return True + + # Employees need delegated permission + if self.company_role_enum >= CompanyRole.EMPLOYEE: + return self.has_delegated_permission(f'edit_{field_category}') + + return False + + def set_role(self, new_role: SystemRole, sync_is_admin: bool = True): + """ + Set the user's system role. + + Args: + new_role: The new SystemRole to assign + sync_is_admin: If True, also update is_admin field for backward compatibility + """ + self.role = new_role.name + if sync_is_admin: + self.is_admin = (new_role == SystemRole.ADMIN) + + def set_company_role(self, new_role: CompanyRole): + """Set the user's company role.""" + self.company_role = new_role.name + def __repr__(self): - return f'' + return f'' + + +class UserCompanyPermissions(Base): + """ + Delegated permissions for company employees. + + Allows MANAGER to grant specific permissions to EMPLOYEE users, + enabling fine-grained control over what each employee can do. + + Example: + - Jan Kowalski (EMPLOYEE) gets permission to edit social media + - Anna Nowak (EMPLOYEE) gets permission to manage B2B classifieds + """ + __tablename__ = 'user_company_permissions' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False) + + # Content editing permissions + can_edit_description = Column(Boolean, default=True) # Opis firmy, historia, wartości + can_edit_services = Column(Boolean, default=True) # Usługi, kompetencje, technologie + can_edit_contacts = Column(Boolean, default=False) # Email, telefon, adres + can_edit_social = Column(Boolean, default=False) # Social media, strona www + + # Feature permissions + can_manage_classifieds = Column(Boolean, default=True) # B2B ogłoszenia w imieniu firmy + can_post_forum = Column(Boolean, default=True) # Posty na forum w imieniu firmy + can_view_analytics = Column(Boolean, default=False) # Statystyki firmy, wyświetlenia + + # Granted by (for audit trail) + granted_by_id = Column(Integer, ForeignKey('users.id'), nullable=True) + granted_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # Relationships + user = relationship('User', foreign_keys=[user_id], backref='company_permissions') + company = relationship('Company', backref='user_permissions') + granted_by = relationship('User', foreign_keys=[granted_by_id]) + + __table_args__ = ( + UniqueConstraint('user_id', 'company_id', name='uq_user_company_permissions'), + ) + + def __repr__(self): + return f'' + + @classmethod + def get_or_create(cls, session, user_id: int, company_id: int) -> 'UserCompanyPermissions': + """Get existing permissions or create default ones.""" + perms = session.query(cls).filter_by( + user_id=user_id, + company_id=company_id + ).first() + + if not perms: + perms = cls(user_id=user_id, company_id=company_id) + session.add(perms) + session.flush() + + return perms # ============================================================ diff --git a/database/migrations/035_add_role_system.sql b/database/migrations/035_add_role_system.sql new file mode 100644 index 0000000..511a7fd --- /dev/null +++ b/database/migrations/035_add_role_system.sql @@ -0,0 +1,183 @@ +-- Migration: 035_add_role_system.sql +-- Description: Add hierarchical role system for granular access control +-- Author: Claude Code +-- Date: 2026-02-01 +-- +-- Role hierarchy: +-- UNAFFILIATED (10) - Firma spoza Izby - tylko publiczne profile +-- MEMBER (20) - Członek Norda bez firmy - pełny dostęp do treści +-- EMPLOYEE (30) - Pracownik firmy członkowskiej - edycja danych firmy +-- MANAGER (40) - Kadra zarządzająca - pełna kontrola firmy + użytkownicy +-- OFFICE_MANAGER (50) - Kierownik biura Norda - panel admina +-- ADMIN (100) - Administrator portalu - pełne prawa +-- +-- Company roles: +-- NONE (0) - Brak powiązania z firmą +-- VIEWER (10) - Może przeglądać dashboard firmy +-- EMPLOYEE (20) - Może edytować dane firmy +-- MANAGER (30) - Pełna kontrola + zarządzanie użytkownikami + +-- ============================================================ +-- STEP 1: Add new columns +-- ============================================================ + +-- Add role column with default UNAFFILIATED +ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'UNAFFILIATED' NOT NULL; + +-- Add company_role column with default NONE +ALTER TABLE users ADD COLUMN IF NOT EXISTS company_role VARCHAR(20) DEFAULT 'NONE' NOT NULL; + +-- Add index for role lookups +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); + +-- ============================================================ +-- STEP 2: Migrate existing admins +-- ============================================================ + +UPDATE users +SET role = 'ADMIN' +WHERE is_admin = TRUE AND (role IS NULL OR role = 'UNAFFILIATED'); + +-- ============================================================ +-- STEP 3: Auto-detect MANAGER role from KRS data +-- Users linked to company management (zarząd) get MANAGER role +-- ============================================================ + +UPDATE users u +SET + role = 'MANAGER', + company_role = 'MANAGER' +WHERE + u.company_id IS NOT NULL + AND u.role NOT IN ('ADMIN', 'OFFICE_MANAGER') + AND EXISTS ( + SELECT 1 FROM company_people cp + WHERE cp.person_id = u.person_id + AND cp.company_id = u.company_id + AND cp.role_category = 'zarzad' + ); + +-- ============================================================ +-- STEP 4: Set EMPLOYEE role for users with company but no KRS link +-- ============================================================ + +UPDATE users +SET + role = 'EMPLOYEE', + company_role = 'EMPLOYEE' +WHERE + company_id IS NOT NULL + AND role NOT IN ('ADMIN', 'OFFICE_MANAGER', 'MANAGER'); + +-- ============================================================ +-- STEP 5: Set MEMBER role for Norda members without company +-- ============================================================ + +UPDATE users +SET role = 'MEMBER' +WHERE + is_norda_member = TRUE + AND company_id IS NULL + AND role = 'UNAFFILIATED'; + +-- ============================================================ +-- STEP 6: Create user_company_permissions table for delegation +-- ============================================================ + +CREATE TABLE IF NOT EXISTS user_company_permissions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + + -- Content editing permissions + can_edit_description BOOLEAN DEFAULT TRUE, -- Opis firmy, historia, wartości + can_edit_services BOOLEAN DEFAULT TRUE, -- Usługi, kompetencje, technologie + can_edit_contacts BOOLEAN DEFAULT FALSE, -- Email, telefon, adres + can_edit_social BOOLEAN DEFAULT FALSE, -- Social media, strona www + + -- Feature permissions + can_manage_classifieds BOOLEAN DEFAULT TRUE, -- B2B ogłoszenia w imieniu firmy + can_post_forum BOOLEAN DEFAULT TRUE, -- Posty na forum w imieniu firmy + can_view_analytics BOOLEAN DEFAULT FALSE, -- Statystyki firmy, wyświetlenia + + -- Audit trail + granted_by_id INTEGER REFERENCES users(id), + granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Unique constraint: one permission record per user-company pair + CONSTRAINT uq_user_company_permissions UNIQUE (user_id, company_id) +); + +-- Indexes for lookups +CREATE INDEX IF NOT EXISTS idx_user_company_permissions_user ON user_company_permissions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_company_permissions_company ON user_company_permissions(company_id); + +-- Grant permissions to application user +GRANT ALL ON TABLE user_company_permissions TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE user_company_permissions_id_seq TO nordabiz_app; + +-- ============================================================ +-- STEP 7: Create default permissions for existing EMPLOYEE users +-- ============================================================ + +INSERT INTO user_company_permissions (user_id, company_id, can_edit_description, can_edit_services) +SELECT id, company_id, TRUE, TRUE +FROM users +WHERE role = 'EMPLOYEE' AND company_id IS NOT NULL +ON CONFLICT (user_id, company_id) DO NOTHING; + +-- ============================================================ +-- STEP 8: Verify migration results +-- ============================================================ + +-- Log migration statistics (view in PostgreSQL logs) +DO $$ +DECLARE + admin_count INTEGER; + office_mgr_count INTEGER; + manager_count INTEGER; + employee_count INTEGER; + member_count INTEGER; + unaffiliated_count INTEGER; + permissions_count INTEGER; +BEGIN + SELECT COUNT(*) INTO admin_count FROM users WHERE role = 'ADMIN'; + SELECT COUNT(*) INTO office_mgr_count FROM users WHERE role = 'OFFICE_MANAGER'; + SELECT COUNT(*) INTO manager_count FROM users WHERE role = 'MANAGER'; + SELECT COUNT(*) INTO employee_count FROM users WHERE role = 'EMPLOYEE'; + SELECT COUNT(*) INTO member_count FROM users WHERE role = 'MEMBER'; + SELECT COUNT(*) INTO unaffiliated_count FROM users WHERE role = 'UNAFFILIATED'; + SELECT COUNT(*) INTO permissions_count FROM user_company_permissions; + + RAISE NOTICE 'Role migration complete:'; + RAISE NOTICE ' ADMIN: %', admin_count; + RAISE NOTICE ' OFFICE_MANAGER: %', office_mgr_count; + RAISE NOTICE ' MANAGER: %', manager_count; + RAISE NOTICE ' EMPLOYEE: %', employee_count; + RAISE NOTICE ' MEMBER: %', member_count; + RAISE NOTICE ' UNAFFILIATED: %', unaffiliated_count; + RAISE NOTICE ' Delegated permissions records: %', permissions_count; +END $$; + +-- ============================================================ +-- VERIFICATION QUERIES (run manually to verify) +-- ============================================================ + +-- Check for any users with NULL role (should be 0) +-- SELECT COUNT(*) FROM users WHERE role IS NULL; + +-- View role distribution +-- SELECT role, COUNT(*) as count FROM users GROUP BY role ORDER BY count DESC; + +-- View users with company but no company_role +-- SELECT id, email, company_id, role, company_role +-- FROM users +-- WHERE company_id IS NOT NULL AND company_role = 'NONE'; + +-- Check KRS-linked managers +-- SELECT u.id, u.email, u.role, u.company_role, c.name as company_name +-- FROM users u +-- JOIN companies c ON u.company_id = c.id +-- WHERE u.role = 'MANAGER' +-- LIMIT 10; diff --git a/utils/decorators.py b/utils/decorators.py index 1a2f656..8c1d93e 100644 --- a/utils/decorators.py +++ b/utils/decorators.py @@ -3,17 +3,223 @@ 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 +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//edit') + @login_required + @company_permission('edit') + def edit_company(company_id): + ... + + @bp.route('/company//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 forum_access_required(f): + """ + Decorator that requires user to have forum access. + Members and above can access the forum. + + Usage: + @bp.route('/forum/topic/') + @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//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 @@ -27,7 +233,10 @@ def admin_required(f): def decorated_function(*args, **kwargs): if not current_user.is_authenticated: return redirect(url_for('auth.login')) - if not current_user.is_admin: + + # 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) @@ -61,6 +270,8 @@ 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//edit') @login_required @@ -77,10 +288,8 @@ def company_owner_or_admin(f): if company_id is None: abort(400) - if current_user.is_admin: - return f(*args, **kwargs) - - if current_user.company_id == company_id: + # Use new permission system + if current_user.can_edit_company(company_id): return f(*args, **kwargs) flash('Nie masz uprawnień do tej firmy.', 'error') diff --git a/utils/permissions.py b/utils/permissions.py new file mode 100644 index 0000000..e0658bf --- /dev/null +++ b/utils/permissions.py @@ -0,0 +1,354 @@ +""" +Permission Helpers +================== + +Helper functions for complex permission checks beyond simple role checks. + +This module provides: +- Content access checks (company profiles, contact info) +- Feature access checks (forum, chat, classifieds) +- Data visibility filters (what user can see) + +Usage: + from utils.permissions import can_view_contact_info, filter_visible_companies + + if can_view_contact_info(current_user, company): + show_contact_details() + + visible_companies = filter_visible_companies(current_user, all_companies) +""" + +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from database import User, Company, SystemRole + + +def can_view_contact_info(user: 'User', target_company: 'Company' = None) -> bool: + """ + Check if user can view contact information (email, phone, address). + + Non-members (UNAFFILIATED) cannot see contact details. + Members and above can see all contact info (respecting privacy settings). + + Args: + user: The user requesting access + target_company: The company whose info is being accessed (optional) + + Returns: + True if user can view contact information + """ + if not user or not user.is_authenticated: + return False + + return user.can_view_contacts() + + +def can_view_company_dashboard(user: 'User', company_id: int) -> bool: + """ + Check if user can view a company's internal dashboard. + + Args: + user: The user requesting access + company_id: ID of the company dashboard + + Returns: + True if user can view the dashboard + """ + if not user or not user.is_authenticated: + return False + + # Admins and Office Managers can view any dashboard + if user.can_access_admin_panel(): + return True + + # User can view their own company's dashboard + return user.company_id == company_id + + +def can_invite_user_to_company(user: 'User', company_id: int) -> bool: + """ + Check if user can invite new users to a company. + + Only MANAGER role (within company) and ADMIN can invite users. + + Args: + user: The user attempting to invite + company_id: ID of the company to invite to + + Returns: + True if user can invite users to this company + """ + if not user or not user.is_authenticated: + return False + + return user.can_manage_company(company_id) + + +def get_editable_company_fields(user: 'User', company_id: int) -> List[str]: + """ + Get list of company profile fields that user can edit. + + Access is determined by: + 1. System role (OFFICE_MANAGER/ADMIN can edit everything) + 2. Company role (MANAGER has full access to their company) + 3. Delegated permissions (EMPLOYEE gets specific permissions from MANAGER) + + Args: + user: The user requesting edit access + company_id: ID of the company to edit + + Returns: + List of field names user can edit + """ + from database import SystemRole, CompanyRole + + if not user or not user.is_authenticated: + return [] + + # Not allowed to edit this company at all + if not user.can_edit_company(company_id): + return [] + + # Field categories + description_fields = [ + 'description_short', + 'description_full', + 'core_values', + 'founding_history', + ] + + services_fields = [ + 'services_offered', + 'technologies_used', + 'operational_area', + 'languages_offered', + ] + + contact_fields = [ + 'email', + 'phone', + 'address_street', + 'address_city', + 'address_postal', + ] + + social_fields = [ + 'website', + # Social media handled via related tables + ] + + admin_only_fields = [ + 'name', + 'legal_name', + 'nip', + 'regon', + 'krs', + 'year_established', + 'employees_count', + 'capital_amount', + 'status', + 'category_id', + ] + + # Admins and Office Managers can edit everything + if user.has_role(SystemRole.OFFICE_MANAGER): + return description_fields + services_fields + contact_fields + social_fields + admin_only_fields + + # Check user's own company + if user.company_id != company_id: + return [] + + # Managers can edit everything except admin-only fields + if user.company_role_enum >= CompanyRole.MANAGER: + return description_fields + services_fields + contact_fields + social_fields + + # Employees get permissions based on delegation + if user.company_role_enum >= CompanyRole.EMPLOYEE: + fields = [] + + if user.can_edit_company_field('description'): + fields.extend(description_fields) + + if user.can_edit_company_field('services'): + fields.extend(services_fields) + + if user.can_edit_company_field('contacts'): + fields.extend(contact_fields) + + if user.can_edit_company_field('social'): + fields.extend(social_fields) + + return fields + + return [] + + +def filter_visible_companies(user: 'User', companies: List['Company']) -> List['Company']: + """ + Filter companies list based on user's access level. + + All users can see basic company info (name, category, description). + Only members can see contact details. + + This function doesn't filter the list - all companies are visible. + Use can_view_contact_info() to determine what details to show. + + Args: + user: The user viewing companies + companies: List of companies to filter + + Returns: + Same list (filtering happens at template level) + """ + # All companies are visible to everyone + # Contact info visibility is handled in templates using can_view_contact_info + return companies + + +def get_chat_access_level(user: 'User') -> str: + """ + Get user's NordaGPT chat access level. + + Args: + user: The user accessing chat + + Returns: + 'full' - Full access to all features + 'limited' - Basic Q&A only, no company recommendations + 'none' - No access + """ + from database import SystemRole + + if not user or not user.is_authenticated: + return 'none' + + if user.has_role(SystemRole.MEMBER): + return 'full' + + # UNAFFILIATED users get limited access + return 'limited' + + +def can_access_b2b_classifieds(user: 'User') -> bool: + """ + Check if user can access B2B classifieds (tablica ogłoszeń). + + Requires at least MEMBER role. + + Args: + user: The user requesting access + + Returns: + True if user can access B2B classifieds + """ + from database import SystemRole + + if not user or not user.is_authenticated: + return False + + return user.has_role(SystemRole.MEMBER) + + +def can_create_classified(user: 'User') -> bool: + """ + Check if user can create B2B classified ads. + + Requires at least EMPLOYEE role (must be associated with a company). + + Args: + user: The user attempting to create + + Returns: + True if user can create classifieds + """ + from database import SystemRole + + if not user or not user.is_authenticated: + return False + + # Must have a company association and be at least EMPLOYEE + return user.company_id is not None and user.has_role(SystemRole.EMPLOYEE) + + +def can_access_calendar(user: 'User') -> bool: + """ + Check if user can access the events calendar. + + Requires at least MEMBER role. + + Args: + user: The user requesting access + + Returns: + True if user can access calendar + """ + from database import SystemRole + + if not user or not user.is_authenticated: + return False + + return user.has_role(SystemRole.MEMBER) + + +def can_create_event(user: 'User') -> bool: + """ + Check if user can create calendar events. + + Requires OFFICE_MANAGER or ADMIN role. + + Args: + user: The user attempting to create + + Returns: + True if user can create events + """ + from database import SystemRole + + if not user or not user.is_authenticated: + return False + + return user.has_role(SystemRole.OFFICE_MANAGER) + + +def get_user_permissions_summary(user: 'User') -> dict: + """ + Get a summary of all permissions for a user. + + Useful for debugging and displaying in user profile. + + Args: + user: The user to summarize + + Returns: + Dictionary with permission flags + """ + from database import SystemRole + + if not user or not user.is_authenticated: + return { + 'role': None, + 'company_role': None, + 'can_view_contacts': False, + 'can_access_forum': False, + 'can_access_chat': 'none', + 'can_access_admin_panel': False, + 'can_manage_users': False, + 'can_moderate_forum': False, + 'can_edit_own_company': False, + 'can_manage_own_company': False, + } + + return { + 'role': user.role, + 'role_label': dict(SystemRole.choices()).get(user.system_role.value, 'Nieznany'), + 'company_role': user.company_role, + 'can_view_contacts': user.can_view_contacts(), + 'can_access_forum': user.can_access_forum(), + 'can_access_chat': get_chat_access_level(user), + 'can_access_admin_panel': user.can_access_admin_panel(), + 'can_manage_users': user.can_manage_users(), + 'can_moderate_forum': user.can_moderate_forum(), + 'can_edit_own_company': user.can_edit_company() if user.company_id else False, + 'can_manage_own_company': user.can_manage_company() if user.company_id else False, + }