feat: Support multiple websites per company (max 5)
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

Add CompanyWebsite model with label, is_primary flag, and backward
compatibility sync to company.website. Dynamic form in company edit,
separate buttons in contact bar, additional banners in detail view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-17 07:45:22 +01:00
parent 1785622c0e
commit 7e570e0492
6 changed files with 268 additions and 13 deletions

View File

@ -9,7 +9,7 @@ from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from blueprints.public import bp from blueprints.public import bp
from sqlalchemy import or_ from sqlalchemy import or_
from database import SessionLocal, Company, CompanyContact, CompanySocialMedia, Category from database import SessionLocal, Company, CompanyContact, CompanySocialMedia, CompanyWebsite, Category
from utils.helpers import sanitize_input, sanitize_html, validate_email, ensure_url from utils.helpers import sanitize_input, sanitize_html, validate_email, ensure_url
from datetime import datetime from datetime import datetime
import logging import logging
@ -53,6 +53,10 @@ def company_edit(company_id=None):
company_id=company.id company_id=company.id
).all() ).all()
company_websites = db.query(CompanyWebsite).filter_by(
company_id=company.id
).order_by(CompanyWebsite.is_primary.desc()).all()
permissions = { permissions = {
'description': current_user.can_edit_company_field('description', company_id=company.id), 'description': current_user.can_edit_company_field('description', company_id=company.id),
'services': current_user.can_edit_company_field('services', company_id=company.id), 'services': current_user.can_edit_company_field('services', company_id=company.id),
@ -69,6 +73,7 @@ def company_edit(company_id=None):
contacts=editable_contacts, contacts=editable_contacts,
all_contacts=contacts, all_contacts=contacts,
social_media=social_media, social_media=social_media,
company_websites=company_websites,
permissions=permissions, permissions=permissions,
) )
finally: finally:
@ -171,8 +176,7 @@ def _save_services(company):
def _save_contacts(db, company): def _save_contacts(db, company):
"""Save contacts tab fields.""" """Save contacts tab fields."""
website_raw = sanitize_input(request.form.get('website', ''), max_length=500) _save_websites(db, company)
company.website = ensure_url(website_raw) if website_raw else None
email_raw = sanitize_input(request.form.get('email', ''), max_length=255) email_raw = sanitize_input(request.form.get('email', ''), max_length=255)
if email_raw: if email_raw:
@ -252,3 +256,54 @@ def _save_social_media(db, company):
source='manual_edit', source='manual_edit',
verified_at=datetime.now(), verified_at=datetime.now(),
)) ))
def _save_websites(db, company):
"""Save multiple website URLs from the contacts tab."""
# Delete existing editable websites
db.query(CompanyWebsite).filter(
CompanyWebsite.company_id == company.id,
or_(
CompanyWebsite.source.in_(['manual_edit', 'manual', 'migration']),
CompanyWebsite.source.is_(None)
)
).delete(synchronize_session='fetch')
website_urls = request.form.getlist('website_urls[]')
website_labels = request.form.getlist('website_labels[]')
primary_idx_raw = request.form.get('website_primary', '0')
try:
primary_idx = int(primary_idx_raw)
except (ValueError, TypeError):
primary_idx = 0
added = 0
primary_url = None
for i, url_raw in enumerate(website_urls):
if added >= 5:
break
url_raw = sanitize_input(url_raw, max_length=500)
if not url_raw:
continue
url = ensure_url(url_raw)
label = sanitize_input(website_labels[i], max_length=100) if i < len(website_labels) else ''
is_primary = (i == primary_idx)
if is_primary:
primary_url = url
db.add(CompanyWebsite(
company_id=company.id,
url=url,
label=label or None,
is_primary=is_primary,
source='manual_edit',
))
added += 1
# Sync company.website with primary for backward compatibility
if primary_url:
company.website = primary_url
elif added > 0:
# No explicit primary — first one becomes primary
company.website = ensure_url(sanitize_input(website_urls[0], max_length=500))
else:
company.website = None

View File

@ -850,6 +850,11 @@ class Company(Base):
ai_insights = relationship('CompanyAIInsights', back_populates='company', uselist=False) ai_insights = relationship('CompanyAIInsights', back_populates='company', uselist=False)
ai_enrichment_proposals = relationship('AiEnrichmentProposal', back_populates='company', cascade='all, delete-orphan') ai_enrichment_proposals = relationship('AiEnrichmentProposal', back_populates='company', cascade='all, delete-orphan')
# Multiple websites
websites = relationship('CompanyWebsite', back_populates='company',
cascade='all, delete-orphan',
order_by='CompanyWebsite.is_primary.desc()')
class Service(Base): class Service(Base):
"""Services offered by companies""" """Services offered by companies"""
@ -2441,6 +2446,24 @@ class CompanySocialMedia(Base):
) )
class CompanyWebsite(Base):
"""Multiple website URLs for companies"""
__tablename__ = 'company_websites'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
url = Column(String(500), nullable=False)
label = Column(String(100)) # optional: "Sklep", "Strona główna"
is_primary = Column(Boolean, default=False)
source = Column(String(100)) # manual_edit, migration, website_scrape
is_valid = Column(Boolean, default=True)
last_checked_at = Column(DateTime)
check_status = Column(String(50)) # ok, 404, redirect, blocked
created_at = Column(DateTime, default=datetime.now)
company = relationship('Company', back_populates='websites')
class CompanyRecommendation(Base): class CompanyRecommendation(Base):
"""Peer recommendations between NORDA BIZNES members""" """Peer recommendations between NORDA BIZNES members"""
__tablename__ = 'company_recommendations' __tablename__ = 'company_recommendations'

View File

@ -0,0 +1,28 @@
-- Migration 067: Add company_websites table for multiple website URLs per company
-- Date: 2026-02-17
CREATE TABLE IF NOT EXISTS company_websites (
id SERIAL PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
label VARCHAR(100),
is_primary BOOLEAN DEFAULT FALSE,
source VARCHAR(100),
is_valid BOOLEAN DEFAULT TRUE,
last_checked_at TIMESTAMP,
check_status VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_company_websites_company_id ON company_websites(company_id);
-- Grant permissions
GRANT ALL ON TABLE company_websites TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE company_websites_id_seq TO nordabiz_app;
-- Migrate existing data: each company.website → company_websites row with is_primary=True
INSERT INTO company_websites (company_id, url, is_primary, source, created_at)
SELECT id, website, TRUE, 'migration', NOW()
FROM companies
WHERE website IS NOT NULL AND website != ''
ON CONFLICT DO NOTHING;

View File

@ -698,7 +698,17 @@
<!-- PASEK KONTAKTOWY - szybki dostep --> <!-- PASEK KONTAKTOWY - szybki dostep -->
<div class="contact-bar"> <div class="contact-bar">
{% if company.website %} {% if company.websites %}
{% for w in company.websites %}
<a href="{{ w.url }}" class="contact-bar-item" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
<span>{{ w.label if w.label else ('WWW' if w.is_primary else w.url|replace('https://', '')|replace('http://', '')|replace('www.', '')|truncate(20, True)) }}</span>
</a>
{% endfor %}
{% elif company.website %}
<a href="{{ company.website }}" class="contact-bar-item" target="_blank" rel="noopener noreferrer"> <a href="{{ company.website }}" class="contact-bar-item" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@ -2486,6 +2496,37 @@
</a> </a>
</div> </div>
<!-- Additional websites -->
{% if company.websites %}
{% set extra_websites = company.websites|rejectattr('is_primary')|list %}
{% if extra_websites %}
{% for w in extra_websites %}
<div style="margin-bottom: var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg); background: linear-gradient(135deg, #64748b, #475569); border-radius: var(--radius-lg); display: flex; align-items: center; gap: var(--spacing-md);">
<div style="width: 40px; height: 40px; border-radius: 10px; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center;">
<svg width="20" height="20" fill="white" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" fill="none" stroke="white" stroke-width="2"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" fill="none" stroke="white" stroke-width="2"/>
</svg>
</div>
<div style="flex: 1;">
{% if w.label %}<div style="font-size: var(--font-size-xs); color: rgba(255,255,255,0.7); margin-bottom: 2px;">{{ w.label }}</div>{% endif %}
<a href="{{ w.url|ensure_url }}" target="_blank" rel="noopener noreferrer"
style="font-size: var(--font-size-lg); font-weight: 600; color: white; text-decoration: none; display: flex; align-items: center; gap: var(--spacing-sm);">
{{ w.url|replace('https://', '')|replace('http://', '')|replace('www.', '') }}
<svg width="16" height="16" fill="white" viewBox="0 0 24 24" style="opacity: 0.8;">
<path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/>
</svg>
</a>
</div>
<a href="{{ w.url|ensure_url }}" target="_blank" rel="noopener noreferrer"
style="padding: var(--spacing-xs) var(--spacing-md); background: white; color: #475569; border-radius: var(--radius); text-decoration: none; font-weight: 600; font-size: var(--font-size-sm);">
Odwiedź
</a>
</div>
{% endfor %}
{% endif %}
{% endif %}
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spacing-lg);"> <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spacing-lg);">
<!-- SSL Certificate Card --> <!-- SSL Certificate Card -->

View File

@ -203,7 +203,19 @@
{% endif %} {% endif %}
<div class="contact-grid"> <div class="contact-grid">
{% if company.website %} {% if company.websites %}
{% for w in company.websites %}
<div class="contact-item">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="10" cy="10" r="9"/>
<path d="M1 10h18M10 1a15 15 0 0 1 0 18 15 15 0 0 1 0-18"/>
</svg>
<a href="{{ w.url }}" target="_blank" rel="noopener noreferrer">
{{ w.url|replace('https://', '')|replace('http://', '')|replace('www.', '') }}{% if w.label %} ({{ w.label }}){% endif %}
</a>
</div>
{% endfor %}
{% elif company.website %}
<div class="contact-item"> <div class="contact-item">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="10" cy="10" r="9"/> <circle cx="10" cy="10" r="9"/>

View File

@ -278,6 +278,22 @@
/* Social platform icon hints */ /* Social platform icon hints */
.social-row .social-platform-select { font-weight: 500; } .social-row .social-platform-select { font-weight: 500; }
/* Website primary radio */
.website-primary-label {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
}
.website-primary-label input[type="radio"]:checked + span {
color: var(--primary, #2E4872);
font-weight: 600;
}
/* Form actions (sticky bottom) */ /* Form actions (sticky bottom) */
.ce-actions { .ce-actions {
display: flex; display: flex;
@ -462,11 +478,41 @@
<fieldset {% if not permissions.contacts %}disabled{% endif %}> <fieldset {% if not permissions.contacts %}disabled{% endif %}>
<span class="ce-section-title">Dane kontaktowe</span> <span class="ce-section-title">Dane kontaktowe</span>
<div class="form-row"> <div class="form-group" style="margin-bottom: var(--spacing-lg);">
<div class="form-group"> <label class="form-label">Strony internetowe <span style="font-weight: normal; color: var(--text-secondary);">(max 5)</span></label>
<label for="website" class="form-label">Strona internetowa</label> <div id="websiteList">
<input type="url" id="website" name="website" class="form-input" value="{{ company.website or '' }}" placeholder="https://www.twojafirma.pl"> {% for w in company_websites %}
<div class="social-row website-row">
<input type="url" name="website_urls[]" class="form-input social-url-input" value="{{ w.url }}" placeholder="https://www.twojafirma.pl">
<input type="text" name="website_labels[]" class="form-input" style="flex: 0 0 140px;" value="{{ w.label or '' }}" placeholder="Etykieta (np. Sklep)">
<label class="website-primary-label" title="Strona główna">
<input type="radio" name="website_primary" value="{{ loop.index0 }}" {% if w.is_primary %}checked{% endif %}>
<span>Główna</span>
</label>
<button type="button" class="btn-remove" onclick="removeWebsiteRow(this)" title="Usuń">&#x2715;</button>
</div> </div>
{% endfor %}
{% if not company_websites %}
{% if company.website %}
<div class="social-row website-row">
<input type="url" name="website_urls[]" class="form-input social-url-input" value="{{ company.website }}" placeholder="https://www.twojafirma.pl">
<input type="text" name="website_labels[]" class="form-input" style="flex: 0 0 140px;" value="" placeholder="Etykieta (np. Sklep)">
<label class="website-primary-label" title="Strona główna">
<input type="radio" name="website_primary" value="0" checked>
<span>Główna</span>
</label>
<button type="button" class="btn-remove" onclick="removeWebsiteRow(this)" title="Usuń">&#x2715;</button>
</div>
{% endif %}
{% endif %}
</div>
<button type="button" class="btn-add" id="addWebsiteBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Dodaj stronę WWW
</button>
</div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="email" class="form-label">Email firmowy</label> <label for="email" class="form-label">Email firmowy</label>
<input type="email" id="email" name="email" class="form-input" value="{{ company.email or '' }}" placeholder="kontakt@twojafirma.pl"> <input type="email" id="email" name="email" class="form-input" value="{{ company.email or '' }}" placeholder="kontakt@twojafirma.pl">
@ -691,10 +737,60 @@
return; return;
} }
} }
var websiteField = document.getElementById('website'); // Auto-prefix https:// for all website URL inputs
if (websiteField && websiteField.value.trim() && !websiteField.value.match(/^https?:\/\//)) { document.querySelectorAll('#websiteList input[name="website_urls[]"]').forEach(function(f) {
websiteField.value = 'https://' + websiteField.value.trim(); if (f.value.trim() && !f.value.match(/^https?:\/\//)) {
f.value = 'https://' + f.value.trim();
} }
}); });
});
})();
// Website list management
function removeWebsiteRow(btn) {
btn.closest('.website-row').remove();
reindexWebsiteRadios();
toggleWebsiteBtn();
}
function reindexWebsiteRadios() {
var rows = document.querySelectorAll('#websiteList .website-row');
var hadChecked = false;
rows.forEach(function(row, i) {
var radio = row.querySelector('input[type="radio"]');
radio.value = i;
if (radio.checked) hadChecked = true;
});
if (!hadChecked && rows.length > 0) {
rows[0].querySelector('input[type="radio"]').checked = true;
}
}
function toggleWebsiteBtn() {
var btn = document.getElementById('addWebsiteBtn');
if (btn) {
btn.style.display = document.querySelectorAll('#websiteList .website-row').length >= 5 ? 'none' : '';
}
}
(function() {
var addBtn = document.getElementById('addWebsiteBtn');
if (addBtn) {
addBtn.addEventListener('click', function() {
var list = document.getElementById('websiteList');
var idx = list.querySelectorAll('.website-row').length;
if (idx >= 5) return;
var row = document.createElement('div');
row.className = 'social-row website-row';
row.innerHTML = '<input type="url" name="website_urls[]" class="form-input social-url-input" value="" placeholder="https://www.twojafirma.pl">'
+ '<input type="text" name="website_labels[]" class="form-input" style="flex: 0 0 140px;" value="" placeholder="Etykieta (np. Sklep)">'
+ '<label class="website-primary-label" title="Strona główna"><input type="radio" name="website_primary" value="' + idx + '"><span>Główna</span></label>'
+ '<button type="button" class="btn-remove" onclick="removeWebsiteRow(this)" title="Usuń">&#x2715;</button>';
list.appendChild(row);
if (idx === 0) row.querySelector('input[type="radio"]').checked = true;
toggleWebsiteBtn();
});
toggleWebsiteBtn();
}
})(); })();
{% endblock %} {% endblock %}