feat: Add hierarchical role system with delegated permissions
Implements 6-tier role hierarchy: - ADMIN (100): Full system access - OFFICE_MANAGER (50): Admin panel without user management - MANAGER (40): Full company control + user management - EMPLOYEE (30): Edit company data (with delegated permissions) - MEMBER (20): Full content access (forum, contacts, chat) - UNAFFILIATED (10): Public profiles only Features: - SystemRole and CompanyRole enums in database.py - UserCompanyPermissions model for delegation - New decorators: @role_required(), @company_permission() - Auto-detection of MANAGER role from KRS data - Backward compatible with is_admin flag Migration: 035_add_role_system.sql Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1bf56148f5
commit
ae70ad326e
353
database.py
353
database.py
@ -27,12 +27,102 @@ Updated: 2026-01-11 (AI Usage Tracking)
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
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.dialects.postgresql import ARRAY as PG_ARRAY, JSONB as PG_JSONB
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker, relationship
|
from sqlalchemy.orm import sessionmaker, relationship
|
||||||
from flask_login import UserMixin
|
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
|
# Database configuration
|
||||||
# WARNING: The fallback DATABASE_URL uses a placeholder password.
|
# WARNING: The fallback DATABASE_URL uses a placeholder password.
|
||||||
# Production credentials MUST be set via the DATABASE_URL environment variable.
|
# Production credentials MUST be set via the DATABASE_URL environment variable.
|
||||||
@ -173,7 +263,7 @@ Base = declarative_base()
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
class User(Base, UserMixin):
|
class User(Base, UserMixin):
|
||||||
"""User accounts"""
|
"""User accounts with role-based access control."""
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
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)
|
person = relationship('Person', backref='users', lazy='joined') # Link to Person (KRS data)
|
||||||
phone = Column(String(50))
|
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_active = Column(Boolean, default=True)
|
||||||
is_verified = Column(Boolean, default=False)
|
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)
|
is_norda_member = Column(Boolean, default=False)
|
||||||
|
|
||||||
# Timestamps
|
# 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_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')
|
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):
|
def __repr__(self):
|
||||||
return f'<User {self.email}>'
|
return f'<User {self.email} role={self.role}>'
|
||||||
|
|
||||||
|
|
||||||
|
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'<UserCompanyPermissions user={self.user_id} company={self.company_id}>'
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
183
database/migrations/035_add_role_system.sql
Normal file
183
database/migrations/035_add_role_system.sql
Normal file
@ -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;
|
||||||
@ -3,17 +3,223 @@ Custom Decorators
|
|||||||
=================
|
=================
|
||||||
|
|
||||||
Reusable decorators for access control and validation.
|
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 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
|
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 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):
|
def admin_required(f):
|
||||||
"""
|
"""
|
||||||
Decorator that requires user to be logged in AND be an admin.
|
Decorator that requires user to be logged in AND be an admin.
|
||||||
|
|
||||||
|
DEPRECATED: Use @role_required(SystemRole.ADMIN) instead.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@bp.route('/admin/users')
|
@bp.route('/admin/users')
|
||||||
@login_required
|
@login_required
|
||||||
@ -27,7 +233,10 @@ def admin_required(f):
|
|||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
return redirect(url_for('auth.login'))
|
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')
|
flash('Brak uprawnień administratora.', 'error')
|
||||||
return redirect(url_for('public.index'))
|
return redirect(url_for('public.index'))
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
@ -61,6 +270,8 @@ def company_owner_or_admin(f):
|
|||||||
Decorator for routes that accept company_id.
|
Decorator for routes that accept company_id.
|
||||||
Allows access only if user is admin OR owns the company.
|
Allows access only if user is admin OR owns the company.
|
||||||
|
|
||||||
|
DEPRECATED: Use @company_permission('edit') instead.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@bp.route('/company/<int:company_id>/edit')
|
@bp.route('/company/<int:company_id>/edit')
|
||||||
@login_required
|
@login_required
|
||||||
@ -77,10 +288,8 @@ def company_owner_or_admin(f):
|
|||||||
if company_id is None:
|
if company_id is None:
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
if current_user.is_admin:
|
# Use new permission system
|
||||||
return f(*args, **kwargs)
|
if current_user.can_edit_company(company_id):
|
||||||
|
|
||||||
if current_user.company_id == company_id:
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
flash('Nie masz uprawnień do tej firmy.', 'error')
|
flash('Nie masz uprawnień do tej firmy.', 'error')
|
||||||
|
|||||||
354
utils/permissions.py
Normal file
354
utils/permissions.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user