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
Email clients (Gmail, Outlook, Apple Mail) don't support CSS linear-gradient(), causing white text on white background — company name, header title, and CTA button were invisible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
854 lines
33 KiB
Python
854 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
|
|
)
|
|
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.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.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.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})
|
|
|
|
|
|
@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;">✓</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 →</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>
|
|
·
|
|
<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>
|
|
|
|
|
<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()
|