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
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:
parent
1785622c0e
commit
7e570e0492
@ -9,7 +9,7 @@ from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from blueprints.public import bp
|
||||
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 datetime import datetime
|
||||
import logging
|
||||
@ -53,6 +53,10 @@ def company_edit(company_id=None):
|
||||
company_id=company.id
|
||||
).all()
|
||||
|
||||
company_websites = db.query(CompanyWebsite).filter_by(
|
||||
company_id=company.id
|
||||
).order_by(CompanyWebsite.is_primary.desc()).all()
|
||||
|
||||
permissions = {
|
||||
'description': current_user.can_edit_company_field('description', 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,
|
||||
all_contacts=contacts,
|
||||
social_media=social_media,
|
||||
company_websites=company_websites,
|
||||
permissions=permissions,
|
||||
)
|
||||
finally:
|
||||
@ -171,8 +176,7 @@ def _save_services(company):
|
||||
|
||||
def _save_contacts(db, company):
|
||||
"""Save contacts tab fields."""
|
||||
website_raw = sanitize_input(request.form.get('website', ''), max_length=500)
|
||||
company.website = ensure_url(website_raw) if website_raw else None
|
||||
_save_websites(db, company)
|
||||
|
||||
email_raw = sanitize_input(request.form.get('email', ''), max_length=255)
|
||||
if email_raw:
|
||||
@ -252,3 +256,54 @@ def _save_social_media(db, company):
|
||||
source='manual_edit',
|
||||
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
|
||||
|
||||
23
database.py
23
database.py
@ -850,6 +850,11 @@ class Company(Base):
|
||||
ai_insights = relationship('CompanyAIInsights', back_populates='company', uselist=False)
|
||||
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):
|
||||
"""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):
|
||||
"""Peer recommendations between NORDA BIZNES members"""
|
||||
__tablename__ = 'company_recommendations'
|
||||
|
||||
28
database/migrations/067_company_websites.sql
Normal file
28
database/migrations/067_company_websites.sql
Normal 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;
|
||||
@ -698,7 +698,17 @@
|
||||
|
||||
<!-- PASEK KONTAKTOWY - szybki dostep -->
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
@ -2486,6 +2496,37 @@
|
||||
</a>
|
||||
</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);">
|
||||
|
||||
<!-- SSL Certificate Card -->
|
||||
|
||||
@ -203,7 +203,19 @@
|
||||
{% endif %}
|
||||
|
||||
<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">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="10" cy="10" r="9"/>
|
||||
|
||||
@ -278,6 +278,22 @@
|
||||
/* Social platform icon hints */
|
||||
.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) */
|
||||
.ce-actions {
|
||||
display: flex;
|
||||
@ -462,11 +478,41 @@
|
||||
<fieldset {% if not permissions.contacts %}disabled{% endif %}>
|
||||
<span class="ce-section-title">Dane kontaktowe</span>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="website" class="form-label">Strona internetowa</label>
|
||||
<input type="url" id="website" name="website" class="form-input" value="{{ company.website or '' }}" placeholder="https://www.twojafirma.pl">
|
||||
<div class="form-group" style="margin-bottom: var(--spacing-lg);">
|
||||
<label class="form-label">Strony internetowe <span style="font-weight: normal; color: var(--text-secondary);">(max 5)</span></label>
|
||||
<div id="websiteList">
|
||||
{% 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ń">✕</button>
|
||||
</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ń">✕</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">
|
||||
<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">
|
||||
@ -691,10 +737,60 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
var websiteField = document.getElementById('website');
|
||||
if (websiteField && websiteField.value.trim() && !websiteField.value.match(/^https?:\/\//)) {
|
||||
websiteField.value = 'https://' + websiteField.value.trim();
|
||||
}
|
||||
// Auto-prefix https:// for all website URL inputs
|
||||
document.querySelectorAll('#websiteList input[name="website_urls[]"]').forEach(function(f) {
|
||||
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ń">✕</button>';
|
||||
list.appendChild(row);
|
||||
if (idx === 0) row.querySelector('input[type="radio"]').checked = true;
|
||||
toggleWebsiteBtn();
|
||||
});
|
||||
toggleWebsiteBtn();
|
||||
}
|
||||
})();
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user