feat: add alert action buttons and companies needing attention
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

Alert improvements:
- "Send welcome email" button on never-logged-in alerts (sends activation
  email with 72h reset token)
- "Reset password" button on reset-no-effect and repeat-resets alerts
- Buttons show status: sending → sent/error, prevent double-clicks
- New POST /admin/analytics/send-welcome/<user_id> endpoint

Companies needing attention:
- New section on Overview tab listing active companies with incomplete
  profiles (missing description, contact, website, address, logo)
- Sorted by number of issues, shows quality badge and edit link
- Checks logo file existence on disk for webp/svg/png/jpg

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-10 20:35:55 +01:00
parent 618bd9b8d3
commit 53491db06a
2 changed files with 199 additions and 2 deletions

View File

@ -1455,6 +1455,41 @@ def _tab_overview(db, start_date, days):
} for r in company_views_raw]
max_company = company_popularity[0]['views'] if company_popularity else 1
# Companies needing attention (active, with incomplete profiles)
import os
all_companies = db.query(Company).filter(Company.status == 'active').all()
companies_attention = []
for co in all_companies:
issues = []
if not co.description_short and not co.description_full:
issues.append('Brak opisu')
if not co.phone and not co.email:
issues.append('Brak kontaktu')
if not co.website:
issues.append('Brak strony WWW')
if not co.address_city:
issues.append('Brak adresu')
# Check logo file
logo_exists = False
for ext in ('webp', 'svg', 'png', 'jpg'):
logo_path = os.path.join('static', 'img', 'companies', f'{co.slug}.{ext}')
if os.path.exists(logo_path):
logo_exists = True
break
if not logo_exists:
issues.append('Brak logo')
if issues:
companies_attention.append({
'id': co.id,
'name': co.name,
'slug': co.slug,
'issues': issues,
'issue_count': len(issues),
'quality': co.data_quality or 'basic',
})
companies_attention.sort(key=lambda x: x['issue_count'], reverse=True)
return {
'filter_type': filter_type,
'kpi': kpi,
@ -1476,6 +1511,8 @@ def _tab_overview(db, start_date, days):
'referrer_sources': [{'domain': d, 'count': c, 'bar_pct': int(c / max_ref * 100)} for d, c in referrer_sources],
'company_popularity': company_popularity,
'max_company_views': max_company,
'companies_attention': companies_attention[:20],
'companies_attention_total': len(companies_attention),
}
@ -2228,6 +2265,70 @@ def user_insights_send_reset(user_id):
db.close()
@bp.route('/analytics/send-welcome/<int:user_id>', methods=['POST'])
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def user_insights_send_welcome(user_id):
"""Send welcome activation email to user who never logged in."""
db = SessionLocal()
try:
user = db.query(User).get(user_id)
if not user or not user.is_active:
return jsonify({'error': 'Użytkownik nie znaleziony lub nieaktywny'}), 404
# Generate activation token (72h validity)
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires = datetime.now() + timedelta(hours=72)
db.commit()
base_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
reset_url = f"{base_url}/reset-password/{token}"
import email_service
if not email_service.is_configured():
return jsonify({'error': 'Email service nie skonfigurowany'}), 500
success = email_service.send_welcome_activation_email(
email=user.email, name=user.name or user.email, reset_url=reset_url
)
if not success:
return jsonify({'error': 'Błąd wysyłki emaila'}), 500
email_log = EmailLog(
recipient_email=user.email,
recipient_name=user.name,
email_type='welcome',
subject='Zaproszenie do portalu Norda Biznes Partner',
status='sent',
user_id=user.id,
sender_email=current_user.email,
)
db.add(email_log)
audit = AuditLog(
user_id=current_user.id,
user_email=current_user.email,
action='user.welcome_email_sent',
entity_type='user',
entity_id=user.id,
entity_name=user.email,
ip_address=request.remote_addr,
request_path=request.path,
)
db.add(audit)
db.commit()
logger.info(f"Admin {current_user.email} sent welcome email to {user.email} (user {user.id})")
return jsonify({'success': True, 'message': f'Email powitalny wysłany do {user.email}'})
except Exception as e:
logger.error(f"Send welcome error for user {user_id}: {e}")
db.rollback()
return jsonify({'error': 'Błąd wysyłki'}), 500
finally:
db.close()
# ============================================================
# OLD URL REDIRECTS
# ============================================================

View File

@ -98,6 +98,15 @@
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); }
@media (max-width: 1024px) { .two-columns { grid-template-columns: 1fr; } }
/* Alert action buttons */
.btn-alert-action { padding: 4px 10px; font-size: var(--font-size-xs); border: 1px solid var(--primary); color: var(--primary); background: white; border-radius: var(--radius-sm); cursor: pointer; white-space: nowrap; transition: var(--transition); }
.btn-alert-action:hover { background: var(--primary); color: white; }
.btn-alert-action:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-alert-action.sent { border-color: var(--success); color: var(--success); }
/* Issue tags */
.issue-tag { display: inline-block; padding: 2px 8px; font-size: var(--font-size-xs); border-radius: var(--radius-sm); background: #fef3c7; color: #92400e; }
/* Path transitions */
.transition-row { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
.transition-arrow { color: var(--text-muted); }
@ -239,8 +248,13 @@
<div class="alert-message">{{ alert.user.name or alert.user.email }}: {{ alert.message }}</div>
<div class="alert-detail">{{ alert.detail }}</div>
</div>
<div class="alert-action">
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}">Szczegóły →</a>
<div class="alert-action" style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
{% if alert.type == 'never_logged_in' %}
<button class="btn-alert-action" onclick="sendWelcome({{ alert.user.id }}, this)" title="Wyślij email aktywacyjny">📧 Wyślij zaproszenie</button>
{% elif alert.type == 'reset_no_effect' or alert.type == 'repeat_resets' %}
<button class="btn-alert-action" onclick="sendReset({{ alert.user.id }}, this)" title="Wyślij nowy reset hasła">🔑 Reset hasła</button>
{% endif %}
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}" style="font-size: var(--font-size-sm); color: var(--primary);">Szczegóły →</a>
</div>
</div>
{% endfor %}
@ -1055,6 +1069,43 @@
</div>
</div>
<!-- Companies needing attention -->
{% if data.companies_attention %}
<div class="section-card">
<h2>Firmy wymagające uwagi
<span class="badge badge-high">{{ data.companies_attention_total }}</span>
</h2>
<div class="table-scroll" style="max-height: 400px;">
<table class="data-table" style="font-size: var(--font-size-sm);">
<thead>
<tr>
<th>Firma</th>
<th>Braki</th>
<th style="text-align: center;">Jakość</th>
<th></th>
</tr>
</thead>
<tbody>
{% for co in data.companies_attention %}
<tr>
<td style="font-weight: 500; max-width: 200px;">{{ co.name }}</td>
<td>
{% for issue in co.issues %}
<span class="issue-tag">{{ issue }}</span>
{% endfor %}
</td>
<td style="text-align: center;">
<span class="badge {% if co.quality == 'complete' %}badge-ok{% elif co.quality == 'enhanced' %}badge-medium{% else %}badge-high{% endif %}">{{ co.quality }}</span>
</td>
<td><a href="/company/{{ co.id }}/edit" style="font-size: var(--font-size-xs); color: var(--primary);">Edytuj →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- ============================================================ -->
<!-- TAB: CHAT & CONVERSIONS -->
<!-- ============================================================ -->
@ -1343,4 +1394,49 @@ document.querySelectorAll('.btn-reset-pw').forEach(function(btn) {
}
});
});
// Alert action buttons
async function sendWelcome(userId, btn) {
btn.disabled = true;
btn.textContent = 'Wysyłanie...';
try {
const resp = await fetch('/admin/analytics/send-welcome/' + userId, {
method: 'POST',
headers: {'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''}
});
const data = await resp.json();
if (data.success) {
btn.textContent = '✓ Wysłano';
btn.classList.add('sent');
} else {
btn.textContent = '✗ ' + (data.error || 'Błąd');
btn.disabled = false;
}
} catch(e) {
btn.textContent = '✗ Błąd';
btn.disabled = false;
}
}
async function sendReset(userId, btn) {
btn.disabled = true;
btn.textContent = 'Wysyłanie...';
try {
const resp = await fetch('/admin/analytics/send-reset/' + userId, {
method: 'POST',
headers: {'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''}
});
const data = await resp.json();
if (data.success) {
btn.textContent = '✓ Wysłano';
btn.classList.add('sent');
} else {
btn.textContent = '✗ ' + (data.error || 'Błąd');
btn.disabled = false;
}
} catch(e) {
btn.textContent = '✗ Błąd';
btn.disabled = false;
}
}
{% endblock %}