nordabiz/blueprints/admin/routes_users_api.py
Maciej Pienczyn c0d60481f0
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
refactor(rbac): Migrate legacy is_admin checks to role-based has_role()/set_role()
Replace ~20 remaining is_admin references across backend, templates and scripts
with proper SystemRole checks. Column is_admin stays as deprecated (synced by
set_role()) until DB migration removes it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:06:22 +01:00

403 lines
14 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 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 utils.decorators import role_required
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
)
db.add(new_user)
# Set role based on AI parse result (supports both old is_admin and new role field)
ai_role = user_data.get('role', 'MEMBER')
if ai_role == 'ADMIN' or user_data.get('is_admin', False):
new_user.set_role(SystemRole.ADMIN)
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.ADMIN)
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.role = new_role
# Sync is_admin flag
user.is_admin = (new_role == 'ADMIN')
# Update company_role based on new role
if new_role in ['MANAGER']:
user.company_role = 'MANAGER'
elif new_role in ['EMPLOYEE']:
user.company_role = 'EMPLOYEE'
elif new_role in ['UNAFFILIATED', 'MEMBER']:
user.company_role = 'NONE'
# OFFICE_MANAGER and ADMIN keep their company_role unchanged
# 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(
user_id=user.id,
company_id=user.company_id
).first()
if not existing_perms:
perms = UserCompanyPermissions(
user_id=user.id,
company_id=user.company_id,
granted_by_id=current_user.id
)
db.add(perms)
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.ADMIN)
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})