nordabiz/blueprints/public/routes_team.py
Maciej Pienczyn 3862706197
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
feat(users): track who created each account (created_by_id)
- Add created_by_id FK to users table (NULL = self-registration)
- Set created_by_id in admin create, bulk create, and team add routes
- Show "samorejestracja" or "dodał: [name]" in admin users panel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:48:48 +02:00

429 lines
16 KiB
Python

"""
Team Management Routes
======================
Routes for company MANAGERs to manage their team members:
add/remove users, change roles, toggle permissions.
"""
import re
import secrets
import string
from datetime import datetime, timedelta
from flask import request, jsonify
from flask_login import login_required, current_user
from werkzeug.security import generate_password_hash
from blueprints.public import bp
from database import (
SessionLocal, User, Company, UserCompany,
UserCompanyPermissions, SystemRole, CompanyRole,
)
from email_service import send_welcome_activation_email
from extensions import limiter
from utils.permissions import can_invite_user_to_company
import logging
import os
logger = logging.getLogger(__name__)
EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
PERMISSION_KEYS = [
'can_edit_description', 'can_edit_services', 'can_edit_contacts',
'can_edit_social', 'can_manage_classifieds', 'can_post_forum',
'can_view_analytics',
]
def _require_team_manager(company_id):
"""Check if current user can manage this company's team. Returns (db, company) or raises."""
if not can_invite_user_to_company(current_user, company_id):
return None, None
db = SessionLocal()
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
db.close()
return None, None
return db, company
@bp.route('/firma/<int:company_id>/zespol')
@login_required
def team_list(company_id):
"""List team members with roles and permissions."""
db, company = _require_team_manager(company_id)
if not db:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
try:
members = db.query(UserCompany).filter(
UserCompany.company_id == company_id
).all()
result = []
for uc in members:
user = db.query(User).filter(User.id == uc.user_id).first()
if not user:
continue
perms = db.query(UserCompanyPermissions).filter_by(
user_id=user.id, company_id=company_id
).first()
member_data = {
'id': user.id,
'name': user.name or user.email,
'email': user.email,
'phone': user.phone,
'role': uc.role,
'is_primary': uc.is_primary,
'is_active': user.is_active,
'is_current_user': user.id == current_user.id,
'last_login': user.last_login.isoformat() if user.last_login else None,
'permissions': {},
}
if perms:
for key in PERMISSION_KEYS:
member_data['permissions'][key] = getattr(perms, key, False)
result.append(member_data)
# Sort: MANAGERs first, then by name
role_order = {'MANAGER': 0, 'EMPLOYEE': 1, 'VIEWER': 2, 'NONE': 3}
result.sort(key=lambda m: (role_order.get(m['role'], 9), (m['name'] or '').lower()))
return jsonify({'success': True, 'members': result, 'company_name': company.name})
except Exception as e:
logger.error(f"Error listing team for company {company_id}: {e}")
return jsonify({'success': False, 'error': 'Błąd podczas pobierania zespołu'}), 500
finally:
db.close()
@bp.route('/firma/<int:company_id>/zespol/dodaj', methods=['POST'])
@login_required
@limiter.limit("20 per hour")
def team_add_member(company_id):
"""Add a user to the company team."""
db, company = _require_team_manager(company_id)
if not db:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
try:
data = request.get_json() or {}
email = (data.get('email') or '').strip().lower()
name = (data.get('name') or '').strip()
role_str = (data.get('role') or 'EMPLOYEE').upper()
# Validate
if not email or not EMAIL_RE.match(email):
return jsonify({'success': False, 'error': 'Podaj prawidłowy adres email'}), 400
if not name:
return jsonify({'success': False, 'error': 'Podaj imię i nazwisko'}), 400
if role_str not in ('VIEWER', 'EMPLOYEE', 'MANAGER'):
return jsonify({'success': False, 'error': 'Nieprawidłowa rola'}), 400
# Check if already in this team
existing_user = db.query(User).filter(User.email == email).first()
if existing_user:
existing_uc = db.query(UserCompany).filter_by(
user_id=existing_user.id, company_id=company_id
).first()
if existing_uc:
return jsonify({'success': False, 'error': 'Ta osoba już należy do Twojego zespołu'}), 400
is_new_account = False
if existing_user:
# Existing user — just link to company
user = existing_user
is_primary = user.company_id is None # primary if no company yet
uc = UserCompany(
user_id=user.id,
company_id=company_id,
role=role_str,
is_primary=is_primary,
)
db.add(uc)
if is_primary:
user.company_id = company_id
user.company_role = role_str
user.is_norda_member = company.status == 'active'
else:
# New user — create account
is_new_account = True
password_chars = string.ascii_letters + string.digits + "!@#$%^&*"
generated_password = ''.join(secrets.choice(password_chars) for _ in range(16))
password_hash = generate_password_hash(generated_password, method='pbkdf2:sha256')
user = User(
email=email,
password_hash=password_hash,
name=name,
company_id=company_id,
is_verified=True,
is_active=True,
is_norda_member=company.status == 'active',
created_by_id=current_user.id,
)
user.set_role(SystemRole.EMPLOYEE)
user.set_company_role(CompanyRole[role_str])
db.add(user)
db.flush() # get user.id
uc = UserCompany(
user_id=user.id,
company_id=company_id,
role=role_str,
is_primary=True,
)
db.add(uc)
# Create permissions
db.flush()
UserCompanyPermissions.get_or_create(db, user.id, company_id)
db.commit()
# Send activation email for new accounts
if is_new_account:
try:
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires = datetime.now() + timedelta(hours=72)
db.commit()
app_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
reset_url = f"{app_url}/reset-password/{token}"
send_welcome_activation_email(email, name, reset_url)
logger.info(f"Activation email sent to {email} for company {company_id}")
except Exception as mail_err:
logger.error(f"Failed to send activation email to {email}: {mail_err}")
logger.info(
f"Manager {current_user.email} added {email} (role={role_str}) "
f"to company {company_id} (new_account={is_new_account})"
)
return jsonify({
'success': True,
'user_id': user.id,
'is_new_account': is_new_account,
'message': f'{"Konto utworzone i email" if is_new_account else "Użytkownik"} '
f'został dodany do zespołu',
})
except Exception as e:
db.rollback()
logger.error(f"Error adding team member to company {company_id}: {e}")
return jsonify({'success': False, 'error': 'Błąd podczas dodawania osoby'}), 500
finally:
db.close()
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/rola', methods=['POST'])
@login_required
def team_change_role(company_id, user_id):
"""Change a team member's company role."""
if user_id == current_user.id:
return jsonify({'success': False, 'error': 'Nie możesz zmienić własnej roli'}), 400
db, company = _require_team_manager(company_id)
if not db:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
try:
data = request.get_json() or {}
role_str = (data.get('role') or '').upper()
if role_str not in ('VIEWER', 'EMPLOYEE', 'MANAGER'):
return jsonify({'success': False, 'error': 'Nieprawidłowa rola'}), 400
uc = db.query(UserCompany).filter_by(
user_id=user_id, company_id=company_id
).first()
if not uc:
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
uc.role = role_str
# Sync user.company_role if this is their primary company
user = db.query(User).filter(User.id == user_id).first()
if user and user.company_id == company_id:
user.set_company_role(CompanyRole[role_str])
# Update permissions when role changes
perms = UserCompanyPermissions.get_or_create(db, user_id, company_id)
if role_str == 'MANAGER':
perms.can_edit_contacts = True
perms.can_edit_social = True
perms.can_view_analytics = True
elif role_str in ('EMPLOYEE', 'VIEWER'):
perms.can_edit_contacts = False
perms.can_edit_social = False
perms.can_view_analytics = False
perms.granted_by_id = current_user.id
perms.updated_at = datetime.now()
db.commit()
logger.info(f"Manager {current_user.email} changed role of user {user_id} to {role_str} in company {company_id}")
return jsonify({'success': True, 'message': f'Rola zmieniona na {role_str}'})
except Exception as e:
db.rollback()
logger.error(f"Error changing role for user {user_id} in company {company_id}: {e}")
return jsonify({'success': False, 'error': 'Błąd podczas zmiany roli'}), 500
finally:
db.close()
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/uprawnienia', methods=['POST'])
@login_required
def team_toggle_permission(company_id, user_id):
"""Toggle a specific permission for a team member."""
db, company = _require_team_manager(company_id)
if not db:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
try:
data = request.get_json() or {}
perm_key = data.get('permission')
perm_value = bool(data.get('value'))
if perm_key not in PERMISSION_KEYS:
return jsonify({'success': False, 'error': 'Nieprawidłowe uprawnienie'}), 400
# Only toggle permissions for EMPLOYEE role
uc = db.query(UserCompany).filter_by(
user_id=user_id, company_id=company_id
).first()
if not uc:
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
if uc.role != 'EMPLOYEE':
return jsonify({'success': False, 'error': 'Uprawnienia można zmieniać tylko pracownikom'}), 400
perms = UserCompanyPermissions.get_or_create(db, user_id, company_id)
setattr(perms, perm_key, perm_value)
perms.granted_by_id = current_user.id
perms.updated_at = datetime.now()
db.commit()
logger.info(f"Manager {current_user.email} set {perm_key}={perm_value} for user {user_id} in company {company_id}")
return jsonify({'success': True, 'message': 'Uprawnienie zaktualizowane'})
except Exception as e:
db.rollback()
logger.error(f"Error toggling permission for user {user_id}: {e}")
return jsonify({'success': False, 'error': 'Błąd podczas zmiany uprawnienia'}), 500
finally:
db.close()
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/zaproszenie', methods=['POST'])
@login_required
@limiter.limit("10 per hour")
def team_resend_invite(company_id, user_id):
"""Resend activation email to a team member who hasn't logged in yet."""
db, company = _require_team_manager(company_id)
if not db:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
try:
uc = db.query(UserCompany).filter_by(
user_id=user_id, company_id=company_id
).first()
if not uc:
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
if user.last_login:
return jsonify({'success': False, 'error': 'Ta osoba już się logowała — nie potrzebuje zaproszenia'}), 400
# Generate new activation token
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires = datetime.now() + timedelta(hours=72)
db.commit()
app_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
reset_url = f"{app_url}/reset-password/{token}"
try:
send_welcome_activation_email(user.email, user.name or user.email, reset_url)
logger.info(f"Resent activation email to {user.email} for company {company_id} by {current_user.email}")
return jsonify({'success': True, 'message': f'Zaproszenie wysłane na {user.email}'})
except Exception as mail_err:
logger.error(f"Failed to resend activation email to {user.email}: {mail_err}")
return jsonify({'success': False, 'error': 'Nie udało się wysłać emaila — spróbuj później'}), 500
except Exception as e:
db.rollback()
logger.error(f"Error resending invite for user {user_id}: {e}")
return jsonify({'success': False, 'error': 'Błąd podczas wysyłania zaproszenia'}), 500
finally:
db.close()
@bp.route('/firma/<int:company_id>/zespol/<int:user_id>/usun', methods=['POST'])
@login_required
def team_remove_member(company_id, user_id):
"""Remove a user from the company team (does not delete the account)."""
if user_id == current_user.id:
return jsonify({'success': False, 'error': 'Nie możesz usunąć siebie z zespołu'}), 400
db, company = _require_team_manager(company_id)
if not db:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
try:
uc = db.query(UserCompany).filter_by(
user_id=user_id, company_id=company_id
).first()
if not uc:
return jsonify({'success': False, 'error': 'Użytkownik nie należy do tego zespołu'}), 404
user = db.query(User).filter(User.id == user_id).first()
user_name = (user.name or user.email) if user else '?'
# Delete permissions
db.query(UserCompanyPermissions).filter_by(
user_id=user_id, company_id=company_id
).delete()
# Delete company association
db.delete(uc)
# Clear primary company link if this was user's primary
if user and user.company_id == company_id:
user.company_id = None
user.set_company_role(CompanyRole.NONE)
# Check if user has other company associations
other_uc = db.query(UserCompany).filter(
UserCompany.user_id == user_id,
UserCompany.company_id != company_id
).first()
if not other_uc:
user.is_norda_member = False
db.commit()
logger.info(f"Manager {current_user.email} removed user {user_id} ({user_name}) from company {company_id}")
return jsonify({'success': True, 'message': f'{user_name} został usunięty z zespołu'})
except Exception as e:
db.rollback()
logger.error(f"Error removing user {user_id} from company {company_id}: {e}")
return jsonify({'success': False, 'error': 'Błąd podczas usuwania osoby'}), 500
finally:
db.close()