feat(multi-company): Allow users to be associated with multiple companies
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Adds user_companies table with BEFORE/AFTER triggers to sync primary company to users.company_id. Dashboard shows all user's companies with edit buttons. Company edit routes accept optional company_id parameter. Admin API endpoints for managing user-company associations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1dbe3d2dfa
commit
2e6eca55e7
@ -17,7 +17,7 @@ from . import bp
|
|||||||
from database import (
|
from database import (
|
||||||
SessionLocal, MembershipApplication, CompanyDataRequest,
|
SessionLocal, MembershipApplication, CompanyDataRequest,
|
||||||
Company, Category, User, UserNotification, Person, CompanyPerson, CompanyPKD,
|
Company, Category, User, UserNotification, Person, CompanyPerson, CompanyPKD,
|
||||||
SystemRole
|
SystemRole, UserCompany
|
||||||
)
|
)
|
||||||
from krs_api_service import get_company_from_krs
|
from krs_api_service import get_company_from_krs
|
||||||
from utils.decorators import role_required
|
from utils.decorators import role_required
|
||||||
@ -354,6 +354,16 @@ def admin_membership_approve(app_id):
|
|||||||
user.company_id = company.id
|
user.company_id = company.id
|
||||||
user.is_norda_member = True
|
user.is_norda_member = True
|
||||||
|
|
||||||
|
# Create user-company association (multi-company support)
|
||||||
|
is_first_company = not db.query(UserCompany).filter_by(user_id=user.id).first()
|
||||||
|
user_company = UserCompany(
|
||||||
|
user_id=user.id,
|
||||||
|
company_id=company.id,
|
||||||
|
role=user.company_role or 'MANAGER',
|
||||||
|
is_primary=is_first_company,
|
||||||
|
)
|
||||||
|
db.add(user_company)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@ -12,12 +12,13 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from database import SessionLocal, User, Company, SystemRole, CompanyRole, UserCompanyPermissions
|
from database import SessionLocal, User, Company, SystemRole, CompanyRole, UserCompanyPermissions, UserCompany
|
||||||
from utils.decorators import role_required
|
from utils.decorators import role_required
|
||||||
import gemini_service
|
import gemini_service
|
||||||
from . import bp
|
from . import bp
|
||||||
@ -349,6 +350,15 @@ def admin_users_change_role():
|
|||||||
user.company_role = 'NONE'
|
user.company_role = 'NONE'
|
||||||
# OFFICE_MANAGER and ADMIN keep their company_role unchanged
|
# OFFICE_MANAGER and ADMIN keep their company_role unchanged
|
||||||
|
|
||||||
|
# Sync role to user_companies table (primary company)
|
||||||
|
if user.company_id and new_role in ['MANAGER', 'EMPLOYEE']:
|
||||||
|
uc = db.query(UserCompany).filter_by(
|
||||||
|
user_id=user.id, company_id=user.company_id
|
||||||
|
).first()
|
||||||
|
if uc:
|
||||||
|
uc.role = new_role
|
||||||
|
uc.updated_at = datetime.now()
|
||||||
|
|
||||||
# Create default permissions for EMPLOYEE if they have a company
|
# Create default permissions for EMPLOYEE if they have a company
|
||||||
if new_role == 'EMPLOYEE' and user.company_id:
|
if new_role == 'EMPLOYEE' and user.company_id:
|
||||||
existing_perms = db.query(UserCompanyPermissions).filter_by(
|
existing_perms = db.query(UserCompanyPermissions).filter_by(
|
||||||
@ -399,3 +409,162 @@ def admin_users_get_roles():
|
|||||||
]
|
]
|
||||||
|
|
||||||
return jsonify({'success': True, 'roles': roles})
|
return jsonify({'success': True, 'roles': roles})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# USER-COMPANY ASSOCIATIONS (Multi-company support)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@bp.route('/users-api/<int:user_id>/companies', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.ADMIN)
|
||||||
|
def admin_user_companies_list(user_id):
|
||||||
|
"""List all companies associated with a user."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(User).get(user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
|
||||||
|
|
||||||
|
associations = db.query(UserCompany).filter_by(user_id=user_id).all()
|
||||||
|
result = []
|
||||||
|
for uc in associations:
|
||||||
|
company = db.query(Company).get(uc.company_id)
|
||||||
|
result.append({
|
||||||
|
'id': uc.id,
|
||||||
|
'company_id': uc.company_id,
|
||||||
|
'company_name': company.name if company else '(usunięta)',
|
||||||
|
'role': uc.role,
|
||||||
|
'is_primary': uc.is_primary,
|
||||||
|
'created_at': uc.created_at.isoformat() if uc.created_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'companies': result})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/users-api/<int:user_id>/companies', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.ADMIN)
|
||||||
|
def admin_user_company_add(user_id):
|
||||||
|
"""Add a company association to a user."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(User).get(user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
company_id = data.get('company_id')
|
||||||
|
role = data.get('role', 'MANAGER')
|
||||||
|
|
||||||
|
if not company_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Brak company_id'}), 400
|
||||||
|
|
||||||
|
company = db.query(Company).get(company_id)
|
||||||
|
if not company:
|
||||||
|
return jsonify({'success': False, 'error': 'Firma nie znaleziona'}), 404
|
||||||
|
|
||||||
|
# Check if already associated
|
||||||
|
existing = db.query(UserCompany).filter_by(
|
||||||
|
user_id=user_id, company_id=company_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'error': 'Użytkownik jest już powiązany z tą firmą'}), 409
|
||||||
|
|
||||||
|
# If user has no companies yet, make this the primary
|
||||||
|
has_companies = db.query(UserCompany).filter_by(user_id=user_id).first()
|
||||||
|
is_primary = not has_companies
|
||||||
|
|
||||||
|
uc = UserCompany(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
role=role,
|
||||||
|
is_primary=is_primary,
|
||||||
|
)
|
||||||
|
db.add(uc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Admin {current_user.email} added company {company_id} ({company.name}) to user {user.email} with role {role}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Firma {company.name} przypisana do użytkownika',
|
||||||
|
'association_id': uc.id,
|
||||||
|
'is_primary': is_primary,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error adding company to user: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/users-api/<int:user_id>/companies/<int:company_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.ADMIN)
|
||||||
|
def admin_user_company_remove(user_id, company_id):
|
||||||
|
"""Remove a company association from a user."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
uc = db.query(UserCompany).filter_by(
|
||||||
|
user_id=user_id, company_id=company_id
|
||||||
|
).first()
|
||||||
|
if not uc:
|
||||||
|
return jsonify({'success': False, 'error': 'Powiązanie nie znalezione'}), 404
|
||||||
|
|
||||||
|
company_name = ''
|
||||||
|
company = db.query(Company).get(company_id)
|
||||||
|
if company:
|
||||||
|
company_name = company.name
|
||||||
|
|
||||||
|
db.delete(uc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Admin {current_user.email} removed company {company_id} ({company_name}) from user {user_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Usunięto powiązanie z firmą {company_name}',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error removing company from user: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/users-api/<int:user_id>/companies/<int:company_id>/primary', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.ADMIN)
|
||||||
|
def admin_user_company_set_primary(user_id, company_id):
|
||||||
|
"""Set a company as the user's primary company."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
uc = db.query(UserCompany).filter_by(
|
||||||
|
user_id=user_id, company_id=company_id
|
||||||
|
).first()
|
||||||
|
if not uc:
|
||||||
|
return jsonify({'success': False, 'error': 'Powiązanie nie znalezione'}), 404
|
||||||
|
|
||||||
|
uc.is_primary = True
|
||||||
|
uc.updated_at = datetime.now()
|
||||||
|
# Trigger will handle clearing other primaries and syncing users.company_id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
company = db.query(Company).get(company_id)
|
||||||
|
logger.info(f"Admin {current_user.email} set company {company_id} as primary for user {user_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Firma {company.name if company else company_id} ustawiona jako główna',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error setting primary company: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@ -44,6 +44,7 @@ from database import (
|
|||||||
ForumTopic,
|
ForumTopic,
|
||||||
Classified,
|
Classified,
|
||||||
UserNotification,
|
UserNotification,
|
||||||
|
UserCompany,
|
||||||
)
|
)
|
||||||
from utils.helpers import sanitize_input
|
from utils.helpers import sanitize_input
|
||||||
from extensions import limiter
|
from extensions import limiter
|
||||||
@ -594,6 +595,17 @@ def dashboard():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # MembershipApplication table may not exist yet
|
pass # MembershipApplication table may not exist yet
|
||||||
|
|
||||||
|
# Load user's company associations (multi-company support)
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
user_companies = db.query(UserCompany).options(
|
||||||
|
joinedload(UserCompany.company)
|
||||||
|
).filter_by(
|
||||||
|
user_id=current_user.id
|
||||||
|
).order_by(UserCompany.is_primary.desc(), UserCompany.created_at.asc()).all()
|
||||||
|
# Force-load company names before session closes
|
||||||
|
for uc in user_companies:
|
||||||
|
_ = uc.company.name if uc.company else None
|
||||||
|
|
||||||
# Widget 1: Upcoming events (3 nearest future events)
|
# Widget 1: Upcoming events (3 nearest future events)
|
||||||
upcoming_events = db.query(NordaEvent).filter(
|
upcoming_events = db.query(NordaEvent).filter(
|
||||||
NordaEvent.event_date >= date.today()
|
NordaEvent.event_date >= date.today()
|
||||||
@ -641,6 +653,7 @@ def dashboard():
|
|||||||
has_pending_application=has_pending_application,
|
has_pending_application=has_pending_application,
|
||||||
has_draft_application=has_draft_application,
|
has_draft_application=has_draft_application,
|
||||||
pending_application=pending_application,
|
pending_application=pending_application,
|
||||||
|
user_companies=user_companies,
|
||||||
unread_notifications=unread_notifications,
|
unread_notifications=unread_notifications,
|
||||||
upcoming_events_count=upcoming_events_count,
|
upcoming_events_count=upcoming_events_count,
|
||||||
user_forum_topics_count=user_forum_topics_count,
|
user_forum_topics_count=user_forum_topics_count,
|
||||||
|
|||||||
@ -22,16 +22,19 @@ EDITABLE_SOURCES = [None, 'manual_edit', 'manual']
|
|||||||
|
|
||||||
|
|
||||||
@bp.route('/firma/edytuj')
|
@bp.route('/firma/edytuj')
|
||||||
|
@bp.route('/firma/edytuj/<int:company_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def company_edit():
|
def company_edit(company_id=None):
|
||||||
"""Display the company profile edit form."""
|
"""Display the company profile edit form."""
|
||||||
if not current_user.can_edit_company():
|
target_company_id = company_id or current_user.company_id
|
||||||
|
|
||||||
|
if not target_company_id or not current_user.can_edit_company(target_company_id):
|
||||||
flash('Nie masz uprawnień do edycji profilu firmy.', 'error')
|
flash('Nie masz uprawnień do edycji profilu firmy.', 'error')
|
||||||
return redirect(url_for('public.dashboard'))
|
return redirect(url_for('public.dashboard'))
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
company = db.query(Company).get(current_user.company_id)
|
company = db.query(Company).get(target_company_id)
|
||||||
if not company:
|
if not company:
|
||||||
flash('Nie znaleziono firmy.', 'error')
|
flash('Nie znaleziono firmy.', 'error')
|
||||||
return redirect(url_for('public.dashboard'))
|
return redirect(url_for('public.dashboard'))
|
||||||
@ -50,10 +53,10 @@ def company_edit():
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
permissions = {
|
permissions = {
|
||||||
'description': current_user.can_edit_company_field('description'),
|
'description': current_user.can_edit_company_field('description', company_id=company.id),
|
||||||
'services': current_user.can_edit_company_field('services'),
|
'services': current_user.can_edit_company_field('services', company_id=company.id),
|
||||||
'contacts': current_user.can_edit_company_field('contacts'),
|
'contacts': current_user.can_edit_company_field('contacts', company_id=company.id),
|
||||||
'social': current_user.can_edit_company_field('social'),
|
'social': current_user.can_edit_company_field('social', company_id=company.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
editable_contacts = [c for c in contacts if c.source in EDITABLE_SOURCES]
|
editable_contacts = [c for c in contacts if c.source in EDITABLE_SOURCES]
|
||||||
@ -72,32 +75,35 @@ def company_edit():
|
|||||||
|
|
||||||
|
|
||||||
@bp.route('/firma/edytuj', methods=['POST'])
|
@bp.route('/firma/edytuj', methods=['POST'])
|
||||||
|
@bp.route('/firma/edytuj/<int:company_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def company_edit_save():
|
def company_edit_save(company_id=None):
|
||||||
"""Save company profile edits."""
|
"""Save company profile edits."""
|
||||||
if not current_user.can_edit_company():
|
target_company_id = company_id or current_user.company_id
|
||||||
|
|
||||||
|
if not target_company_id or not current_user.can_edit_company(target_company_id):
|
||||||
flash('Nie masz uprawnień do edycji profilu firmy.', 'error')
|
flash('Nie masz uprawnień do edycji profilu firmy.', 'error')
|
||||||
return redirect(url_for('public.dashboard'))
|
return redirect(url_for('public.dashboard'))
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
company = db.query(Company).get(current_user.company_id)
|
company = db.query(Company).get(target_company_id)
|
||||||
if not company:
|
if not company:
|
||||||
flash('Nie znaleziono firmy.', 'error')
|
flash('Nie znaleziono firmy.', 'error')
|
||||||
return redirect(url_for('public.dashboard'))
|
return redirect(url_for('public.dashboard'))
|
||||||
|
|
||||||
active_tab = request.form.get('active_tab', 'description')
|
active_tab = request.form.get('active_tab', 'description')
|
||||||
|
|
||||||
if active_tab == 'description' and current_user.can_edit_company_field('description'):
|
if active_tab == 'description' and current_user.can_edit_company_field('description', company_id=company.id):
|
||||||
_save_description(db, company)
|
_save_description(db, company)
|
||||||
|
|
||||||
elif active_tab == 'services' and current_user.can_edit_company_field('services'):
|
elif active_tab == 'services' and current_user.can_edit_company_field('services', company_id=company.id):
|
||||||
_save_services(company)
|
_save_services(company)
|
||||||
|
|
||||||
elif active_tab == 'contacts' and current_user.can_edit_company_field('contacts'):
|
elif active_tab == 'contacts' and current_user.can_edit_company_field('contacts', company_id=company.id):
|
||||||
_save_contacts(db, company)
|
_save_contacts(db, company)
|
||||||
|
|
||||||
elif active_tab == 'social' and current_user.can_edit_company_field('social'):
|
elif active_tab == 'social' and current_user.can_edit_company_field('social', company_id=company.id):
|
||||||
_save_social_media(db, company)
|
_save_social_media(db, company)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@ -106,9 +112,9 @@ def company_edit_save():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.error(f"Error saving company edit for company_id={current_user.company_id}: {e}")
|
logger.error(f"Error saving company edit for company_id={target_company_id}: {e}")
|
||||||
flash('Wystąpił błąd podczas zapisywania zmian. Spróbuj ponownie.', 'error')
|
flash('Wystąpił błąd podczas zapisywania zmian. Spróbuj ponownie.', 'error')
|
||||||
return redirect(url_for('public.company_edit'))
|
return redirect(url_for('public.company_edit', company_id=company_id))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
123
database.py
123
database.py
@ -377,12 +377,52 @@ class User(Base, UserMixin):
|
|||||||
"""
|
"""
|
||||||
return self.has_role(SystemRole.MEMBER)
|
return self.has_role(SystemRole.MEMBER)
|
||||||
|
|
||||||
|
def get_companies(self, session=None):
|
||||||
|
"""
|
||||||
|
Get all companies associated with this user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of UserCompany objects (uses relationship if loaded, else queries).
|
||||||
|
"""
|
||||||
|
if self.company_associations:
|
||||||
|
return self.company_associations
|
||||||
|
if session:
|
||||||
|
return session.query(UserCompany).filter_by(user_id=self.id).all()
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_company_role(self, company_id: int, session=None) -> 'CompanyRole':
|
||||||
|
"""
|
||||||
|
Get user's role for a specific company from user_companies table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
company_id: The company to check.
|
||||||
|
session: SQLAlchemy session (optional, uses relationship if loaded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompanyRole enum value, or CompanyRole.NONE if not associated.
|
||||||
|
"""
|
||||||
|
# Check loaded relationships first
|
||||||
|
for assoc in (self.company_associations or []):
|
||||||
|
if assoc.company_id == company_id:
|
||||||
|
return assoc.role_enum
|
||||||
|
# Fallback to query
|
||||||
|
if session:
|
||||||
|
assoc = session.query(UserCompany).filter_by(
|
||||||
|
user_id=self.id, company_id=company_id
|
||||||
|
).first()
|
||||||
|
if assoc:
|
||||||
|
return assoc.role_enum
|
||||||
|
# Legacy fallback: if company_id matches primary, use company_role
|
||||||
|
if self.company_id == company_id:
|
||||||
|
return self.company_role_enum
|
||||||
|
return CompanyRole.NONE
|
||||||
|
|
||||||
def can_edit_company(self, company_id: int = None) -> bool:
|
def can_edit_company(self, company_id: int = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if user can edit a company's profile.
|
Check if user can edit a company's profile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
company_id: Company to check. If None, checks user's own company.
|
company_id: Company to check. If None, checks user's primary company.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if user can edit the company.
|
True if user can edit the company.
|
||||||
@ -391,20 +431,20 @@ class User(Base, UserMixin):
|
|||||||
if self.has_role(SystemRole.OFFICE_MANAGER):
|
if self.has_role(SystemRole.OFFICE_MANAGER):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check user's own company
|
|
||||||
target_company = company_id or self.company_id
|
target_company = company_id or self.company_id
|
||||||
if not target_company or self.company_id != target_company:
|
if not target_company:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# EMPLOYEE or MANAGER of the company can edit
|
# Check role via user_companies (supports multi-company)
|
||||||
return self.company_role_enum >= CompanyRole.EMPLOYEE
|
role = self.get_company_role(target_company)
|
||||||
|
return role >= CompanyRole.EMPLOYEE
|
||||||
|
|
||||||
def can_manage_company(self, company_id: int = None) -> bool:
|
def can_manage_company(self, company_id: int = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if user can manage a company (including user management).
|
Check if user can manage a company (including user management).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
company_id: Company to check. If None, checks user's own company.
|
company_id: Company to check. If None, checks user's primary company.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if user has full management rights.
|
True if user has full management rights.
|
||||||
@ -413,13 +453,13 @@ class User(Base, UserMixin):
|
|||||||
if self.has_role(SystemRole.ADMIN):
|
if self.has_role(SystemRole.ADMIN):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check user's own company
|
|
||||||
target_company = company_id or self.company_id
|
target_company = company_id or self.company_id
|
||||||
if not target_company or self.company_id != target_company:
|
if not target_company:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Only MANAGER of the company can manage users
|
# Check role via user_companies (supports multi-company)
|
||||||
return self.company_role_enum >= CompanyRole.MANAGER
|
role = self.get_company_role(target_company)
|
||||||
|
return role >= CompanyRole.MANAGER
|
||||||
|
|
||||||
def can_manage_users(self) -> bool:
|
def can_manage_users(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@ -442,9 +482,9 @@ class User(Base, UserMixin):
|
|||||||
"""
|
"""
|
||||||
return self.has_role(SystemRole.OFFICE_MANAGER)
|
return self.has_role(SystemRole.OFFICE_MANAGER)
|
||||||
|
|
||||||
def has_delegated_permission(self, permission: str, session=None) -> bool:
|
def has_delegated_permission(self, permission: str, company_id: int = None, session=None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if user has a specific delegated permission for their company.
|
Check if user has a specific delegated permission for a company.
|
||||||
|
|
||||||
This checks UserCompanyPermissions table for fine-grained permissions
|
This checks UserCompanyPermissions table for fine-grained permissions
|
||||||
granted by a MANAGER.
|
granted by a MANAGER.
|
||||||
@ -452,30 +492,35 @@ class User(Base, UserMixin):
|
|||||||
Args:
|
Args:
|
||||||
permission: One of: 'edit_description', 'edit_services', 'edit_contacts',
|
permission: One of: 'edit_description', 'edit_services', 'edit_contacts',
|
||||||
'edit_social', 'manage_classifieds', 'post_forum', 'view_analytics'
|
'edit_social', 'manage_classifieds', 'post_forum', 'view_analytics'
|
||||||
|
company_id: Company to check. If None, checks user's primary company.
|
||||||
session: SQLAlchemy session (optional, uses relationship if available)
|
session: SQLAlchemy session (optional, uses relationship if available)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if user has this specific permission
|
True if user has this specific permission
|
||||||
"""
|
"""
|
||||||
|
target_company = company_id or self.company_id
|
||||||
|
|
||||||
# Managers have all permissions by default
|
# Managers have all permissions by default
|
||||||
if self.company_role_enum >= CompanyRole.MANAGER:
|
role = self.get_company_role(target_company) if target_company else self.company_role_enum
|
||||||
|
if role >= CompanyRole.MANAGER:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check delegated permissions
|
# Check delegated permissions
|
||||||
if self.company_permissions:
|
if self.company_permissions:
|
||||||
for perm in self.company_permissions:
|
for perm in self.company_permissions:
|
||||||
if perm.company_id == self.company_id:
|
if perm.company_id == target_company:
|
||||||
attr_name = f'can_{permission}'
|
attr_name = f'can_{permission}'
|
||||||
return getattr(perm, attr_name, False)
|
return getattr(perm, attr_name, False)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def can_edit_company_field(self, field_category: str) -> bool:
|
def can_edit_company_field(self, field_category: str, company_id: int = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if user can edit a specific category of company fields.
|
Check if user can edit a specific category of company fields.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
field_category: 'description', 'services', 'contacts', or 'social'
|
field_category: 'description', 'services', 'contacts', or 'social'
|
||||||
|
company_id: Company to check. If None, checks user's primary company.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if user can edit fields in this category
|
True if user can edit fields in this category
|
||||||
@ -484,17 +529,20 @@ class User(Base, UserMixin):
|
|||||||
if self.has_role(SystemRole.OFFICE_MANAGER):
|
if self.has_role(SystemRole.OFFICE_MANAGER):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Must have company
|
target_company = company_id or self.company_id
|
||||||
if not self.company_id:
|
if not target_company:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Check role via user_companies (supports multi-company)
|
||||||
|
role = self.get_company_role(target_company)
|
||||||
|
|
||||||
# Managers can edit everything in their company
|
# Managers can edit everything in their company
|
||||||
if self.company_role_enum >= CompanyRole.MANAGER:
|
if role >= CompanyRole.MANAGER:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Employees need delegated permission
|
# Employees need delegated permission
|
||||||
if self.company_role_enum >= CompanyRole.EMPLOYEE:
|
if role >= CompanyRole.EMPLOYEE:
|
||||||
return self.has_delegated_permission(f'edit_{field_category}')
|
return self.has_delegated_permission(f'edit_{field_category}', company_id=target_company)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -579,6 +627,41 @@ class UserCompanyPermissions(Base):
|
|||||||
return perms
|
return perms
|
||||||
|
|
||||||
|
|
||||||
|
class UserCompany(Base):
|
||||||
|
"""
|
||||||
|
Association between users and companies (multi-company support).
|
||||||
|
|
||||||
|
Allows a user to be linked to multiple companies with different roles.
|
||||||
|
One association per user can be marked as is_primary, which syncs
|
||||||
|
to users.company_id via database trigger.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'user_companies'
|
||||||
|
|
||||||
|
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)
|
||||||
|
role = Column(String(20), nullable=False, default='MANAGER')
|
||||||
|
is_primary = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship('User', backref='company_associations')
|
||||||
|
company = relationship('Company', backref='user_associations')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('user_id', 'company_id', name='uq_user_company'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def role_enum(self) -> CompanyRole:
|
||||||
|
"""Get the CompanyRole enum value."""
|
||||||
|
return CompanyRole.from_string(self.role or 'NONE')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<UserCompany user={self.user_id} company={self.company_id} role={self.role} primary={self.is_primary}>'
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# COMPANY DIRECTORY (existing schema from SQL)
|
# COMPANY DIRECTORY (existing schema from SQL)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
108
database/migrations/050_user_companies.sql
Normal file
108
database/migrations/050_user_companies.sql
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
-- Migration 050: User-Company associations (multi-company support)
|
||||||
|
-- Allows one user to be associated with multiple companies
|
||||||
|
-- Maintains backward compatibility via trigger syncing users.company_id
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Create user_companies table
|
||||||
|
CREATE TABLE IF NOT EXISTS user_companies (
|
||||||
|
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,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'MANAGER',
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, company_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_companies_user_id ON user_companies(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_companies_company_id ON user_companies(company_id);
|
||||||
|
-- No partial unique index on is_primary - enforced by BEFORE trigger instead
|
||||||
|
-- (AFTER trigger can't clear old primary before unique constraint check)
|
||||||
|
|
||||||
|
-- 3. Migrate existing data from users table
|
||||||
|
INSERT INTO user_companies (user_id, company_id, role, is_primary, created_at)
|
||||||
|
SELECT id, company_id,
|
||||||
|
COALESCE(NULLIF(company_role, 'NONE'), 'MANAGER'),
|
||||||
|
TRUE,
|
||||||
|
COALESCE(created_at, NOW())
|
||||||
|
FROM users
|
||||||
|
WHERE company_id IS NOT NULL
|
||||||
|
ON CONFLICT (user_id, company_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 4. BEFORE trigger: enforce single primary + clear others
|
||||||
|
CREATE OR REPLACE FUNCTION sync_user_primary_company()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- On INSERT or UPDATE with is_primary = TRUE
|
||||||
|
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.is_primary = TRUE THEN
|
||||||
|
-- Clear other primary flags for this user BEFORE the row is written
|
||||||
|
UPDATE user_companies
|
||||||
|
SET is_primary = FALSE, updated_at = NOW()
|
||||||
|
WHERE user_id = NEW.user_id
|
||||||
|
AND id != NEW.id
|
||||||
|
AND is_primary = TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_sync_user_primary_company ON user_companies;
|
||||||
|
CREATE TRIGGER trg_sync_user_primary_company
|
||||||
|
BEFORE INSERT OR UPDATE ON user_companies
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION sync_user_primary_company();
|
||||||
|
|
||||||
|
-- 5. AFTER trigger: sync to users table
|
||||||
|
CREATE OR REPLACE FUNCTION sync_user_primary_to_users()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- On INSERT or UPDATE with is_primary = TRUE -> sync to users
|
||||||
|
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.is_primary = TRUE THEN
|
||||||
|
UPDATE users
|
||||||
|
SET company_id = NEW.company_id,
|
||||||
|
company_role = NEW.role
|
||||||
|
WHERE id = NEW.user_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- On DELETE of primary association
|
||||||
|
IF TG_OP = 'DELETE' AND OLD.is_primary = TRUE THEN
|
||||||
|
-- Try to promote another association to primary
|
||||||
|
UPDATE user_companies
|
||||||
|
SET is_primary = TRUE, updated_at = NOW()
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM user_companies
|
||||||
|
WHERE user_id = OLD.user_id AND id != OLD.id
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- If no other association exists, clear users table
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
UPDATE users
|
||||||
|
SET company_id = NULL, company_role = 'NONE'
|
||||||
|
WHERE id = OLD.user_id;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_sync_user_primary_to_users ON user_companies;
|
||||||
|
CREATE TRIGGER trg_sync_user_primary_to_users
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON user_companies
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION sync_user_primary_to_users();
|
||||||
|
|
||||||
|
-- 6. Permissions
|
||||||
|
GRANT ALL ON TABLE user_companies TO nordabiz_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE user_companies_id_seq TO nordabiz_app;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -615,7 +615,7 @@
|
|||||||
<!-- AI Enrichment Button + Edit Profile -->
|
<!-- AI Enrichment Button + Edit Profile -->
|
||||||
<div style="margin: var(--spacing-md) 0; display: flex; gap: var(--spacing-sm); flex-wrap: wrap; align-items: center;">
|
<div style="margin: var(--spacing-md) 0; display: flex; gap: var(--spacing-sm); flex-wrap: wrap; align-items: center;">
|
||||||
{% if can_enrich %}
|
{% if can_enrich %}
|
||||||
<a href="{{ url_for('company_edit') }}" class="ai-enrich-btn" style="text-decoration: none;">
|
<a href="{{ url_for('company_edit', company_id=company.id) }}" class="ai-enrich-btn" style="text-decoration: none;">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
|||||||
@ -345,7 +345,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('public.company_edit_save') }}" id="companyEditForm">
|
<form method="POST" action="{{ url_for('public.company_edit_save', company_id=company.id) }}" id="companyEditForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="active_tab" id="activeTabInput" value="description">
|
<input type="hidden" name="active_tab" id="activeTabInput" value="description">
|
||||||
|
|
||||||
|
|||||||
@ -520,13 +520,13 @@
|
|||||||
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h5>Twoja firma</h5>
|
<h5>{{ 'Twoje firmy' if user_companies|length > 1 else 'Twoja firma' }}</h5>
|
||||||
{% if current_user.company_id %}
|
{% if user_companies %}
|
||||||
<h3 style="color: var(--primary); font-size: 1rem;">{{ current_user.company.name if current_user.company else 'Przypisana' }}</h3>
|
<h3 style="color: var(--primary); font-size: 1rem;">{{ user_companies|length }}</h3>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h3 style="color: var(--text-secondary); font-size: 1rem;">Brak</h3>
|
<h3 style="color: var(--text-secondary); font-size: 1rem;">Brak</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small>Profil firmowy</small>
|
<small>{{ 'Profile firmowe' if user_companies|length > 1 else 'Profil firmowy' }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -570,8 +570,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# User's companies section #}
|
||||||
|
{% if user_companies %}
|
||||||
|
<div class="dashboard-card" style="margin-bottom: var(--spacing-lg, 20px);">
|
||||||
|
<div style="padding: var(--spacing-lg, 20px);">
|
||||||
|
<h4 style="margin: 0 0 var(--spacing-md, 16px) 0; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
||||||
|
</svg>
|
||||||
|
{{ 'Twoje firmy' if user_companies|length > 1 else 'Twoja firma' }}
|
||||||
|
</h4>
|
||||||
|
{% for uc in user_companies %}
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: var(--spacing-sm, 8px) 0; {% if not loop.last %}border-bottom: 1px solid var(--border-color, #e5e7eb);{% endif %}">
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--spacing-sm, 8px);">
|
||||||
|
<span style="font-weight: 500;">{{ uc.company.name if uc.company else '(firma usunięta)' }}</span>
|
||||||
|
{% if uc.is_primary and user_companies|length > 1 %}
|
||||||
|
<span style="background: var(--primary); color: white; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px;">Główna</span>
|
||||||
|
{% endif %}
|
||||||
|
<span style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||||
|
{% if uc.role == 'MANAGER' %}Zarządzający{% elif uc.role == 'EMPLOYEE' %}Pracownik{% elif uc.role == 'VIEWER' %}Podgląd{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: var(--spacing-xs, 4px);">
|
||||||
|
{% if uc.company %}
|
||||||
|
<a href="{{ url_for('public.company_detail', company_id=uc.company_id) }}" class="btn btn-sm btn-outline" style="font-size: 0.8rem; padding: 4px 10px;">Profil</a>
|
||||||
|
{% if current_user.can_edit_company(uc.company_id) %}
|
||||||
|
<a href="{{ url_for('public.company_edit', company_id=uc.company_id) }}" class="btn btn-sm btn-primary" style="font-size: 0.8rem; padding: 4px 10px;">Edytuj</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Membership CTA for users without company #}
|
{# Membership CTA for users without company #}
|
||||||
{% if not current_user.company_id %}
|
{% if not user_companies %}
|
||||||
{% if has_pending_application %}
|
{% if has_pending_application %}
|
||||||
<div class="membership-status">
|
<div class="membership-status">
|
||||||
<div class="membership-status-content">
|
<div class="membership-status-content">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user