feat: Add URL normalization and inline audit sections

- Add normalize_social_url() function to database.py to prevent
  www vs non-www duplicates in social media records
- Update update_social_media.py to normalize URLs before insert
- Update social_media_audit.py to normalize URLs before insert
- Add inline GBP Audit section to company profile
- Add inline Social Media Audit section to company profile
- Add inline IT Audit section to company profile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-11 23:07:03 +01:00
parent 91fea3ba2c
commit 986360f7d5
5 changed files with 545 additions and 13 deletions

37
app.py
View File

@ -107,7 +107,9 @@ from database import (
MembershipFee,
MembershipFeeConfig,
Person,
CompanyPerson
CompanyPerson,
GBPAudit,
ITAudit
)
# Import services
@ -632,6 +634,16 @@ def company_detail(company_id):
Person.nazwisko
).all()
# Load GBP audit (most recent)
gbp_audit = db.query(GBPAudit).filter_by(
company_id=company_id
).order_by(GBPAudit.audit_date.desc()).first()
# Load IT audit (most recent)
it_audit = db.query(ITAudit).filter_by(
company_id=company_id
).order_by(ITAudit.audit_date.desc()).first()
return render_template('company_detail.html',
company=company,
maturity_data=maturity_data,
@ -643,7 +655,9 @@ def company_detail(company_id):
social_media=social_media,
contacts=contacts,
recommendations=recommendations,
people=people
people=people,
gbp_audit=gbp_audit,
it_audit=it_audit
)
finally:
db.close()
@ -8109,7 +8123,8 @@ def admin_zopk_news_approve(news_id):
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
logger.error(f"Error approving ZOPK news {news_id}: {e}")
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas zatwierdzania'}), 500
finally:
db.close()
@ -8143,7 +8158,8 @@ def admin_zopk_news_reject(news_id):
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
logger.error(f"Error rejecting ZOPK news {news_id}: {e}")
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas odrzucania'}), 500
finally:
db.close()
@ -8274,7 +8290,8 @@ def admin_zopk_reject_old_news():
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
logger.error(f"Error rejecting old ZOPK news: {e}")
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas odrzucania starych newsów'}), 500
finally:
db.close()
@ -8308,7 +8325,8 @@ def admin_zopk_evaluate_ai():
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
logger.error(f"Error evaluating ZOPK news with AI: {e}")
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas oceny AI'}), 500
finally:
db.close()
@ -8342,7 +8360,8 @@ def admin_zopk_reevaluate_scores():
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
logger.error(f"Error reevaluating ZOPK news scores: {e}")
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas ponownej oceny'}), 500
finally:
db.close()
@ -8425,13 +8444,13 @@ def api_zopk_search_news():
# Update job status on error
try:
fetch_job.status = 'failed'
fetch_job.error_message = str(e)
fetch_job.error_message = str(e) # Keep internal log
fetch_job.completed_at = datetime.now()
db.commit()
except:
pass
return jsonify({'success': False, 'error': str(e)}), 500
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas wyszukiwania newsów'}), 500
finally:
db.close()

View File

@ -45,6 +45,57 @@ DATABASE_URL = os.getenv(
IS_SQLITE = DATABASE_URL.startswith('sqlite')
def normalize_social_url(url: str, platform: str = None) -> str:
"""
Normalize social media URLs to prevent duplicates.
Handles:
- www vs non-www (removes www.)
- http vs https (forces https)
- Trailing slashes (removes)
- Platform-specific canonicalization
Examples:
normalize_social_url('http://www.facebook.com/inpipl/')
-> 'https://facebook.com/inpipl'
normalize_social_url('https://www.instagram.com/user/')
-> 'https://instagram.com/user'
"""
if not url:
return url
url = url.strip()
# Force https
if url.startswith('http://'):
url = 'https://' + url[7:]
elif not url.startswith('https://'):
url = 'https://' + url
# Remove www. prefix
url = url.replace('https://www.', 'https://')
# Remove trailing slash
url = url.rstrip('/')
# Platform-specific normalization
if platform == 'facebook' or 'facebook.com' in url:
# fb.com -> facebook.com
url = url.replace('https://fb.com/', 'https://facebook.com/')
url = url.replace('https://m.facebook.com/', 'https://facebook.com/')
if platform == 'twitter' or 'twitter.com' in url or 'x.com' in url:
# x.com -> twitter.com (or vice versa, pick one canonical)
url = url.replace('https://x.com/', 'https://twitter.com/')
if platform == 'linkedin' or 'linkedin.com' in url:
# Remove locale prefix
url = url.replace('/pl/', '/').replace('/en/', '/')
return url
class StringArray(TypeDecorator):
"""
Platform-agnostic array type.

View File

@ -54,6 +54,25 @@ import whois
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
try:
from database import normalize_social_url
except ImportError:
# Fallback: define locally if import fails
def normalize_social_url(url: str, platform: str = None) -> str:
"""Normalize social media URLs to prevent duplicates."""
if not url:
return url
url = url.strip()
if url.startswith('http://'):
url = 'https://' + url[7:]
elif not url.startswith('https://'):
url = 'https://' + url
url = url.replace('https://www.', 'https://')
url = url.rstrip('/')
return url
# Configure logging
logging.basicConfig(
level=logging.INFO,
@ -1097,6 +1116,9 @@ class SocialMediaAuditor:
# Save social media
for platform, url in result.get('social_media', {}).items():
# Normalize URL to prevent www vs non-www duplicates
normalized_url = normalize_social_url(url, platform)
upsert_social = text("""
INSERT INTO company_social_media (
company_id, platform, url, verified_at, source, is_valid
@ -1112,7 +1134,7 @@ class SocialMediaAuditor:
session.execute(upsert_social, {
'company_id': company_id,
'platform': platform,
'url': url,
'url': normalized_url,
'verified_at': result['audit_date'],
'source': 'website_scrape',
'is_valid': True,

View File

@ -2261,6 +2261,441 @@
</div>
{% endif %}
<!-- GBP Audit Section - Only show if GBP audit was performed -->
{% if gbp_audit and gbp_audit.completeness_score is not none %}
<div class="company-section" id="gbp-audit">
<h2 class="section-title">
Audyt Google Business Profile
<span style="font-size: var(--font-size-sm); font-weight: normal; color: var(--text-secondary); margin-left: var(--spacing-sm);">
({{ gbp_audit.audit_date.strftime('%d.%m.%Y') }})
</span>
</h2>
<!-- GBP Score Banner -->
<div style="margin-bottom: var(--spacing-lg); padding: var(--spacing-lg); border-radius: var(--radius-lg); display: flex; align-items: center; gap: var(--spacing-lg);
background: linear-gradient(135deg, {% if gbp_audit.completeness_score >= 90 %}#10b981, #059669{% elif gbp_audit.completeness_score >= 50 %}#f59e0b, #d97706{% else %}#ef4444, #dc2626{% endif %});">
<div style="width: 80px; height: 80px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
<span style="font-size: 32px; font-weight: 700; color: white;">{{ gbp_audit.completeness_score }}</span>
</div>
<div style="flex: 1; color: white;">
<div style="font-size: var(--font-size-xl); font-weight: 600; margin-bottom: 4px;">
{% if gbp_audit.completeness_score >= 90 %}Doskonały profil GBP{% elif gbp_audit.completeness_score >= 70 %}Dobry profil GBP{% elif gbp_audit.completeness_score >= 50 %}Przeciętny profil GBP{% else %}Profil GBP wymaga uzupełnienia{% endif %}
</div>
<div style="font-size: var(--font-size-sm); opacity: 0.9;">
{% if gbp_audit.completeness_score >= 90 %}Profil Google Business jest kompletny i dobrze zoptymalizowany{% elif gbp_audit.completeness_score >= 70 %}Profil jest dobrze uzupełniony, ale można go jeszcze ulepszyć{% elif gbp_audit.completeness_score >= 50 %}Profil wymaga uzupełnienia kilku ważnych informacji{% else %}Profil wymaga uzupełnienia podstawowych danych{% endif %}
</div>
</div>
</div>
<!-- GBP Stats Grid -->
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--spacing-md); margin-bottom: var(--spacing-lg);">
<!-- Reviews -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center; border: 2px solid #e5e7eb;">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm); background: #fef3c7; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="#92400e" viewBox="0 0 24 24">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: #92400e;">
{% if gbp_audit.review_count is not none %}{{ gbp_audit.review_count }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Opinii</div>
</div>
<!-- Rating -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if gbp_audit.average_rating and gbp_audit.average_rating|float >= 4.5 %}#10b981{% elif gbp_audit.average_rating and gbp_audit.average_rating|float >= 3.5 %}#f59e0b{% elif gbp_audit.average_rating %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if gbp_audit.average_rating and gbp_audit.average_rating|float >= 4.5 %}#dcfce7{% elif gbp_audit.average_rating and gbp_audit.average_rating|float >= 3.5 %}#fef3c7{% elif gbp_audit.average_rating %}#fee2e2{% else %}#f3f4f6{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if gbp_audit.average_rating and gbp_audit.average_rating|float >= 4.5 %}#166534{% elif gbp_audit.average_rating and gbp_audit.average_rating|float >= 3.5 %}#92400e{% elif gbp_audit.average_rating %}#991b1b{% else %}#9ca3af{% endif %}" viewBox="0 0 24 24">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if gbp_audit.average_rating and gbp_audit.average_rating|float >= 4.5 %}#166534{% elif gbp_audit.average_rating and gbp_audit.average_rating|float >= 3.5 %}#92400e{% elif gbp_audit.average_rating %}#991b1b{% else %}#9ca3af{% endif %};">
{% if gbp_audit.average_rating is not none %}{{ gbp_audit.average_rating|round(1) }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Średnia ocen</div>
</div>
<!-- Photos -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center; border: 2px solid #e5e7eb;">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm); background: #dbeafe; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="#1e40af" viewBox="0 0 24 24">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: #1e40af;">
{% if gbp_audit.photo_count is not none %}{{ gbp_audit.photo_count }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Zdjęć</div>
</div>
<!-- Completeness -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if gbp_audit.completeness_score >= 90 %}#10b981{% elif gbp_audit.completeness_score >= 50 %}#f59e0b{% else %}#ef4444{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if gbp_audit.completeness_score >= 90 %}#dcfce7{% elif gbp_audit.completeness_score >= 50 %}#fef3c7{% else %}#fee2e2{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if gbp_audit.completeness_score >= 90 %}#166534{% elif gbp_audit.completeness_score >= 50 %}#92400e{% else %}#991b1b{% endif %}" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if gbp_audit.completeness_score >= 90 %}#166534{% elif gbp_audit.completeness_score >= 50 %}#92400e{% else %}#991b1b{% endif %};">
{{ gbp_audit.completeness_score }}%
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Kompletność</div>
</div>
</div>
<!-- Profile Checklist -->
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--spacing-lg);">
<!-- Basic Info -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); border: 1px solid var(--border);">
<h3 style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md); display: flex; align-items: center; gap: var(--spacing-sm);">
<svg width="20" height="20" fill="var(--primary)" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
</svg>
Informacje podstawowe
</h3>
<div style="display: grid; gap: var(--spacing-sm);">
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Nazwa firmy</span>
{% if gbp_audit.has_name %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Adres</span>
{% if gbp_audit.has_address %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Telefon</span>
{% if gbp_audit.has_phone %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Strona www</span>
{% if gbp_audit.has_website %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Godziny otwarcia</span>
{% if gbp_audit.has_hours %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
</div>
</div>
<!-- Content & Engagement -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); border: 1px solid var(--border);">
<h3 style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md); display: flex; align-items: center; gap: var(--spacing-sm);">
<svg width="20" height="20" fill="var(--primary)" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
</svg>
Treści i zaangażowanie
</h3>
<div style="display: grid; gap: var(--spacing-sm);">
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Opis firmy</span>
{% if gbp_audit.has_description %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Kategorie</span>
{% if gbp_audit.has_categories %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Usługi/produkty</span>
{% if gbp_audit.has_services %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">OK</span>
{% else %}
<span style="padding: 2px 8px; background: #fef3c7; color: #92400e; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Zdjęcia</span>
{% if gbp_audit.has_photos %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">{{ gbp_audit.photo_count or 'OK' }}</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Opinie klientów</span>
{% if gbp_audit.has_reviews %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">{{ gbp_audit.review_count }}</span>
{% else %}
<span style="padding: 2px 8px; background: #fef3c7; color: #92400e; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Brak</span>
{% endif %}
</div>
</div>
</div>
</div>
{% if gbp_audit.google_maps_url %}
<div style="margin-top: var(--spacing-lg); text-align: center;">
<a href="{{ gbp_audit.google_maps_url }}" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm) var(--spacing-lg); background: var(--primary); color: white; text-decoration: none; border-radius: var(--radius); font-weight: 500;">
<svg width="18" height="18" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
Zobacz w Google Maps
</a>
</div>
{% endif %}
</div>
{% endif %}
<!-- Social Media Audit Section -->
{% if social_media and social_media|length > 0 %}
<div class="company-section" id="social-media-audit">
<h2 class="section-title">
Audyt Social Media
</h2>
<!-- Social Media Summary Banner -->
{% set active_platforms = social_media|selectattr('is_valid', 'equalto', true)|list|length %}
{% set total_followers = social_media|selectattr('followers_count')|sum(attribute='followers_count') %}
<div style="margin-bottom: var(--spacing-lg); padding: var(--spacing-lg); border-radius: var(--radius-lg); display: flex; align-items: center; gap: var(--spacing-lg);
background: linear-gradient(135deg, {% if active_platforms >= 4 %}#10b981, #059669{% elif active_platforms >= 2 %}#3b82f6, #2563eb{% else %}#f59e0b, #d97706{% endif %});">
<div style="width: 80px; height: 80px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
<span style="font-size: 32px; font-weight: 700; color: white;">{{ active_platforms }}</span>
</div>
<div style="flex: 1; color: white;">
<div style="font-size: var(--font-size-xl); font-weight: 600; margin-bottom: 4px;">
{% if active_platforms >= 4 %}Doskonała obecność w Social Media{% elif active_platforms >= 2 %}Dobra obecność w Social Media{% else %}Podstawowa obecność w Social Media{% endif %}
</div>
<div style="font-size: var(--font-size-sm); opacity: 0.9;">
{{ active_platforms }} aktywn{{ 'a platforma' if active_platforms == 1 else ('e platformy' if active_platforms in [2, 3, 4] else 'ych platform') }}
{% if total_followers > 0 %} · {{ "{:,}".format(total_followers|int).replace(",", " ") }} obserwujących łącznie{% endif %}
</div>
</div>
</div>
<!-- Social Media Platforms Grid -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spacing-md);">
{% set platform_icons = {
'facebook': {'icon': '<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>', 'color': '#1877F2'},
'instagram': {'icon': '<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>', 'color': '#E4405F'},
'linkedin': {'icon': '<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>', 'color': '#0A66C2'},
'youtube': {'icon': '<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>', 'color': '#FF0000'},
'twitter': {'icon': '<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>', 'color': '#000000'},
'tiktok': {'icon': '<path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/>', 'color': '#000000'}
} %}
{% for sm in social_media %}
<a href="{{ sm.url }}" target="_blank" rel="noopener" style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center; border: 1px solid var(--border); text-decoration: none; color: inherit; transition: var(--transition);">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm); background: {{ platform_icons[sm.platform]['color'] if sm.platform in platform_icons else 'var(--primary)' }}20; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{{ platform_icons[sm.platform]['color'] if sm.platform in platform_icons else 'var(--primary)' }}" viewBox="0 0 24 24">
{{ platform_icons[sm.platform]['icon']|safe if sm.platform in platform_icons else '<circle cx="12" cy="12" r="10"/>' }}
</svg>
</div>
<div style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: 2px; text-transform: capitalize;">{{ sm.platform }}</div>
{% if sm.followers_count %}
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">{{ "{:,}".format(sm.followers_count|int).replace(",", " ") }} obserwujących</div>
{% elif sm.page_name %}
<div style="font-size: var(--font-size-sm); color: var(--text-secondary);">{{ sm.page_name|truncate(20) }}</div>
{% else %}
<div style="font-size: var(--font-size-sm); color: var(--text-muted);">Aktywny profil</div>
{% endif %}
{% if sm.is_valid %}
<span style="display: inline-block; margin-top: var(--spacing-xs); padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 11px; font-weight: 500;">Zweryfikowany</span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- IT Audit Section - Only show if IT audit was performed -->
{% if it_audit and it_audit.overall_score is not none %}
<div class="company-section" id="it-audit">
<h2 class="section-title">
Audyt IT
<span style="font-size: var(--font-size-sm); font-weight: normal; color: var(--text-secondary); margin-left: var(--spacing-sm);">
({{ it_audit.audit_date.strftime('%d.%m.%Y') }})
</span>
</h2>
<!-- IT Score Banner -->
<div style="margin-bottom: var(--spacing-lg); padding: var(--spacing-lg); border-radius: var(--radius-lg); display: flex; align-items: center; gap: var(--spacing-lg);
background: linear-gradient(135deg, {% if it_audit.overall_score >= 90 %}#10b981, #059669{% elif it_audit.overall_score >= 50 %}#6366f1, #4f46e5{% else %}#f59e0b, #d97706{% endif %});">
<div style="width: 80px; height: 80px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
<span style="font-size: 32px; font-weight: 700; color: white;">{{ it_audit.overall_score }}</span>
</div>
<div style="flex: 1; color: white;">
<div style="font-size: var(--font-size-xl); font-weight: 600; margin-bottom: 4px;">
{% if it_audit.maturity_level == 'advanced' %}Zaawansowana infrastruktura IT{% elif it_audit.maturity_level == 'established' %}Rozwinięta infrastruktura IT{% elif it_audit.maturity_level == 'developing' %}Rozwijająca się infrastruktura IT{% else %}Podstawowa infrastruktura IT{% endif %}
</div>
<div style="font-size: var(--font-size-sm); opacity: 0.9;">
{% if it_audit.overall_score >= 90 %}Firma posiada nowoczesną i dobrze zabezpieczoną infrastrukturę{% elif it_audit.overall_score >= 70 %}Infrastruktura IT na dobrym poziomie z potencjałem do rozwoju{% elif it_audit.overall_score >= 50 %}Infrastruktura IT wymaga modernizacji w niektórych obszarach{% else %}Infrastruktura IT wymaga znaczącej rozbudowy{% endif %}
</div>
</div>
</div>
<!-- IT Scores Grid -->
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--spacing-md); margin-bottom: var(--spacing-lg);">
<!-- Overall Score -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if it_audit.overall_score >= 90 %}#10b981{% elif it_audit.overall_score >= 50 %}#6366f1{% else %}#f59e0b{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if it_audit.overall_score >= 90 %}#dcfce7{% elif it_audit.overall_score >= 50 %}#e0e7ff{% else %}#fef3c7{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if it_audit.overall_score >= 90 %}#166534{% elif it_audit.overall_score >= 50 %}#4338ca{% else %}#92400e{% endif %}" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if it_audit.overall_score >= 90 %}#166534{% elif it_audit.overall_score >= 50 %}#4338ca{% else %}#92400e{% endif %};">
{{ it_audit.overall_score }}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Ogólny wynik</div>
</div>
<!-- Security Score -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if it_audit.security_score and it_audit.security_score >= 90 %}#10b981{% elif it_audit.security_score and it_audit.security_score >= 50 %}#f59e0b{% elif it_audit.security_score %}#ef4444{% else %}#e5e7eb{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if it_audit.security_score and it_audit.security_score >= 90 %}#dcfce7{% elif it_audit.security_score and it_audit.security_score >= 50 %}#fef3c7{% elif it_audit.security_score %}#fee2e2{% else %}#f3f4f6{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if it_audit.security_score and it_audit.security_score >= 90 %}#166534{% elif it_audit.security_score and it_audit.security_score >= 50 %}#92400e{% elif it_audit.security_score %}#991b1b{% else %}#9ca3af{% endif %}" viewBox="0 0 24 24">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if it_audit.security_score and it_audit.security_score >= 90 %}#166534{% elif it_audit.security_score and it_audit.security_score >= 50 %}#92400e{% elif it_audit.security_score %}#991b1b{% else %}#9ca3af{% endif %};">
{% if it_audit.security_score is not none %}{{ it_audit.security_score }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Bezpieczeństwo</div>
</div>
<!-- Collaboration Score -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center;
border: 2px solid {% if it_audit.collaboration_score and it_audit.collaboration_score >= 90 %}#10b981{% elif it_audit.collaboration_score and it_audit.collaboration_score >= 50 %}#3b82f6{% elif it_audit.collaboration_score %}#f59e0b{% else %}#e5e7eb{% endif %};">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm);
background: {% if it_audit.collaboration_score and it_audit.collaboration_score >= 90 %}#dcfce7{% elif it_audit.collaboration_score and it_audit.collaboration_score >= 50 %}#dbeafe{% elif it_audit.collaboration_score %}#fef3c7{% else %}#f3f4f6{% endif %};
display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="{% if it_audit.collaboration_score and it_audit.collaboration_score >= 90 %}#166534{% elif it_audit.collaboration_score and it_audit.collaboration_score >= 50 %}#1e40af{% elif it_audit.collaboration_score %}#92400e{% else %}#9ca3af{% endif %}" viewBox="0 0 24 24">
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/>
</svg>
</div>
<div style="font-size: 28px; font-weight: 700; color: {% if it_audit.collaboration_score and it_audit.collaboration_score >= 90 %}#166534{% elif it_audit.collaboration_score and it_audit.collaboration_score >= 50 %}#1e40af{% elif it_audit.collaboration_score %}#92400e{% else %}#9ca3af{% endif %};">
{% if it_audit.collaboration_score is not none %}{{ it_audit.collaboration_score }}{% else %}-{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Współpraca</div>
</div>
<!-- Maturity Level -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); text-align: center; border: 2px solid #e5e7eb;">
<div style="width: 48px; height: 48px; border-radius: 50%; margin: 0 auto var(--spacing-sm); background: #f3f4f6; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" fill="#6b7280" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/>
</svg>
</div>
<div style="font-size: 18px; font-weight: 700; color: var(--text-primary);">
{% if it_audit.maturity_level == 'advanced' %}Zaawansowany{% elif it_audit.maturity_level == 'established' %}Rozwinięty{% elif it_audit.maturity_level == 'developing' %}Rozwijający{% else %}Podstawowy{% endif %}
</div>
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); font-weight: 500;">Poziom dojrzałości</div>
</div>
</div>
<!-- IT Capabilities Grid -->
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--spacing-lg);">
<!-- Cloud & Identity -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); border: 1px solid var(--border);">
<h3 style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md); display: flex; align-items: center; gap: var(--spacing-sm);">
<svg width="20" height="20" fill="var(--primary)" viewBox="0 0 24 24">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/>
</svg>
Chmura i tożsamość
</h3>
<div style="display: grid; gap: var(--spacing-sm);">
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Microsoft 365</span>
{% if it_audit.has_m365 %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Tak</span>
{% else %}
<span style="padding: 2px 8px; background: #f3f4f6; color: #9ca3af; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Nie</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Azure AD / Entra ID</span>
{% if it_audit.has_azure_ad %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Tak</span>
{% else %}
<span style="padding: 2px 8px; background: #f3f4f6; color: #9ca3af; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Nie</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">Google Workspace</span>
{% if it_audit.has_google_workspace %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Tak</span>
{% else %}
<span style="padding: 2px 8px; background: #f3f4f6; color: #9ca3af; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Nie</span>
{% endif %}
</div>
</div>
</div>
<!-- Security -->
<div style="background: var(--background); border-radius: var(--radius-lg); padding: var(--spacing-lg); border: 1px solid var(--border);">
<h3 style="font-size: var(--font-size-base); font-weight: 600; color: var(--text-primary); margin-bottom: var(--spacing-md); display: flex; align-items: center; gap: var(--spacing-sm);">
<svg width="20" height="20" fill="var(--primary)" viewBox="0 0 24 24">
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>
</svg>
Bezpieczeństwo
</h3>
<div style="display: grid; gap: var(--spacing-sm);">
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">MFA (2FA)</span>
{% if it_audit.has_mfa %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Tak</span>
{% else %}
<span style="padding: 2px 8px; background: #fee2e2; color: #991b1b; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Nie</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">VPN</span>
{% if it_audit.has_vpn %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Tak</span>
{% else %}
<span style="padding: 2px 8px; background: #f3f4f6; color: #9ca3af; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Nie</span>
{% endif %}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm); background: white; border-radius: var(--radius);">
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">EDR</span>
{% if it_audit.has_edr %}
<span style="padding: 2px 8px; background: #dcfce7; color: #166534; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Tak</span>
{% else %}
<span style="padding: 2px 8px; background: #fef3c7; color: #92400e; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500;">Nie</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{# Company Events - UKRYTE (2026-01-11) - do przywrócenia w przyszłości
{% if events %}
<div class="company-section">

View File

@ -18,7 +18,7 @@ from datetime import datetime
# The database module will fall back to a safe placeholder if not set.
# NEVER commit real credentials to version control (CWE-798).
from database import SessionLocal, Company, CompanySocialMedia
from database import SessionLocal, Company, CompanySocialMedia, normalize_social_url
from sqlalchemy import func
PLATFORMS = ['facebook', 'instagram', 'youtube', 'linkedin', 'tiktok', 'twitter']
@ -63,14 +63,19 @@ def update_social_media(dry_run=False):
if not url:
continue
# Check if profile already exists
# Normalize URL to prevent www vs non-www duplicates
url = normalize_social_url(url, platform)
# Check if profile already exists for this platform
existing = db.query(CompanySocialMedia).filter(
CompanySocialMedia.company_id == company_id,
CompanySocialMedia.platform == platform
).first()
if existing:
if existing.url != url:
# Normalize existing URL for comparison
existing_normalized = normalize_social_url(existing.url, platform)
if existing_normalized != url:
old_url = existing.url
if not dry_run:
existing.url = url