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 (
|
||||
SessionLocal, MembershipApplication, CompanyDataRequest,
|
||||
Company, Category, User, UserNotification, Person, CompanyPerson, CompanyPKD,
|
||||
SystemRole
|
||||
SystemRole, UserCompany
|
||||
)
|
||||
from krs_api_service import get_company_from_krs
|
||||
from utils.decorators import role_required
|
||||
@ -354,6 +354,16 @@ def admin_membership_approve(app_id):
|
||||
user.company_id = company.id
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
|
||||
@ -12,12 +12,13 @@ import re
|
||||
import secrets
|
||||
import string
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
from flask import jsonify, request
|
||||
from flask_login import current_user, login_required
|
||||
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
|
||||
import gemini_service
|
||||
from . import bp
|
||||
@ -349,6 +350,15 @@ def admin_users_change_role():
|
||||
user.company_role = 'NONE'
|
||||
# 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
|
||||
if new_role == 'EMPLOYEE' and user.company_id:
|
||||
existing_perms = db.query(UserCompanyPermissions).filter_by(
|
||||
@ -399,3 +409,162 @@ def admin_users_get_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,
|
||||
Classified,
|
||||
UserNotification,
|
||||
UserCompany,
|
||||
)
|
||||
from utils.helpers import sanitize_input
|
||||
from extensions import limiter
|
||||
@ -594,6 +595,17 @@ def dashboard():
|
||||
except Exception:
|
||||
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)
|
||||
upcoming_events = db.query(NordaEvent).filter(
|
||||
NordaEvent.event_date >= date.today()
|
||||
@ -641,6 +653,7 @@ def dashboard():
|
||||
has_pending_application=has_pending_application,
|
||||
has_draft_application=has_draft_application,
|
||||
pending_application=pending_application,
|
||||
user_companies=user_companies,
|
||||
unread_notifications=unread_notifications,
|
||||
upcoming_events_count=upcoming_events_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/<int:company_id>')
|
||||
@login_required
|
||||
def company_edit():
|
||||
def company_edit(company_id=None):
|
||||
"""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')
|
||||
return redirect(url_for('public.dashboard'))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
company = db.query(Company).get(current_user.company_id)
|
||||
company = db.query(Company).get(target_company_id)
|
||||
if not company:
|
||||
flash('Nie znaleziono firmy.', 'error')
|
||||
return redirect(url_for('public.dashboard'))
|
||||
@ -50,10 +53,10 @@ def company_edit():
|
||||
).all()
|
||||
|
||||
permissions = {
|
||||
'description': current_user.can_edit_company_field('description'),
|
||||
'services': current_user.can_edit_company_field('services'),
|
||||
'contacts': current_user.can_edit_company_field('contacts'),
|
||||
'social': current_user.can_edit_company_field('social'),
|
||||
'description': current_user.can_edit_company_field('description', company_id=company.id),
|
||||
'services': current_user.can_edit_company_field('services', company_id=company.id),
|
||||
'contacts': current_user.can_edit_company_field('contacts', company_id=company.id),
|
||||
'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]
|
||||
@ -72,32 +75,35 @@ def company_edit():
|
||||
|
||||
|
||||
@bp.route('/firma/edytuj', methods=['POST'])
|
||||
@bp.route('/firma/edytuj/<int:company_id>', methods=['POST'])
|
||||
@login_required
|
||||
def company_edit_save():
|
||||
def company_edit_save(company_id=None):
|
||||
"""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')
|
||||
return redirect(url_for('public.dashboard'))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
company = db.query(Company).get(current_user.company_id)
|
||||
company = db.query(Company).get(target_company_id)
|
||||
if not company:
|
||||
flash('Nie znaleziono firmy.', 'error')
|
||||
return redirect(url_for('public.dashboard'))
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
db.commit()
|
||||
@ -106,9 +112,9 @@ def company_edit_save():
|
||||
|
||||
except Exception as e:
|
||||
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')
|
||||
return redirect(url_for('public.company_edit'))
|
||||
return redirect(url_for('public.company_edit', company_id=company_id))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
123
database.py
123
database.py
@ -377,12 +377,52 @@ class User(Base, UserMixin):
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Check if user can edit a company's profile.
|
||||
|
||||
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:
|
||||
True if user can edit the company.
|
||||
@ -391,20 +431,20 @@ class User(Base, UserMixin):
|
||||
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:
|
||||
if not target_company:
|
||||
return False
|
||||
|
||||
# EMPLOYEE or MANAGER of the company can edit
|
||||
return self.company_role_enum >= CompanyRole.EMPLOYEE
|
||||
# Check role via user_companies (supports multi-company)
|
||||
role = self.get_company_role(target_company)
|
||||
return role >= 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.
|
||||
company_id: Company to check. If None, checks user's primary company.
|
||||
|
||||
Returns:
|
||||
True if user has full management rights.
|
||||
@ -413,13 +453,13 @@ class User(Base, UserMixin):
|
||||
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:
|
||||
if not target_company:
|
||||
return False
|
||||
|
||||
# Only MANAGER of the company can manage users
|
||||
return self.company_role_enum >= CompanyRole.MANAGER
|
||||
# Check role via user_companies (supports multi-company)
|
||||
role = self.get_company_role(target_company)
|
||||
return role >= CompanyRole.MANAGER
|
||||
|
||||
def can_manage_users(self) -> bool:
|
||||
"""
|
||||
@ -442,9 +482,9 @@ class User(Base, UserMixin):
|
||||
"""
|
||||
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
|
||||
granted by a MANAGER.
|
||||
@ -452,30 +492,35 @@ class User(Base, UserMixin):
|
||||
Args:
|
||||
permission: One of: 'edit_description', 'edit_services', 'edit_contacts',
|
||||
'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)
|
||||
|
||||
Returns:
|
||||
True if user has this specific permission
|
||||
"""
|
||||
target_company = company_id or self.company_id
|
||||
|
||||
# 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
|
||||
|
||||
# Check delegated permissions
|
||||
if 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}'
|
||||
return getattr(perm, attr_name, 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.
|
||||
|
||||
Args:
|
||||
field_category: 'description', 'services', 'contacts', or 'social'
|
||||
company_id: Company to check. If None, checks user's primary company.
|
||||
|
||||
Returns:
|
||||
True if user can edit fields in this category
|
||||
@ -484,17 +529,20 @@ class User(Base, UserMixin):
|
||||
if self.has_role(SystemRole.OFFICE_MANAGER):
|
||||
return True
|
||||
|
||||
# Must have company
|
||||
if not self.company_id:
|
||||
target_company = company_id or self.company_id
|
||||
if not target_company:
|
||||
return False
|
||||
|
||||
# Check role via user_companies (supports multi-company)
|
||||
role = self.get_company_role(target_company)
|
||||
|
||||
# Managers can edit everything in their company
|
||||
if self.company_role_enum >= CompanyRole.MANAGER:
|
||||
if role >= CompanyRole.MANAGER:
|
||||
return True
|
||||
|
||||
# Employees need delegated permission
|
||||
if self.company_role_enum >= CompanyRole.EMPLOYEE:
|
||||
return self.has_delegated_permission(f'edit_{field_category}')
|
||||
if role >= CompanyRole.EMPLOYEE:
|
||||
return self.has_delegated_permission(f'edit_{field_category}', company_id=target_company)
|
||||
|
||||
return False
|
||||
|
||||
@ -579,6 +627,41 @@ class UserCompanyPermissions(Base):
|
||||
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)
|
||||
# ============================================================
|
||||
|
||||
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 -->
|
||||
<div style="margin: var(--spacing-md) 0; display: flex; gap: var(--spacing-sm); flex-wrap: wrap; align-items: center;">
|
||||
{% 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">
|
||||
<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"/>
|
||||
|
||||
@ -345,7 +345,7 @@
|
||||
</button>
|
||||
</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="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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5>Twoja firma</h5>
|
||||
{% if current_user.company_id %}
|
||||
<h3 style="color: var(--primary); font-size: 1rem;">{{ current_user.company.name if current_user.company else 'Przypisana' }}</h3>
|
||||
<h5>{{ 'Twoje firmy' if user_companies|length > 1 else 'Twoja firma' }}</h5>
|
||||
{% if user_companies %}
|
||||
<h3 style="color: var(--primary); font-size: 1rem;">{{ user_companies|length }}</h3>
|
||||
{% else %}
|
||||
<h3 style="color: var(--text-secondary); font-size: 1rem;">Brak</h3>
|
||||
{% endif %}
|
||||
<small>Profil firmowy</small>
|
||||
<small>{{ 'Profile firmowe' if user_companies|length > 1 else 'Profil firmowy' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -570,8 +570,43 @@
|
||||
</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 #}
|
||||
{% if not current_user.company_id %}
|
||||
{% if not user_companies %}
|
||||
{% if has_pending_application %}
|
||||
<div class="membership-status">
|
||||
<div class="membership-status-content">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user