feat: all attendees clickable, user profile page, auto-link Person on login
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
- /profil/<user_id>: simple profile for users without person_id, redirects to /osoba/ if person_id exists - event.html: all attendees are now clickable links (green) - Auto-link User→Person by name match on every login (no manual scripts) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f95e89aa74
commit
bf246a8f94
@ -39,6 +39,30 @@ except ImportError:
|
||||
logger.warning("Security service not available in auth blueprint")
|
||||
|
||||
|
||||
def _auto_link_person(db, user):
|
||||
"""Auto-link User to Person record by name match (if not already linked)."""
|
||||
if user.person_id or not user.name:
|
||||
return
|
||||
try:
|
||||
from database import Person
|
||||
name_parts = user.name.strip().split()
|
||||
if len(name_parts) < 2:
|
||||
return
|
||||
# Try exact match: first name + last name (case-insensitive)
|
||||
first_name = name_parts[0].upper()
|
||||
last_name = name_parts[-1].upper()
|
||||
from sqlalchemy import func
|
||||
person = db.query(Person).filter(
|
||||
func.upper(Person.nazwisko) == last_name,
|
||||
func.upper(Person.imiona).like(f'{first_name}%'),
|
||||
).first()
|
||||
if person:
|
||||
user.person_id = person.id
|
||||
logger.info(f"Auto-linked user {user.id} ({user.name}) to person {person.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-link person failed for user {user.id}: {e}")
|
||||
|
||||
|
||||
def _send_registration_notification(user_info):
|
||||
"""Send email notification when a new user registers"""
|
||||
try:
|
||||
@ -356,6 +380,7 @@ def login():
|
||||
# No 2FA - login directly
|
||||
login_user(user, remember=remember)
|
||||
user.last_login = datetime.now()
|
||||
_auto_link_person(db, user)
|
||||
|
||||
# Log successful login to audit
|
||||
log_audit(db, 'login', 'user', entity_id=user.id, entity_name=user.email,
|
||||
@ -453,6 +478,7 @@ def verify_2fa():
|
||||
login_user(user, remember=remember)
|
||||
session['2fa_verified'] = True
|
||||
user.last_login = datetime.now()
|
||||
_auto_link_person(db, user)
|
||||
|
||||
# Log successful 2FA login to audit
|
||||
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
|
||||
@ -455,6 +455,70 @@ def person_detail(person_id):
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/profil/<int:user_id>')
|
||||
def user_profile(user_id):
|
||||
"""User profile page - redirects to person_detail if person_id exists,
|
||||
otherwise shows a simple profile from User data."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter_by(id=user_id).first()
|
||||
if not user:
|
||||
flash('Użytkownik nie znaleziony.', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# If user has person_id, redirect to full person profile
|
||||
if user.person_id:
|
||||
return redirect(url_for('person_detail', person_id=user.person_id))
|
||||
|
||||
# Build simple profile from User data
|
||||
from datetime import datetime, timedelta
|
||||
from database import EventAttendee, NordaEvent, ForumTopic, ForumReply, UserCompany, CompanyRole
|
||||
|
||||
# Last activity label
|
||||
last_active_label = None
|
||||
if user.last_login:
|
||||
diff = datetime.now() - user.last_login
|
||||
if diff < timedelta(hours=1):
|
||||
last_active_label = 'Aktywny teraz'
|
||||
elif diff < timedelta(days=1):
|
||||
hours = int(diff.total_seconds() // 3600)
|
||||
last_active_label = f'Aktywny {hours} godz. temu'
|
||||
elif diff < timedelta(days=7):
|
||||
last_active_label = f'Aktywny {diff.days} dni temu'
|
||||
elif diff < timedelta(days=60):
|
||||
last_active_label = f'Aktywny {diff.days // 7} tyg. temu'
|
||||
else:
|
||||
last_active_label = f'Ostatnio: {user.last_login.strftime("%d.%m.%Y")}'
|
||||
|
||||
# Company associations
|
||||
user_companies = db.query(UserCompany).filter_by(user_id=user.id).all()
|
||||
|
||||
# Events attended
|
||||
attended_events = db.query(NordaEvent).join(
|
||||
EventAttendee, EventAttendee.event_id == NordaEvent.id
|
||||
).filter(
|
||||
EventAttendee.user_id == user.id,
|
||||
EventAttendee.status == 'confirmed',
|
||||
).order_by(NordaEvent.event_date.desc()).limit(5).all()
|
||||
|
||||
# Forum stats
|
||||
forum_topics_count = db.query(ForumTopic).filter_by(
|
||||
author_id=user.id, is_deleted=False).count()
|
||||
forum_replies_count = db.query(ForumReply).filter_by(
|
||||
author_id=user.id, is_deleted=False).count()
|
||||
|
||||
return render_template('user_profile.html',
|
||||
profile_user=user,
|
||||
last_active_label=last_active_label,
|
||||
user_companies=user_companies,
|
||||
attended_events=attended_events,
|
||||
forum_topics_count=forum_topics_count,
|
||||
forum_replies_count=forum_replies_count,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/company/<slug>/recommend', methods=['GET', 'POST'])
|
||||
def company_recommend(slug):
|
||||
"""Create recommendation for a company - requires login"""
|
||||
|
||||
@ -505,24 +505,18 @@
|
||||
<div class="attendees-list">
|
||||
{% for attendee in event.attendees|sort(attribute='user.name') %}
|
||||
<div class="attendee-badge">
|
||||
{# Person badge - green if verified (has person_id), blue if not #}
|
||||
{# Person badge - always clickable: person profile if person_id, user profile otherwise #}
|
||||
{% if attendee.user.person_id %}
|
||||
<a href="{{ url_for('person_detail', person_id=attendee.user.person_id) }}" class="attendee-name verified">
|
||||
{% else %}
|
||||
<a href="{{ url_for('user_profile', user_id=attendee.user.id) }}" class="attendee-name verified">
|
||||
{% endif %}
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
{{ attendee.user.name or attendee.user.email.split('@')[0] }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="attendee-name">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
{{ attendee.user.name or attendee.user.email.split('@')[0] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# Company badge - always link to company profile #}
|
||||
{% if attendee.user.company %}
|
||||
|
||||
320
templates/user_profile.html
Normal file
320
templates/user_profile.html
Normal file
@ -0,0 +1,320 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ profile_user.name or 'Użytkownik' }} - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.profile-header {
|
||||
background: white;
|
||||
padding: var(--spacing-2xl);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.profile-subtitle {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background: white;
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-title svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.contact-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.contact-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.contact-btn.primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.contact-btn.primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.contact-btn.secondary {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.contact-btn.secondary:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.contact-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--background);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--background);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
.event-item .event-date {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.company-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.company-badge:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.company-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a href="#" onclick="history.back(); return false;" class="back-link">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Wstecz
|
||||
</a>
|
||||
|
||||
<div class="profile-header">
|
||||
<div class="profile-avatar">
|
||||
{% if profile_user.avatar_path %}
|
||||
<img src="{{ url_for('static', filename=profile_user.avatar_path) }}" alt="{{ profile_user.name }}">
|
||||
{% else %}
|
||||
{{ (profile_user.name or profile_user.email)[0].upper() }}{{ (profile_user.name or '').split()[-1][0].upper() if (profile_user.name or '').split()|length > 1 else '' }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="profile-name">
|
||||
{{ profile_user.name or 'Użytkownik' }}
|
||||
{% if profile_user.is_rada_member %}
|
||||
<span style="display:inline-block;background:#f59e0b;color:#92400e;font-size:12px;padding:3px 10px;border-radius:20px;font-weight:600;vertical-align:middle;margin-left:8px;">Rada Izby</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="profile-subtitle">
|
||||
{% if profile_user.company %}
|
||||
{{ profile_user.company.name }}
|
||||
{% else %}
|
||||
Członek portalu Norda Biznes
|
||||
{% endif %}
|
||||
{% if last_active_label %}
|
||||
<span style="display:inline-block;margin-left:12px;padding:2px 10px;background:#ecfdf5;color:#166534;border-radius:12px;font-size:var(--font-size-sm);">{{ last_active_label }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if current_user.is_authenticated and current_user.id != profile_user.id %}
|
||||
<div class="contact-actions">
|
||||
<a href="{{ url_for('messages.compose', recipient=profile_user.id) }}" class="contact-btn primary">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
Wyślij wiadomość prywatną na portalu
|
||||
</a>
|
||||
{% if profile_user.company %}
|
||||
<a href="{{ url_for('company_detail_by_slug', slug=profile_user.company.slug) }}" class="contact-btn secondary">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
Profil firmy
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if attended_events or forum_topics_count > 0 or forum_replies_count > 0 %}
|
||||
<div class="profile-section">
|
||||
<h2 class="section-title">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
Aktywność na portalu
|
||||
</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ forum_topics_count }}</div>
|
||||
<div class="stat-label">temat{{ 'ów' if forum_topics_count != 1 else '' }} na forum</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ forum_replies_count }}</div>
|
||||
<div class="stat-label">odpowiedzi</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ attended_events|length }}</div>
|
||||
<div class="stat-label">wydarzeń</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if attended_events %}
|
||||
<div style="font-weight: 600; font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-sm);">Zapisany na wydarzenia:</div>
|
||||
{% for event in attended_events %}
|
||||
<a href="{{ url_for('calendar.calendar_event', event_id=event.id) }}" class="event-item">
|
||||
<span>
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="vertical-align:-2px;margin-right:4px;">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
{{ event.title }}
|
||||
</span>
|
||||
<span class="event-date">{{ event.event_date.strftime('%d.%m.%Y') }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user_companies %}
|
||||
<div class="profile-section">
|
||||
<h2 class="section-title">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
Powiązane firmy
|
||||
</h2>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--spacing-sm);">
|
||||
{% for uc in user_companies %}
|
||||
{% if uc.company %}
|
||||
<a href="{{ url_for('company_detail_by_slug', slug=uc.company.slug) }}" class="company-badge">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 21V5a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v5m-4 0h4"/>
|
||||
</svg>
|
||||
{{ uc.company.name }} ({{ uc.role.value if uc.role else 'członek' }})
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user