nordabiz/blueprints/admin/routes_users_api.py
Maciej Pienczyn 925c9862c3
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: sorting/filtering by roles in admin users + OFFICE_MANAGER access
- Add sort keys and data-sort-value attributes to 'Upr. firmowe' and 'Rola' columns
- Add filter tabs for MANAGER, OFFICE_MANAGER, company-role NONE and MANAGER
- Add data-company-role attribute to user rows for JS filtering
- Grant OFFICE_MANAGER access to admin_users, assign-company, reset-password, change-role, get-roles endpoints

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

855 lines
33 KiB
Python

"""
Admin Users API Routes - Admin blueprint
Migrated from app.py as part of the blueprint refactoring.
Contains API routes for AI-powered user parsing and bulk user creation.
"""
import json
import logging
import os
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, UserCompany
from utils.decorators import role_required
from email_service import send_email
import gemini_service
from . import bp
logger = logging.getLogger(__name__)
# ============================================================
# AI PROMPTS FOR USER PARSING
# ============================================================
AI_USER_PARSE_PROMPT = """Jesteś asystentem systemu NordaBiz pomagającym administratorowi tworzyć konta użytkowników.
ZADANIE:
Przeanalizuj podany tekst i wyodrębnij informacje o użytkownikach.
DANE WEJŚCIOWE:
```
{input_text}
```
DOSTĘPNE FIRMY W SYSTEMIE (id: nazwa):
{companies_json}
INSTRUKCJE:
1. Wyodrębnij każdą osobę/użytkownika z tekstu
2. Dla każdego użytkownika zidentyfikuj:
- email (WYMAGANY - jeśli brak prawidłowego emaila, pomiń użytkownika)
- imię i nazwisko (jeśli dostępne)
- firma (dopasuj do listy dostępnych firm po nazwie, nawet częściowej)
- rola: jeśli tekst zawiera słowa "admin", "administrator", "zarząd" przy danej osobie - ustaw role na "ADMIN", w przeciwnym razie "MEMBER"
3. Jeśli email jest niepoprawny (brak @), dodaj ostrzeżenie
4. Jeśli firma nie pasuje do żadnej z listy, ustaw company_id na null
ZWRÓĆ TYLKO CZYSTY JSON w dokładnie takim formacie (bez żadnego tekstu przed ani po):
{{
"analysis": "Krótki opis znalezionych danych (1-2 zdania po polsku)",
"users": [
{{
"email": "adres@email.pl",
"name": "Imię Nazwisko lub null",
"company_id": 123,
"company_name": "Nazwa dopasowanej firmy lub null",
"role": "MEMBER",
"warnings": []
}}
]
}}"""
AI_USER_IMAGE_PROMPT = """Jesteś asystentem systemu NordaBiz pomagającym administratorowi tworzyć konta użytkowników.
ZADANIE:
Przeanalizuj ten obraz (screenshot) i wyodrębnij informacje o użytkownikach.
Szukaj: adresów email, imion i nazwisk, nazw firm, ról (admin/user).
DOSTĘPNE FIRMY W SYSTEMIE (id: nazwa):
{companies_json}
INSTRUKCJE:
1. Przeczytaj cały tekst widoczny na obrazie
2. Wyodrębnij każdą osobę/użytkownika
3. Dla każdego użytkownika zidentyfikuj:
- email (WYMAGANY - jeśli brak, pomiń)
- imię i nazwisko
- firma (dopasuj do listy)
- rola: admin lub zwykły użytkownik
4. Jeśli email jest nieczytelny lub niepoprawny, dodaj ostrzeżenie
ZWRÓĆ TYLKO CZYSTY JSON w dokładnie takim formacie (bez żadnego tekstu przed ani po):
{{
"analysis": "Krótki opis co widzisz na obrazie (1-2 zdania po polsku)",
"users": [
{{
"email": "adres@email.pl",
"name": "Imię Nazwisko lub null",
"company_id": 123,
"company_name": "Nazwa dopasowanej firmy lub null",
"role": "MEMBER",
"warnings": []
}}
]
}}"""
# ============================================================
# API ROUTES
# ============================================================
@bp.route('/users-api/ai-parse', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_users_ai_parse():
"""Parse text or image with AI to extract user data."""
db = SessionLocal()
try:
# Get list of companies for AI context
companies = db.query(Company).order_by(Company.name).all()
companies_json = "\n".join([f"{c.id}: {c.name}" for c in companies])
# Check input type
input_type = request.form.get('input_type') or (request.get_json() or {}).get('input_type', 'text')
if input_type == 'image':
# Handle image upload
if 'file' not in request.files:
return jsonify({'success': False, 'error': 'Brak pliku obrazu'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'error': 'Nie wybrano pliku'}), 400
# Validate file type
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
if ext not in allowed_extensions:
return jsonify({'success': False, 'error': 'Dozwolone formaty: PNG, JPG, JPEG, GIF, WEBP'}), 400
# Save temp file
with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{ext}') as tmp:
file.save(tmp.name)
temp_path = tmp.name
try:
# Get Gemini service and analyze image
service = gemini_service.get_gemini_service()
prompt = AI_USER_IMAGE_PROMPT.format(companies_json=companies_json)
ai_response = service.analyze_image(temp_path, prompt)
finally:
# Clean up temp file
if os.path.exists(temp_path):
os.unlink(temp_path)
else:
# Handle text input
data = request.get_json() or {}
content = data.get('content', '').strip()
if not content:
return jsonify({'success': False, 'error': 'Brak treści do analizy'}), 400
# Get Gemini service and analyze text
service = gemini_service.get_gemini_service()
prompt = AI_USER_PARSE_PROMPT.format(
input_text=content,
companies_json=companies_json
)
ai_response = service.generate_text(
prompt=prompt,
feature='ai_user_parse',
user_id=current_user.id,
temperature=0.3 # Lower temperature for more consistent JSON output
)
# Parse AI response as JSON
# Try to extract JSON from response (handle potential markdown code blocks)
json_match = re.search(r'\{[\s\S]*\}', ai_response)
if not json_match:
logger.error(f"AI response not valid JSON: {ai_response[:500]}")
return jsonify({
'success': False,
'error': 'AI nie zwróciło prawidłowej odpowiedzi. Spróbuj ponownie.'
}), 500
try:
parsed = json.loads(json_match.group())
except json.JSONDecodeError as e:
logger.error(f"JSON parse error: {e}, response: {ai_response[:500]}")
return jsonify({
'success': False,
'error': 'Błąd parsowania odpowiedzi AI. Spróbuj ponownie.'
}), 500
# Check for duplicate emails in database
proposed_users = parsed.get('users', [])
existing_emails = []
for user in proposed_users:
email = user.get('email', '').strip().lower()
if email:
existing = db.query(User).filter(User.email == email).first()
if existing:
existing_emails.append(email)
user['warnings'] = user.get('warnings', []) + [f'Email już istnieje w systemie']
logger.info(f"Admin {current_user.email} used AI to parse users: {len(proposed_users)} found")
return jsonify({
'success': True,
'ai_response': parsed.get('analysis', 'Analiza zakończona'),
'proposed_users': proposed_users,
'duplicate_emails': existing_emails
})
except Exception as e:
logger.error(f"Error in AI user parse: {e}")
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
finally:
db.close()
@bp.route('/users-api/bulk-create', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_users_bulk_create():
"""Create multiple users from confirmed proposals."""
db = SessionLocal()
try:
data = request.get_json() or {}
users_to_create = data.get('users', [])
if not users_to_create:
return jsonify({'success': False, 'error': 'Brak użytkowników do utworzenia'}), 400
created = []
failed = []
password_chars = string.ascii_letters + string.digits + "!@#$%^&*"
for user_data in users_to_create:
email = user_data.get('email', '').strip().lower()
if not email:
failed.append({'email': email or 'brak', 'error': 'Brak adresu email'})
continue
# Check if email already exists
existing = db.query(User).filter(User.email == email).first()
if existing:
failed.append({'email': email, 'error': 'Email już istnieje'})
continue
# Validate company_id if provided
company_id = user_data.get('company_id')
if company_id:
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
company_id = None # Reset if company doesn't exist
# Generate password
generated_password = ''.join(secrets.choice(password_chars) for _ in range(16))
password_hash = generate_password_hash(generated_password, method='pbkdf2:sha256')
# Create user
try:
new_user = User(
email=email,
password_hash=password_hash,
name=user_data.get('name', '').strip() or None,
company_id=company_id,
is_verified=True,
is_active=True,
created_by_id=current_user.id
)
db.add(new_user)
# Set role based on AI parse result
ai_role = user_data.get('role', 'MEMBER')
try:
new_user.set_role(SystemRole[ai_role])
except KeyError:
new_user.set_role(SystemRole.MEMBER)
db.flush() # Get the ID
created.append({
'email': email,
'user_id': new_user.id,
'name': new_user.name,
'generated_password': generated_password
})
except Exception as e:
failed.append({'email': email, 'error': str(e)})
# Commit all successful creates
if created:
db.commit()
logger.info(f"Admin {current_user.email} bulk created {len(created)} users via AI")
return jsonify({
'success': True,
'created': created,
'failed': failed,
'message': f'Utworzono {len(created)} użytkowników' + (f', {len(failed)} błędów' if failed else '')
})
except Exception as e:
db.rollback()
logger.error(f"Error in bulk user create: {e}")
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
finally:
db.close()
@bp.route('/users-api/change-role', methods=['POST'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_users_change_role():
"""Change user's system role."""
db = SessionLocal()
try:
data = request.get_json() or {}
user_id = data.get('user_id')
new_role = data.get('role')
if not user_id or not new_role:
return jsonify({'success': False, 'error': 'Brak wymaganych danych'}), 400
# Validate role
valid_roles = ['UNAFFILIATED', 'MEMBER', 'EMPLOYEE', 'MANAGER', 'OFFICE_MANAGER', 'ADMIN']
if new_role not in valid_roles:
return jsonify({'success': False, 'error': f'Nieprawidłowa rola: {new_role}'}), 400
# Get user
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
# Prevent self-demotion from admin
if user.id == current_user.id and new_role != 'ADMIN':
return jsonify({'success': False, 'error': 'Nie możesz odebrać sobie uprawnień administratora'}), 400
old_role = user.role
user.set_role(SystemRole[new_role])
# Note: company_role is now managed independently via change-company-role endpoint
# Create default permissions if user has a company
if new_role == 'EMPLOYEE' and user.company_id:
perms = UserCompanyPermissions.get_or_create(db, user.id, user.company_id)
perms.granted_by_id = current_user.id
db.commit()
logger.info(f"Admin {current_user.email} changed role for user {user.email}: {old_role} -> {new_role}")
return jsonify({
'success': True,
'message': f'Rola zmieniona na {new_role}',
'user_id': user.id,
'old_role': old_role,
'new_role': new_role
})
except Exception as e:
db.rollback()
logger.error(f"Error changing user role: {e}")
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
finally:
db.close()
@bp.route('/users-api/roles', methods=['GET'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_users_get_roles():
"""Get list of available roles for dropdown."""
roles = [
{'value': 'UNAFFILIATED', 'label': 'Niezrzeszony', 'description': 'Firma spoza Izby'},
{'value': 'MEMBER', 'label': 'Członek', 'description': 'Członek Norda bez firmy'},
{'value': 'EMPLOYEE', 'label': 'Pracownik', 'description': 'Pracownik firmy członkowskiej'},
{'value': 'MANAGER', 'label': 'Kadra Zarządzająca', 'description': 'Pełna kontrola firmy'},
{'value': 'OFFICE_MANAGER', 'label': 'Kierownik Biura', 'description': 'Panel admina bez użytkowników'},
{'value': 'ADMIN', 'label': 'Administrator', 'description': 'Pełne prawa'},
]
return jsonify({'success': True, 'roles': roles})
@bp.route('/users-api/change-company-role', methods=['POST'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_users_change_company_role():
"""Change user's company role (portal permissions for company profile)."""
db = SessionLocal()
try:
data = request.get_json() or {}
user_id = data.get('user_id')
new_role = data.get('company_role')
if not user_id or not new_role:
return jsonify({'success': False, 'error': 'Brak wymaganych danych'}), 400
valid_roles = ['NONE', 'VIEWER', 'EMPLOYEE', 'MANAGER']
if new_role not in valid_roles:
return jsonify({'success': False, 'error': f'Nieprawidłowa rola: {new_role}'}), 400
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
old_role = user.company_role
user.company_role = new_role
# Sync to user_companies table
if user.company_id:
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()
db.commit()
logger.info(f"Admin {current_user.email} changed company_role for user {user.email}: {old_role} -> {new_role}")
return jsonify({
'success': True,
'message': f'Uprawnienia firmowe zmienione na {new_role}',
'user_id': user.id,
'old_role': old_role,
'new_role': new_role
})
except Exception as e:
db.rollback()
logger.error(f"Error changing company role: {e}")
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
finally:
db.close()
# ============================================================
# ROLE/PERMISSION NOTIFICATION EMAIL
# ============================================================
COMPANY_ROLE_LABELS = {
'MANAGER': 'Zarządzający',
'EMPLOYEE': 'Pracownik',
'VIEWER': 'Podgląd',
'NONE': 'Brak uprawnień',
}
COMPANY_ROLE_PERMISSIONS = {
'MANAGER': [
('Edycja danych kontaktowych', 'adres, telefon, email, strona www'),
('Profile społecznościowe', 'Facebook, Instagram, LinkedIn i inne'),
('Statystyki i analityka', 'odwiedziny profilu, popularność, trendy'),
('Zarządzanie zespołem', 'zapraszanie pracowników, uprawnienia'),
],
'EMPLOYEE': [
('Edycja danych kontaktowych', 'adres, telefon, email, strona www'),
],
'VIEWER': [
('Podgląd profilu firmy', 'przeglądanie informacji o firmie'),
],
}
POLISH_MONTHS_GENITIVE = [
'', 'stycznia', 'lutego', 'marca', 'kwietnia', 'maja', 'czerwca',
'lipca', 'sierpnia', 'września', 'października', 'listopada', 'grudnia'
]
def _build_role_notification_html(user_name, company_name, company_slug, company_role, sender_name, sent_at):
"""Build HTML email for role/permission change notification (v3 design)."""
role_label = COMPANY_ROLE_LABELS.get(company_role, company_role)
permissions = COMPANY_ROLE_PERMISSIONS.get(company_role, [])
# Green circle checkmark icon (inline SVG as data URI for email compatibility)
check_icon = (
'<td width="32" valign="top" style="padding-right:12px;">'
'<div style="width:28px; height:28px; background:#16a34a; border-radius:50%; text-align:center; line-height:28px;">'
'<span style="color:#ffffff; font-size:16px; font-weight:bold;">&#10003;</span>'
'</div></td>'
)
permissions_html = ''
for title, desc in permissions:
permissions_html += f'''
<tr>
<td style="padding: 12px 16px; border-bottom: 1px solid #e2e8f0;">
<table cellpadding="0" cellspacing="0"><tr>
{check_icon}
<td valign="top">
<strong style="color:#1e293b; font-size:15px;">{title}</strong>
<br><span style="color:#2563eb; font-size:13px;">{desc}</span>
</td>
</tr></table>
</td>
</tr>'''
if not permissions_html:
permissions_html = '''
<tr>
<td style="padding: 12px 16px; color: #64748b;">
Brak aktywnych uprawnień dla tej roli.
</td>
</tr>'''
company_url = f'https://nordabiznes.pl/company/{company_slug}'
date_str = f'{sent_at.day} {POLISH_MONTHS_GENITIVE[sent_at.month]} {sent_at.year}, godz. {sent_at.strftime("%H:%M")}'
return f'''<!DOCTYPE html>
<html lang="pl">
<head><meta charset="UTF-8"></head>
<body style="margin:0; padding:0; background:#f1f5f9; font-family: 'Inter', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9; padding: 32px 0;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.08);">
<!-- Header -->
<tr><td style="background-color:#1e3a8a; padding: 36px 32px; text-align: center;">
<img src="https://nordabiznes.pl/static/img/logo-email.png" width="64" height="64" alt="NB" style="border-radius:50%; margin-bottom:16px; border: 2px solid rgba(255,255,255,0.3);">
<h1 style="margin:0; color:#ffffff; font-size:24px; font-weight:700;">Zmiana uprawnień</h1>
<p style="margin:8px 0 0; color:#93c5fd; font-size:14px;">Norda Biznes Partner</p>
</td></tr>
<!-- Body -->
<tr><td style="padding: 32px;">
<p style="margin:0 0 16px; color:#1e293b; font-size:16px;">Witaj <strong>{user_name}</strong>,</p>
<p style="margin:0 0 28px; color:#475569; font-size:15px; line-height:1.5;">Twoje uprawnienia w portalu zostały zmienione. Poniżej znajdziesz podsumowanie.</p>
<!-- Company card with dark header -->
<table width="100%" cellpadding="0" cellspacing="0" style="border-radius:10px; overflow:hidden; border: 1px solid #e2e8f0; margin-bottom:28px;">
<tr><td style="background-color:#1e3a8a; padding: 16px 20px;">
<p style="margin:0 0 2px; color:#bfdbfe; font-size:12px; text-transform:uppercase; letter-spacing:1px;">Firma</p>
<p style="margin:0; color:#ffffff; font-size:18px; font-weight:700;">{company_name}</p>
</td></tr>
<tr><td style="background:#ffffff; padding: 20px; text-align:center;">
<p style="margin:0 0 8px; color:#64748b; font-size:12px; text-transform:uppercase; letter-spacing:0.5px;">Aktualna rola</p>
<table cellpadding="0" cellspacing="0" style="margin:0 auto;"><tr>
<td style="background:#16a34a; color:#ffffff; padding:8px 20px; border-radius:20px; font-size:15px; font-weight:600;">{role_label}</td>
</tr></table>
</td></tr>
</table>
<!-- Permissions -->
<p style="margin:0 0 14px; color:#1e293b; font-size:16px; font-weight:600;">Co możesz teraz robić:</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fdf8; border-radius:10px; border: 1px solid #d1fae5; margin-bottom:28px;">
{permissions_html}
</table>
<!-- CTA -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px;">
<tr><td align="center" style="padding: 8px 0;">
<a href="{company_url}" style="display:inline-block; padding:16px 40px; background-color:#1e3a8a; color:#ffffff; text-decoration:none; border-radius:8px; font-size:15px; font-weight:600;">Zobacz profil firmy &rarr;</a>
</td></tr>
<tr><td align="center" style="padding: 8px 0;">
<a href="https://nordabiznes.pl/" style="color:#2563eb; font-size:14px; text-decoration:none;">lub przejdź do panelu</a>
</td></tr>
</table>
<!-- Metadata -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:20px; border-top: 1px solid #e2e8f0; padding-top:16px;">
<tr>
<td style="padding-top:12px;" width="50%" valign="top">
<p style="margin:0 0 2px; color:#94a3b8; font-size:12px;">Data zmiany:</p>
<p style="margin:0; color:#64748b; font-size:13px;">{date_str}</p>
</td>
<td style="padding-top:12px;" width="50%" valign="top" align="right">
<p style="margin:0 0 2px; color:#94a3b8; font-size:12px;">Zmienił/a:</p>
<p style="margin:0; color:#64748b; font-size:13px;">{sender_name} (Administrator)</p>
</td>
</tr>
</table>
<!-- Contact box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:#eff6ff; border-radius:8px; border: 1px solid #bfdbfe;">
<tr><td style="padding: 16px;">
<p style="margin:0 0 6px; color:#1e40af; font-size:14px;">
<strong>Masz pytania?</strong> Skontaktuj się z biurem Izby:
</p>
<p style="margin:0 0 4px; color:#1e40af; font-size:14px;">
<a href="mailto:biuro@norda-biznes.info" style="color:#2563eb;">biuro@norda-biznes.info</a>
&nbsp;&middot;&nbsp;
<a href="tel:+48729716400" style="color:#2563eb;">+48 729 716 400</a>
</p>
<p style="margin:0; color:#64748b; font-size:13px;">
W sprawach technicznych: <a href="mailto:maciej.pienczyn@inpi.pl" style="color:#2563eb;">maciej.pienczyn@inpi.pl</a>
</p>
</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="background:#f8fafc; padding: 24px; text-align:center; border-top: 1px solid #e2e8f0;">
<p style="margin:0 0 4px; color:#1e293b; font-size:14px; font-weight:600;">Norda Biznes Partner</p>
<p style="margin:0 0 2px; color:#94a3b8; font-size:12px;">Stowarzyszenie Norda Biznes</p>
<p style="margin:0 0 12px; color:#94a3b8; font-size:12px;">ul. 12 Marca 238/5, 84-200 Wejherowo</p>
<p style="margin:0 0 12px; color:#94a3b8; font-size:13px;">
<a href="https://nordabiznes.pl" style="color:#2563eb; text-decoration:none;">nordabiznes.pl</a>
&nbsp;|&nbsp;
<a href="https://www.facebook.com/profile.php?id=100057396041901" style="color:#2563eb; text-decoration:none;">Facebook</a>
</p>
<p style="margin:0; color:#cbd5e1; font-size:11px;">To powiadomienie zostało wysłane automatycznie.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>'''
@bp.route('/users-api/<int:user_id>/send-role-notification', methods=['POST'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_send_role_notification(user_id):
"""Send email notification about current role and permissions to a user."""
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
if not user.email:
return jsonify({'success': False, 'error': 'Użytkownik nie ma adresu email'}), 400
if not user.company_id:
return jsonify({'success': False, 'error': 'Użytkownik nie ma przypisanej firmy'}), 400
company = db.query(Company).filter(Company.id == user.company_id).first()
if not company:
return jsonify({'success': False, 'error': 'Firma nie znaleziona'}), 404
now = datetime.now()
company_role = user.company_role or 'NONE'
role_label = COMPANY_ROLE_LABELS.get(company_role, company_role)
body_html = _build_role_notification_html(
user_name=user.name or user.email,
company_name=company.name,
company_slug=company.slug,
company_role=company_role,
sender_name=current_user.name or current_user.email,
sent_at=now,
)
subject = f'Twoje uprawnienia w firmie {company.name} — Norda Biznes Partner'
body_text = (
f'Witaj {user.name or user.email},\n\n'
f'Twoje uprawnienia w firmie {company.name} zostały zaktualizowane.\n'
f'Aktualna rola: {role_label}\n\n'
f'Profil firmy: https://nordabiznes.pl/company/{company.slug}\n\n'
f'Pozdrawiamy,\nZespół Norda Biznes Partner'
)
success = send_email(
to=[user.email],
subject=subject,
body_text=body_text,
body_html=body_html,
email_type='role_notification',
user_id=user.id,
recipient_name=user.name,
)
if success:
logger.info(f"Admin {current_user.email} sent role notification to {user.email} (company: {company.name}, role: {company_role})")
return jsonify({'success': True, 'message': f'Powiadomienie wysłane do {user.email}'})
else:
return jsonify({'success': False, 'error': 'Błąd wysyłania emaila. Sprawdź konfigurację serwera pocztowego.'}), 500
except Exception as e:
logger.error(f"Error sending role notification: {e}")
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
finally:
db.close()
# ============================================================
# 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)
# Auto-set Norda membership when linking to active company
if company.status == 'active':
user.is_norda_member = True
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)
# Clear Norda membership if user has no remaining active company links
user = db.query(User).get(user_id)
if user:
remaining = db.query(UserCompany).filter(
UserCompany.user_id == user_id,
UserCompany.company_id != company_id
).join(Company).filter(Company.status == 'active').first()
if not remaining:
user.is_norda_member = False
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()