nordabiz/database.py
Maciej Pienczyn e87ba8ee09 feat: Add read tracking for Forum topics/replies and B2B classifieds
- Add ForumTopicRead, ForumReplyRead, ClassifiedRead models
- Add SQL migration for new tables
- Record reads when user views forum topic (topic + all visible replies)
- Record reads when user views B2B classified
- Display "Seen by" avatars in forum topic and B2B detail pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:50:27 +01:00

3745 lines
137 KiB
Python

"""
Norda Biznes - Database Models
===============================
SQLAlchemy models for PostgreSQL database.
Models:
- User: User accounts with authentication
- Company: Company information
- Category, Service, Competency: Company classifications
- AIChatConversation, AIChatMessage: Chat history
- AIAPICostLog: API cost tracking
- CompanyDigitalMaturity: Digital maturity scores and benchmarking
- CompanyWebsiteAnalysis: Website analysis and SEO metrics
- MaturityAssessment: Historical tracking of maturity scores
- GBPAudit: Google Business Profile audit results
- ITAudit: IT infrastructure audit results
- ITCollaborationMatch: IT collaboration matches between companies
- AIUsageLog, AIUsageDaily, AIRateLimit: AI API usage monitoring
- Announcement: Ogłoszenia i aktualności dla członków
Author: Norda Biznes Development Team
Created: 2025-11-23
Updated: 2026-01-11 (AI Usage Tracking)
"""
import os
import json
from datetime import datetime
from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, Numeric, Date, Time, TypeDecorator, UniqueConstraint
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY, JSONB as PG_JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, relationship
from flask_login import UserMixin
# Database configuration
# WARNING: The fallback DATABASE_URL uses a placeholder password.
# Production credentials MUST be set via the DATABASE_URL environment variable.
# NEVER commit real credentials to version control (CWE-798).
DATABASE_URL = os.getenv(
'DATABASE_URL',
'postgresql://nordabiz_app:CHANGE_ME@localhost:5432/nordabiz'
)
# Determine if we're using SQLite
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.
Uses PostgreSQL ARRAY for PostgreSQL, stores as JSON string for SQLite.
"""
impl = Text
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == 'postgresql':
return dialect.type_descriptor(PG_ARRAY(String))
return dialect.type_descriptor(Text())
def process_bind_param(self, value, dialect):
if value is None:
return None
if dialect.name == 'postgresql':
return value
return json.dumps(value)
def process_result_value(self, value, dialect):
if value is None:
return None
if dialect.name == 'postgresql':
return value
if isinstance(value, list):
return value
return json.loads(value)
class JSONBType(TypeDecorator):
"""
Platform-agnostic JSONB type.
Uses PostgreSQL JSONB for PostgreSQL, stores as JSON string for SQLite.
"""
impl = Text
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == 'postgresql':
return dialect.type_descriptor(PG_JSONB())
return dialect.type_descriptor(Text())
def process_bind_param(self, value, dialect):
if value is None:
return None
if dialect.name == 'postgresql':
return value
return json.dumps(value)
def process_result_value(self, value, dialect):
if value is None:
return None
if dialect.name == 'postgresql':
return value
if isinstance(value, dict):
return value
return json.loads(value)
# Aliases for backwards compatibility
ARRAY = StringArray
JSONB = JSONBType
# Create engine
engine = create_engine(DATABASE_URL, echo=False)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
# ============================================================
# USER MANAGEMENT
# ============================================================
class User(Base, UserMixin):
"""User accounts"""
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
name = Column(String(255))
company_nip = Column(String(10))
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
company = relationship('Company', backref='users', lazy='joined') # eager load to avoid DetachedInstanceError
person_id = Column(Integer, ForeignKey('people.id'), nullable=True)
person = relationship('Person', backref='users', lazy='joined') # Link to Person (KRS data)
phone = Column(String(50))
# Status
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
is_admin = Column(Boolean, default=False)
is_norda_member = Column(Boolean, default=False)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
last_login = Column(DateTime)
verified_at = Column(DateTime)
# Verification token
verification_token = Column(String(255))
verification_token_expires = Column(DateTime)
# Password reset token
reset_token = Column(String(255))
reset_token_expires = Column(DateTime)
# Account lockout (brute force protection)
failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
# Two-Factor Authentication (TOTP)
totp_secret = Column(String(32), nullable=True) # Base32 encoded secret
totp_enabled = Column(Boolean, default=False)
totp_backup_codes = Column(StringArray, nullable=True) # Emergency backup codes
# Privacy settings
privacy_show_phone = Column(Boolean, default=True) # If FALSE, phone hidden from other users
privacy_show_email = Column(Boolean, default=True) # If FALSE, email hidden from other users
# Contact preferences
contact_prefer_email = Column(Boolean, default=True) # User prefers email contact
contact_prefer_phone = Column(Boolean, default=True) # User prefers phone contact
contact_prefer_portal = Column(Boolean, default=True) # User prefers portal messages
contact_note = Column(Text, nullable=True) # Additional note (e.g. best hours)
# Relationships
conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan')
forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id')
forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumReply.author_id')
forum_subscriptions = relationship('ForumTopicSubscription', back_populates='user', cascade='all, delete-orphan')
def __repr__(self):
return f'<User {self.email}>'
# ============================================================
# COMPANY DIRECTORY (existing schema from SQL)
# ============================================================
class Category(Base):
"""Company categories with hierarchical structure"""
__tablename__ = 'categories'
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False, unique=True)
slug = Column(String(100), nullable=False, unique=True)
description = Column(Text)
icon = Column(String(50))
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
# Hierarchical structure
parent_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
display_order = Column(Integer, default=0)
# Relationships
companies = relationship('Company', back_populates='category')
parent = relationship('Category', remote_side=[id], backref='subcategories')
@property
def is_main_category(self):
"""Check if this is a main (parent) category"""
return self.parent_id is None
class Company(Base):
"""Companies"""
__tablename__ = 'companies'
id = Column(Integer, primary_key=True)
name = Column(String(255), nullable=False)
legal_name = Column(String(255))
slug = Column(String(255), nullable=False, unique=True, index=True)
category_id = Column(Integer, ForeignKey('categories.id'))
# Descriptions
description_short = Column(Text)
description_full = Column(Text)
# Legal
nip = Column(String(10), unique=True)
regon = Column(String(14))
krs = Column(String(10))
# Parent company relationship (for brands/divisions of the same legal entity)
parent_company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
# External registry slugs
aleo_slug = Column(String(255)) # ALEO.com company slug for direct links
# Contact
website = Column(String(500))
email = Column(String(255))
phone = Column(String(50))
# Address
address_street = Column(String(255))
address_city = Column(String(100))
address_postal = Column(String(10))
address_full = Column(Text)
# Business data
year_established = Column(Integer)
employees_count = Column(Integer)
capital_amount = Column(Numeric(15, 2))
# Status (PostgreSQL uses ENUM types, no default here)
status = Column(String(20))
data_quality = Column(String(20))
# Extended company info
legal_form = Column(String(100))
parent_organization = Column(String(255))
industry_sector = Column(String(255))
services_offered = Column(Text)
operational_area = Column(String(500))
languages_offered = Column(String(200))
technologies_used = Column(Text)
founding_history = Column(Text) # Historia firmy + właściciele
core_values = Column(Text) # Wartości firmy
branch_count = Column(Integer)
employee_count_range = Column(String(50))
# PKD (kod działalności gospodarczej) - z CEIDG
pkd_code = Column(String(10)) # np. "6201Z"
pkd_description = Column(Text) # np. "Działalność związana z oprogramowaniem"
# Data rozpoczęcia działalności - z CEIDG
business_start_date = Column(Date) # np. 2021-02-10
# Właściciel JDG - z CEIDG (tylko dla jednoosobowych działalności)
owner_first_name = Column(String(100))
owner_last_name = Column(String(100))
# Data source tracking
data_source = Column(String(100))
data_quality_score = Column(Integer)
last_verified_at = Column(DateTime)
norda_biznes_url = Column(String(500))
norda_biznes_member_id = Column(String(50))
member_since = Column(Date) # Data przystąpienia do Izby NORDA
# Metadata
last_updated = Column(DateTime, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
# === DIGITAL MATURITY (added 2025-11-26) ===
digital_maturity_last_assessed = Column(DateTime)
digital_maturity_score = Column(Integer) # 0-100 composite score
digital_maturity_rank_category = Column(Integer)
digital_maturity_rank_overall = Column(Integer)
# AI Readiness
ai_enabled = Column(Boolean, default=False)
ai_tools_used = Column(ARRAY(String)) # PostgreSQL array (will be Text for SQLite)
data_structured = Column(Boolean, default=False)
# IT Management
it_manager_exists = Column(Boolean, default=False)
it_outsourced = Column(Boolean, default=False)
it_provider_company_id = Column(Integer, ForeignKey('companies.id'))
# Website tracking
website_last_analyzed = Column(DateTime)
website_status = Column(String(20)) # 'active', 'broken', 'no_website'
website_quality_score = Column(Integer) # 0-100
# === KRS DATA (added 2026-01-13) ===
krs_registration_date = Column(Date) # Data wpisu do KRS
krs_company_agreement_date = Column(Date) # Data umowy spółki
krs_duration = Column(String(100)) # Czas trwania (NIEOZNACZONY lub data)
krs_representation_rules = Column(Text) # Sposób reprezentacji
capital_currency = Column(String(3), default='PLN')
capital_shares_count = Column(Integer) # Liczba udziałów
capital_share_value = Column(Numeric(15, 2)) # Wartość nominalna udziału
is_opp = Column(Boolean, default=False) # Czy OPP
krs_last_audit_at = Column(DateTime) # Data ostatniego audytu KRS
krs_pdf_path = Column(Text) # Ścieżka do pliku PDF
# Relationships
category = relationship('Category', back_populates='companies')
services = relationship('CompanyService', back_populates='company', cascade='all, delete-orphan')
competencies = relationship('CompanyCompetency', back_populates='company', cascade='all, delete-orphan')
certifications = relationship('Certification', back_populates='company', cascade='all, delete-orphan')
awards = relationship('Award', back_populates='company', cascade='all, delete-orphan')
events = relationship('CompanyEvent', back_populates='company', cascade='all, delete-orphan')
# Digital Maturity relationships
digital_maturity = relationship('CompanyDigitalMaturity', back_populates='company', uselist=False)
website_analyses = relationship('CompanyWebsiteAnalysis', back_populates='company', cascade='all, delete-orphan')
maturity_history = relationship('MaturityAssessment', back_populates='company', cascade='all, delete-orphan')
# Quality tracking
quality_tracking = relationship('CompanyQualityTracking', back_populates='company', uselist=False)
# Website scraping and AI analysis
website_content = relationship('CompanyWebsiteContent', back_populates='company', cascade='all, delete-orphan')
ai_insights = relationship('CompanyAIInsights', back_populates='company', uselist=False)
class Service(Base):
"""Services offered by companies"""
__tablename__ = 'services'
id = Column(Integer, primary_key=True)
name = Column(String(255), nullable=False, unique=True)
slug = Column(String(255), nullable=False, unique=True)
description = Column(Text)
created_at = Column(DateTime, default=datetime.now)
companies = relationship('CompanyService', back_populates='service')
class CompanyService(Base):
"""Many-to-many: Companies <-> Services"""
__tablename__ = 'company_services'
company_id = Column(Integer, ForeignKey('companies.id'), primary_key=True)
service_id = Column(Integer, ForeignKey('services.id'), primary_key=True)
is_primary = Column(Boolean, default=False)
added_at = Column(DateTime, default=datetime.now)
company = relationship('Company', back_populates='services')
service = relationship('Service', back_populates='companies')
class Competency(Base):
"""Competencies/skills of companies"""
__tablename__ = 'competencies'
id = Column(Integer, primary_key=True)
name = Column(String(255), nullable=False, unique=True)
slug = Column(String(255), nullable=False, unique=True)
category = Column(String(100))
description = Column(Text)
created_at = Column(DateTime, default=datetime.now)
companies = relationship('CompanyCompetency', back_populates='competency')
class CompanyCompetency(Base):
"""Many-to-many: Companies <-> Competencies"""
__tablename__ = 'company_competencies'
company_id = Column(Integer, ForeignKey('companies.id'), primary_key=True)
competency_id = Column(Integer, ForeignKey('competencies.id'), primary_key=True)
level = Column(String(50))
added_at = Column(DateTime, default=datetime.now)
company = relationship('Company', back_populates='competencies')
competency = relationship('Competency', back_populates='companies')
class Certification(Base):
"""Company certifications"""
__tablename__ = 'certifications'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'))
name = Column(String(255), nullable=False)
issuer = Column(String(255))
certificate_number = Column(String(100))
issue_date = Column(Date)
expiry_date = Column(Date)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
company = relationship('Company', back_populates='certifications')
class Award(Base):
"""Company awards and achievements"""
__tablename__ = 'awards'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'))
name = Column(String(255), nullable=False)
issuer = Column(String(255))
year = Column(Integer)
description = Column(Text)
created_at = Column(DateTime, default=datetime.now)
company = relationship('Company', back_populates='awards')
class CompanyEvent(Base):
"""Company events and news"""
__tablename__ = 'company_events'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'))
event_type = Column(String(50), nullable=False)
title = Column(String(500), nullable=False)
description = Column(Text)
event_date = Column(Date)
source_url = Column(String(1000))
created_at = Column(DateTime, default=datetime.now)
company = relationship('Company', back_populates='events')
# ============================================================
# DIGITAL MATURITY ASSESSMENT PLATFORM
# ============================================================
class CompanyDigitalMaturity(Base):
"""Central dashboard for company digital maturity - composite scores and benchmarking"""
__tablename__ = 'company_digital_maturity'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, unique=True, index=True)
last_updated = Column(DateTime, default=datetime.now)
# === COMPOSITE SCORES (0-100 each) ===
overall_score = Column(Integer)
online_presence_score = Column(Integer)
social_media_score = Column(Integer)
it_infrastructure_score = Column(Integer)
business_applications_score = Column(Integer)
backup_disaster_recovery_score = Column(Integer)
cybersecurity_score = Column(Integer)
ai_readiness_score = Column(Integer)
digital_marketing_score = Column(Integer)
# === GAPS & OPPORTUNITIES ===
critical_gaps = Column(ARRAY(String)) # ['no_backup', 'no_firewall', etc.]
improvement_priority = Column(String(20)) # 'critical', 'high', 'medium', 'low'
estimated_investment_needed = Column(Numeric(10, 2)) # PLN
# === BENCHMARKING ===
rank_in_category = Column(Integer) # position in category
rank_overall = Column(Integer) # overall position
percentile = Column(Integer) # top X% of companies
# === SALES INTELLIGENCE ===
total_opportunity_value = Column(Numeric(10, 2)) # potential sales value (PLN)
sales_readiness = Column(String(20)) # 'hot', 'warm', 'cold', 'not_ready'
# Relationship
company = relationship('Company', back_populates='digital_maturity')
class CompanyWebsiteAnalysis(Base):
"""Detailed website and online presence analysis"""
__tablename__ = 'company_website_analysis'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, index=True)
analyzed_at = Column(DateTime, default=datetime.now, index=True)
# === BASIC INFO ===
website_url = Column(String(500))
final_url = Column(String(500)) # After redirects
http_status_code = Column(Integer)
load_time_ms = Column(Integer)
# === TECHNICAL ===
has_ssl = Column(Boolean, default=False)
ssl_expires_at = Column(Date)
ssl_issuer = Column(String(100)) # Certificate Authority (Let's Encrypt, DigiCert, etc.)
is_responsive = Column(Boolean, default=False) # mobile-friendly
cms_detected = Column(String(100))
frameworks_detected = Column(ARRAY(String)) # ['WordPress', 'Bootstrap', etc.]
# === HOSTING & SERVER (from audit) ===
last_modified_at = Column(DateTime)
hosting_provider = Column(String(100))
hosting_ip = Column(String(45))
server_software = Column(String(100))
site_author = Column(String(255)) # Website creator/agency
site_generator = Column(String(100))
domain_registrar = Column(String(100))
is_mobile_friendly = Column(Boolean, default=False)
has_viewport_meta = Column(Boolean, default=False)
# === GOOGLE BUSINESS (from audit) ===
google_rating = Column(Numeric(2, 1))
google_reviews_count = Column(Integer)
google_place_id = Column(String(100))
google_business_status = Column(String(50))
google_opening_hours = Column(JSONB) # Opening hours from GBP
google_photos_count = Column(Integer) # Number of photos on GBP
google_name = Column(String(255)) # Business name from Google
google_address = Column(String(500)) # Formatted address from Google
google_phone = Column(String(50)) # Phone from Google
google_website = Column(String(500)) # Website from Google
google_types = Column(ARRAY(Text)) # Business types/categories
google_maps_url = Column(String(500)) # Google Maps URL
# === AUDIT METADATA ===
audit_source = Column(String(50), default='automated')
audit_version = Column(String(20), default='1.0')
audit_errors = Column(Text)
# === CONTENT RICHNESS ===
content_richness_score = Column(Integer) # 1-10
page_count_estimate = Column(Integer)
word_count_homepage = Column(Integer)
has_blog = Column(Boolean, default=False)
has_portfolio = Column(Boolean, default=False)
has_contact_form = Column(Boolean, default=False)
has_live_chat = Column(Boolean, default=False)
# === EXTRACTED CONTENT ===
content_summary = Column(Text) # AI-generated summary from website
services_extracted = Column(ARRAY(String)) # Services mentioned on website
main_keywords = Column(ARRAY(String)) # Top keywords
# === SEO ===
seo_title = Column(String(500))
seo_description = Column(Text)
has_sitemap = Column(Boolean, default=False)
has_robots_txt = Column(Boolean, default=False)
google_indexed_pages = Column(Integer)
# === PAGESPEED INSIGHTS SCORES (0-100) ===
pagespeed_seo_score = Column(Integer) # Google PageSpeed SEO score 0-100
pagespeed_performance_score = Column(Integer) # Google PageSpeed Performance score 0-100
pagespeed_accessibility_score = Column(Integer) # Google PageSpeed Accessibility score 0-100
pagespeed_best_practices_score = Column(Integer) # Google PageSpeed Best Practices score 0-100
pagespeed_audits = Column(JSONB) # Full PageSpeed audit results as JSON
# === ON-PAGE SEO DETAILS ===
meta_title = Column(String(500)) # Full meta title from <title> tag
meta_description = Column(Text) # Full meta description from <meta name="description">
meta_keywords = Column(Text) # Meta keywords (legacy, rarely used)
# Heading structure
h1_count = Column(Integer) # Number of H1 tags on homepage (should be 1)
h2_count = Column(Integer) # Number of H2 tags on homepage
h3_count = Column(Integer) # Number of H3 tags on homepage
h1_text = Column(String(500)) # Text content of first H1 tag
# Image analysis
total_images = Column(Integer) # Total number of images
images_without_alt = Column(Integer) # Images missing alt attribute - accessibility issue
images_with_alt = Column(Integer) # Images with proper alt text
# Link analysis
internal_links_count = Column(Integer) # Links to same domain
external_links_count = Column(Integer) # Links to external domains
broken_links_count = Column(Integer) # Links returning 4xx/5xx
# Structured data (Schema.org, JSON-LD, Microdata)
has_structured_data = Column(Boolean, default=False) # Whether page contains JSON-LD, Microdata, or RDFa
structured_data_types = Column(ARRAY(String)) # Schema.org types found: Organization, LocalBusiness, etc.
structured_data_json = Column(JSONB) # Full structured data as JSON
# === TECHNICAL SEO ===
# Canonical URL handling
has_canonical = Column(Boolean, default=False) # Whether page has canonical URL defined
canonical_url = Column(String(500)) # The canonical URL value
# Indexability
is_indexable = Column(Boolean, default=True) # Whether page can be indexed (no noindex directive)
noindex_reason = Column(String(200)) # Reason if page is not indexable: meta tag, robots.txt, etc.
# Core Web Vitals
viewport_configured = Column(Boolean) # Whether viewport meta tag is properly configured
largest_contentful_paint_ms = Column(Integer) # Core Web Vital: LCP in milliseconds
first_input_delay_ms = Column(Integer) # Core Web Vital: FID in milliseconds
cumulative_layout_shift = Column(Numeric(5, 3)) # Core Web Vital: CLS score
# Open Graph & Social Meta
has_og_tags = Column(Boolean, default=False) # Whether page has Open Graph tags
og_title = Column(String(500)) # Open Graph title
og_description = Column(Text) # Open Graph description
og_image = Column(String(500)) # Open Graph image URL
has_twitter_cards = Column(Boolean, default=False) # Whether page has Twitter Card meta tags
# Language & International
html_lang = Column(String(10)) # Language attribute from <html lang="...">
has_hreflang = Column(Boolean, default=False) # Whether page has hreflang tags
# === SEO AUDIT METADATA ===
seo_audit_version = Column(String(20)) # Version of SEO audit script used
seo_audited_at = Column(DateTime) # Timestamp of last SEO audit
seo_audit_errors = Column(ARRAY(String)) # Errors encountered during SEO audit
seo_overall_score = Column(Integer) # Calculated overall SEO score 0-100
seo_health_score = Column(Integer) # On-page SEO health score 0-100
seo_issues = Column(JSONB) # List of SEO issues found with severity levels
# === DOMAIN ===
domain_registered_at = Column(Date)
domain_expires_at = Column(Date)
domain_age_years = Column(Integer)
# === ANALYTICS ===
has_google_analytics = Column(Boolean, default=False)
has_google_tag_manager = Column(Boolean, default=False)
has_facebook_pixel = Column(Boolean, default=False)
# === OPPORTUNITY SCORING ===
needs_redesign = Column(Boolean, default=False)
missing_features = Column(ARRAY(String)) # ['blog', 'portfolio', 'ssl', etc.]
opportunity_score = Column(Integer) # 0-100
estimated_project_value = Column(Numeric(10, 2)) # PLN
opportunity_notes = Column(Text)
# Relationship
company = relationship('Company', back_populates='website_analyses')
class CompanyQualityTracking(Base):
"""Quality tracking for company data - verification counter and quality score"""
__tablename__ = 'company_quality_tracking'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, unique=True, index=True)
verification_count = Column(Integer, default=0)
last_verified_at = Column(DateTime)
verified_by = Column(String(100))
verification_notes = Column(Text)
quality_score = Column(Integer) # 0-100%
issues_found = Column(Integer, default=0)
issues_fixed = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship
company = relationship('Company', back_populates='quality_tracking')
class CompanyWebsiteContent(Base):
"""Scraped website content for companies"""
__tablename__ = 'company_website_content'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, index=True)
scraped_at = Column(DateTime, default=datetime.utcnow)
url = Column(String(500))
http_status = Column(Integer)
raw_html = Column(Text)
raw_text = Column(Text)
page_title = Column(String(500))
meta_description = Column(Text)
main_content = Column(Text)
email_addresses = Column(ARRAY(String))
phone_numbers = Column(ARRAY(String))
social_media = Column(JSONB)
word_count = Column(Integer)
# Relationship
company = relationship('Company', back_populates='website_content')
class CompanyAIInsights(Base):
"""AI-generated insights from website analysis"""
__tablename__ = 'company_ai_insights'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, unique=True, index=True)
content_id = Column(Integer, ForeignKey('company_website_content.id'))
business_summary = Column(Text)
services_list = Column(ARRAY(String))
target_market = Column(Text)
unique_selling_points = Column(ARRAY(String))
company_values = Column(ARRAY(String))
certifications = Column(ARRAY(String))
suggested_category = Column(String(100))
category_confidence = Column(Numeric(3, 2))
industry_tags = Column(ARRAY(String))
ai_confidence_score = Column(Numeric(3, 2))
processing_time_ms = Column(Integer)
analyzed_at = Column(DateTime, default=datetime.utcnow)
# Relationship
company = relationship('Company', back_populates='ai_insights')
class MaturityAssessment(Base):
"""Historical tracking of digital maturity scores over time"""
__tablename__ = 'maturity_assessments'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False, index=True)
assessed_at = Column(DateTime, default=datetime.now, index=True)
assessed_by_user_id = Column(Integer, ForeignKey('users.id'))
assessment_type = Column(String(50)) # 'full', 'quick', 'self_reported', 'audit'
# === SNAPSHOT OF SCORES ===
overall_score = Column(Integer)
online_presence_score = Column(Integer)
social_media_score = Column(Integer)
it_infrastructure_score = Column(Integer)
business_applications_score = Column(Integer)
backup_dr_score = Column(Integer)
cybersecurity_score = Column(Integer)
ai_readiness_score = Column(Integer)
# === CHANGES SINCE LAST ASSESSMENT ===
score_change = Column(Integer) # +5, -3, etc.
areas_improved = Column(ARRAY(String)) # ['cybersecurity', 'backup']
areas_declined = Column(ARRAY(String)) # ['social_media']
notes = Column(Text)
# Relationship
company = relationship('Company', back_populates='maturity_history')
# ============================================================
# AI CHAT
# ============================================================
class AIChatConversation(Base):
"""Chat conversations"""
__tablename__ = 'ai_chat_conversations'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
title = Column(String(255))
conversation_type = Column(String(50), default='general')
# Timestamps
started_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
is_active = Column(Boolean, default=True)
# Metrics
message_count = Column(Integer, default=0)
model_name = Column(String(100))
# Relationships
user = relationship('User', back_populates='conversations')
messages = relationship('AIChatMessage', back_populates='conversation', cascade='all, delete-orphan', order_by='AIChatMessage.created_at')
class AIChatMessage(Base):
"""Chat messages"""
__tablename__ = 'ai_chat_messages'
id = Column(Integer, primary_key=True)
conversation_id = Column(Integer, ForeignKey('ai_chat_conversations.id'), nullable=False, index=True)
created_at = Column(DateTime, default=datetime.now)
# Message
role = Column(String(20), nullable=False) # 'user' or 'assistant'
content = Column(Text, nullable=False)
# Metrics
tokens_input = Column(Integer)
tokens_output = Column(Integer)
cost_usd = Column(Numeric(10, 6))
latency_ms = Column(Integer)
# Flags
edited = Column(Boolean, default=False)
regenerated = Column(Boolean, default=False)
# Feedback (for assistant messages)
feedback_rating = Column(Integer) # 1 = thumbs down, 2 = thumbs up
feedback_comment = Column(Text) # Optional user comment
feedback_at = Column(DateTime)
# Quality metrics (for analytics)
companies_mentioned = Column(Integer) # Number of companies in response
query_intent = Column(String(100)) # Detected intent: 'find_company', 'get_info', 'compare', etc.
# Relationship
conversation = relationship('AIChatConversation', back_populates='messages')
feedback = relationship('AIChatFeedback', back_populates='message', uselist=False)
class AIChatFeedback(Base):
"""Detailed feedback for AI responses - for learning and improvement"""
__tablename__ = 'ai_chat_feedback'
id = Column(Integer, primary_key=True)
message_id = Column(Integer, ForeignKey('ai_chat_messages.id'), nullable=False, unique=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
created_at = Column(DateTime, default=datetime.now)
# Rating
rating = Column(Integer, nullable=False) # 1-5 stars or 1=bad, 2=good
is_helpful = Column(Boolean) # Was the answer helpful?
is_accurate = Column(Boolean) # Was the information accurate?
found_company = Column(Boolean) # Did user find what they were looking for?
# Feedback text
comment = Column(Text)
suggested_answer = Column(Text) # What should have been the answer?
# Context for learning
original_query = Column(Text) # The user's question
expected_companies = Column(Text) # JSON list of company names user expected
# Relationship
message = relationship('AIChatMessage', back_populates='feedback')
# ============================================================
# FORUM
# ============================================================
class ForumTopic(Base):
"""Forum topics/threads"""
__tablename__ = 'forum_topics'
id = Column(Integer, primary_key=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
# Category and Status (for feedback tracking)
category = Column(String(50), default='question') # feature_request, bug, question, announcement
status = Column(String(50), default='new') # new, in_progress, resolved, rejected
status_changed_by = Column(Integer, ForeignKey('users.id'))
status_changed_at = Column(DateTime)
status_note = Column(Text)
# Moderation flags
is_pinned = Column(Boolean, default=False)
is_locked = Column(Boolean, default=False)
is_ai_generated = Column(Boolean, default=False)
views_count = Column(Integer, default=0)
# Edit tracking
edited_at = Column(DateTime)
edited_by = Column(Integer, ForeignKey('users.id'))
edit_count = Column(Integer, default=0)
# Soft delete
is_deleted = Column(Boolean, default=False)
deleted_at = Column(DateTime)
deleted_by = Column(Integer, ForeignKey('users.id'))
# Reactions (JSONB: {"👍": [user_ids], "❤️": [user_ids], "🎉": [user_ids]})
reactions = Column(PG_JSONB, default={})
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Constants for validation
CATEGORIES = ['feature_request', 'bug', 'question', 'announcement', 'test']
STATUSES = ['new', 'in_progress', 'resolved', 'rejected']
CATEGORY_LABELS = {
'feature_request': 'Propozycja funkcji',
'bug': 'Błąd',
'question': 'Pytanie',
'announcement': 'Ogłoszenie',
'test': 'Testowy'
}
STATUS_LABELS = {
'new': 'Nowy',
'in_progress': 'W realizacji',
'resolved': 'Rozwiązany',
'rejected': 'Odrzucony'
}
# Relationships
author = relationship('User', foreign_keys=[author_id], back_populates='forum_topics')
status_changer = relationship('User', foreign_keys=[status_changed_by])
editor = relationship('User', foreign_keys=[edited_by])
deleter = relationship('User', foreign_keys=[deleted_by])
replies = relationship('ForumReply', back_populates='topic', cascade='all, delete-orphan', order_by='ForumReply.created_at')
attachments = relationship('ForumAttachment', back_populates='topic', cascade='all, delete-orphan',
primaryjoin="and_(ForumAttachment.topic_id==ForumTopic.id, ForumAttachment.attachment_type=='topic')")
subscriptions = relationship('ForumTopicSubscription', back_populates='topic', cascade='all, delete-orphan')
@property
def reply_count(self):
return len(self.replies)
@property
def last_activity(self):
if self.replies:
return max(r.created_at for r in self.replies)
return self.created_at
@property
def category_label(self):
return self.CATEGORY_LABELS.get(self.category, self.category)
@property
def status_label(self):
return self.STATUS_LABELS.get(self.status, self.status)
class ForumReply(Base):
"""Forum replies to topics"""
__tablename__ = 'forum_replies'
id = Column(Integer, primary_key=True)
topic_id = Column(Integer, ForeignKey('forum_topics.id'), nullable=False)
author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
content = Column(Text, nullable=False)
is_ai_generated = Column(Boolean, default=False)
# Edit tracking
edited_at = Column(DateTime)
edited_by = Column(Integer, ForeignKey('users.id'))
edit_count = Column(Integer, default=0)
# Soft delete
is_deleted = Column(Boolean, default=False)
deleted_at = Column(DateTime)
deleted_by = Column(Integer, ForeignKey('users.id'))
# Reactions (JSONB: {"👍": [user_ids], "❤️": [user_ids], "🎉": [user_ids]})
reactions = Column(PG_JSONB, default={})
# Solution marking
is_solution = Column(Boolean, default=False)
marked_as_solution_by = Column(Integer, ForeignKey('users.id'))
marked_as_solution_at = Column(DateTime)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
topic = relationship('ForumTopic', back_populates='replies')
author = relationship('User', foreign_keys=[author_id], back_populates='forum_replies')
editor = relationship('User', foreign_keys=[edited_by])
deleter = relationship('User', foreign_keys=[deleted_by])
solution_marker = relationship('User', foreign_keys=[marked_as_solution_by])
attachments = relationship('ForumAttachment', back_populates='reply', cascade='all, delete-orphan',
primaryjoin="and_(ForumAttachment.reply_id==ForumReply.id, ForumAttachment.attachment_type=='reply')")
class ForumAttachment(Base):
"""Forum file attachments for topics and replies"""
__tablename__ = 'forum_attachments'
id = Column(Integer, primary_key=True)
# Polymorphic relationship (topic or reply)
attachment_type = Column(String(20), nullable=False) # 'topic' or 'reply'
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
# File metadata
original_filename = Column(String(255), nullable=False)
stored_filename = Column(String(255), nullable=False, unique=True)
file_extension = Column(String(10), nullable=False)
file_size = Column(Integer, nullable=False) # in bytes
mime_type = Column(String(100), nullable=False)
# Uploader
uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
# Relationships
topic = relationship('ForumTopic', back_populates='attachments', foreign_keys=[topic_id])
reply = relationship('ForumReply', back_populates='attachments', foreign_keys=[reply_id])
uploader = relationship('User')
# Allowed file types
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
@property
def url(self):
"""Get the URL to serve this file"""
date = self.created_at or datetime.now()
subdir = 'topics' if self.attachment_type == 'topic' else 'replies'
return f"/static/uploads/forum/{subdir}/{date.year}/{date.month:02d}/{self.stored_filename}"
@property
def is_image(self):
"""Check if this is an image file"""
return self.mime_type.startswith('image/')
@property
def size_display(self):
"""Human-readable file size"""
if self.file_size < 1024:
return f"{self.file_size} B"
elif self.file_size < 1024 * 1024:
return f"{self.file_size / 1024:.1f} KB"
else:
return f"{self.file_size / (1024 * 1024):.1f} MB"
class ForumTopicSubscription(Base):
"""Forum topic subscriptions for notifications"""
__tablename__ = 'forum_topic_subscriptions'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'), nullable=False)
notify_email = Column(Boolean, default=True)
notify_app = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (UniqueConstraint('user_id', 'topic_id', name='uq_forum_subscription_user_topic'),)
# Relationships
user = relationship('User', back_populates='forum_subscriptions')
topic = relationship('ForumTopic', back_populates='subscriptions')
class ForumReport(Base):
"""Forum content reports for moderation"""
__tablename__ = 'forum_reports'
id = Column(Integer, primary_key=True)
reporter_id = Column(Integer, ForeignKey('users.id'), nullable=False)
# Polymorphic relationship (topic or reply)
content_type = Column(String(20), nullable=False) # 'topic' or 'reply'
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
reason = Column(String(50), nullable=False) # spam, offensive, off-topic, other
description = Column(Text)
status = Column(String(20), default='pending') # pending, reviewed, dismissed
reviewed_by = Column(Integer, ForeignKey('users.id'))
reviewed_at = Column(DateTime)
review_note = Column(Text)
created_at = Column(DateTime, default=datetime.now)
# Constants
REASONS = ['spam', 'offensive', 'off-topic', 'other']
REASON_LABELS = {
'spam': 'Spam',
'offensive': 'Obraźliwe treści',
'off-topic': 'Nie na temat',
'other': 'Inne'
}
STATUSES = ['pending', 'reviewed', 'dismissed']
# Relationships
reporter = relationship('User', foreign_keys=[reporter_id])
reviewer = relationship('User', foreign_keys=[reviewed_by])
topic = relationship('ForumTopic')
reply = relationship('ForumReply')
@property
def reason_label(self):
return self.REASON_LABELS.get(self.reason, self.reason)
class ForumEditHistory(Base):
"""Forum edit history for audit trail"""
__tablename__ = 'forum_edit_history'
id = Column(Integer, primary_key=True)
# Polymorphic relationship (topic or reply)
content_type = Column(String(20), nullable=False) # 'topic' or 'reply'
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'))
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'))
editor_id = Column(Integer, ForeignKey('users.id'), nullable=False)
old_content = Column(Text, nullable=False)
new_content = Column(Text, nullable=False)
edit_reason = Column(String(255))
created_at = Column(DateTime, default=datetime.now)
# Relationships
editor = relationship('User')
topic = relationship('ForumTopic')
reply = relationship('ForumReply')
class ForumTopicRead(Base):
"""
Śledzenie odczytów wątków forum (seen by).
Zapisuje kto i kiedy przeczytał dany wątek.
"""
__tablename__ = 'forum_topic_reads'
id = Column(Integer, primary_key=True)
topic_id = Column(Integer, ForeignKey('forum_topics.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
read_at = Column(DateTime, default=datetime.now)
# Relationships
topic = relationship('ForumTopic', backref='readers')
user = relationship('User')
# Unique constraint
__table_args__ = (
UniqueConstraint('topic_id', 'user_id', name='uq_forum_topic_user_read'),
)
def __repr__(self):
return f"<ForumTopicRead topic={self.topic_id} user={self.user_id}>"
class ForumReplyRead(Base):
"""
Śledzenie odczytów odpowiedzi na forum (seen by).
Zapisuje kto i kiedy przeczytał daną odpowiedź.
"""
__tablename__ = 'forum_reply_reads'
id = Column(Integer, primary_key=True)
reply_id = Column(Integer, ForeignKey('forum_replies.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
read_at = Column(DateTime, default=datetime.now)
# Relationships
reply = relationship('ForumReply', backref='readers')
user = relationship('User')
# Unique constraint
__table_args__ = (
UniqueConstraint('reply_id', 'user_id', name='uq_forum_reply_user_read'),
)
def __repr__(self):
return f"<ForumReplyRead reply={self.reply_id} user={self.user_id}>"
class AIAPICostLog(Base):
"""API cost tracking"""
__tablename__ = 'ai_api_costs'
id = Column(Integer, primary_key=True)
timestamp = Column(DateTime, default=datetime.now, index=True)
# API details
api_provider = Column(String(50)) # 'gemini'
model_name = Column(String(100))
feature = Column(String(100)) # 'ai_chat', 'general', etc.
# User context
user_id = Column(Integer, ForeignKey('users.id'), index=True)
# Token usage
input_tokens = Column(Integer)
output_tokens = Column(Integer)
total_tokens = Column(Integer)
# Costs
input_cost = Column(Numeric(10, 6))
output_cost = Column(Numeric(10, 6))
total_cost = Column(Numeric(10, 6))
# Status
success = Column(Boolean, default=True)
error_message = Column(Text)
latency_ms = Column(Integer)
# Privacy
prompt_hash = Column(String(64)) # SHA256 hash, not storing actual prompts
# ============================================================
# CALENDAR / EVENTS
# ============================================================
class NordaEvent(Base):
"""Spotkania i wydarzenia Norda Biznes"""
__tablename__ = 'norda_events'
id = Column(Integer, primary_key=True)
title = Column(String(255), nullable=False)
description = Column(Text)
event_type = Column(String(50), default='meeting') # meeting, webinar, networking, other
# Data i czas
event_date = Column(Date, nullable=False)
time_start = Column(Time)
time_end = Column(Time)
# Lokalizacja
location = Column(String(500)) # Adres lub "Online"
location_url = Column(String(1000)) # Link do Google Maps lub Zoom
# Prelegent (opcjonalnie)
speaker_name = Column(String(255))
speaker_company_id = Column(Integer, ForeignKey('companies.id'))
# Metadane
is_featured = Column(Boolean, default=False)
is_ai_generated = Column(Boolean, default=False)
max_attendees = Column(Integer)
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
# Źródło danych (tracking)
source = Column(String(255)) # np. 'kalendarz_norda_2026', 'manual', 'api'
source_note = Column(Text) # Pełna informacja o źródle
# Relationships
speaker_company = relationship('Company')
creator = relationship('User', foreign_keys=[created_by])
attendees = relationship('EventAttendee', back_populates='event', cascade='all, delete-orphan')
@property
def attendee_count(self):
return len(self.attendees)
@property
def is_past(self):
from datetime import date
return self.event_date < date.today()
class EventAttendee(Base):
"""RSVP na wydarzenia"""
__tablename__ = 'event_attendees'
id = Column(Integer, primary_key=True)
event_id = Column(Integer, ForeignKey('norda_events.id'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
status = Column(String(20), default='confirmed') # confirmed, maybe, declined
registered_at = Column(DateTime, default=datetime.now)
event = relationship('NordaEvent', back_populates='attendees')
user = relationship('User')
# ============================================================
# PRIVATE MESSAGES
# ============================================================
class PrivateMessage(Base):
"""Wiadomości prywatne między członkami"""
__tablename__ = 'private_messages'
id = Column(Integer, primary_key=True)
sender_id = Column(Integer, ForeignKey('users.id'), nullable=False)
recipient_id = Column(Integer, ForeignKey('users.id'), nullable=False)
subject = Column(String(255))
content = Column(Text, nullable=False)
is_read = Column(Boolean, default=False)
read_at = Column(DateTime)
# Dla wątków konwersacji
parent_id = Column(Integer, ForeignKey('private_messages.id'))
created_at = Column(DateTime, default=datetime.now)
sender = relationship('User', foreign_keys=[sender_id], backref='sent_messages')
recipient = relationship('User', foreign_keys=[recipient_id], backref='received_messages')
parent = relationship('PrivateMessage', remote_side=[id])
# ============================================================
# B2B CLASSIFIEDS
# ============================================================
class Classified(Base):
"""Ogłoszenia B2B - Szukam/Oferuję"""
__tablename__ = 'classifieds'
id = Column(Integer, primary_key=True)
author_id = Column(Integer, ForeignKey('users.id'), nullable=False)
company_id = Column(Integer, ForeignKey('companies.id'))
# Typ ogłoszenia
listing_type = Column(String(20), nullable=False) # 'szukam', 'oferuje'
category = Column(String(50), nullable=False) # uslugi, produkty, wspolpraca, praca, inne
title = Column(String(255), nullable=False)
description = Column(Text, nullable=False)
# Opcjonalne szczegóły
budget_info = Column(String(255)) # "do negocjacji", "5000-10000 PLN"
location_info = Column(String(255)) # Wejherowo, Cała Polska, Online
# Status
is_active = Column(Boolean, default=True)
is_ai_generated = Column(Boolean, default=False)
is_test = Column(Boolean, default=False) # Oznaczenie dla testowych ogłoszeń
expires_at = Column(DateTime) # Auto-wygaśnięcie po 30 dniach
views_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
author = relationship('User', backref='classifieds')
company = relationship('Company')
@property
def is_expired(self):
if self.expires_at:
return datetime.now() > self.expires_at
return False
class ClassifiedRead(Base):
"""
Śledzenie odczytów ogłoszeń B2B (seen by).
Zapisuje kto i kiedy przeczytał dane ogłoszenie.
"""
__tablename__ = 'classified_reads'
id = Column(Integer, primary_key=True)
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
read_at = Column(DateTime, default=datetime.now)
# Relationships
classified = relationship('Classified', backref='readers')
user = relationship('User')
# Unique constraint
__table_args__ = (
UniqueConstraint('classified_id', 'user_id', name='uq_classified_user_read'),
)
def __repr__(self):
return f"<ClassifiedRead classified={self.classified_id} user={self.user_id}>"
class CompanyContact(Base):
"""Multiple contacts (phones, emails) per company with source tracking"""
__tablename__ = 'company_contacts'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Contact type: 'phone', 'email', 'fax', 'mobile'
contact_type = Column(String(20), nullable=False, index=True)
# Contact value (phone number or email address)
value = Column(String(255), nullable=False)
# Purpose/description: 'Biuro', 'Sprzedaż', 'Właściciel', 'Transport', 'Serwis', etc.
purpose = Column(String(100))
# Is this the primary contact of this type?
is_primary = Column(Boolean, default=False)
# Source of this contact data
source = Column(String(100)) # 'website', 'krs', 'google_business', 'facebook', 'manual', 'brave_search'
source_url = Column(String(500)) # URL where the contact was found
source_date = Column(Date) # When the contact was found/verified
# Validation
is_verified = Column(Boolean, default=False)
verified_at = Column(DateTime)
verified_by = Column(String(100))
# Metadata
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = relationship('Company', backref='contacts')
__table_args__ = (
UniqueConstraint('company_id', 'contact_type', 'value', name='uq_company_contact_type_value'),
)
class CompanySocialMedia(Base):
"""Social media profiles for companies with verification tracking"""
__tablename__ = 'company_social_media'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
platform = Column(String(50), nullable=False, index=True) # facebook, linkedin, instagram, youtube, twitter
url = Column(String(500), nullable=False)
# Tracking freshness
verified_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
source = Column(String(100)) # website_scrape, brave_search, manual, facebook_api
# Validation
is_valid = Column(Boolean, default=True)
last_checked_at = Column(DateTime)
check_status = Column(String(50)) # ok, 404, redirect, blocked
# Metadata from platform
page_name = Column(String(255))
followers_count = Column(Integer)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = relationship('Company', backref='social_media_profiles')
__table_args__ = (
UniqueConstraint('company_id', 'platform', 'url', name='uq_company_platform_url'),
)
class CompanyRecommendation(Base):
"""Peer recommendations between NORDA BIZNES members"""
__tablename__ = 'company_recommendations'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
# Recommendation content
recommendation_text = Column(Text, nullable=False)
service_category = Column(String(200)) # Optional: specific service recommended for
# Privacy settings
show_contact = Column(Boolean, default=True) # Show recommender's contact info
# Moderation
status = Column(String(20), default='pending', index=True) # pending, approved, rejected
moderated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
moderated_at = Column(DateTime)
rejection_reason = Column(Text)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company = relationship('Company', backref='recommendations')
user = relationship('User', foreign_keys=[user_id], backref='recommendations_given')
moderator = relationship('User', foreign_keys=[moderated_by], backref='recommendations_moderated')
__table_args__ = (
UniqueConstraint('user_id', 'company_id', name='uq_user_company_recommendation'),
)
class UserNotification(Base):
"""
In-app notifications for users.
Supports badges and notification center.
"""
__tablename__ = 'user_notifications'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
# Notification content
title = Column(String(255), nullable=False)
message = Column(Text)
notification_type = Column(String(50), default='info', index=True)
# Types: news, system, message, event, alert
# Related entity (optional)
related_type = Column(String(50)) # company_news, event, message
related_id = Column(Integer)
# Status
is_read = Column(Boolean, default=False, index=True)
read_at = Column(DateTime)
# Link
action_url = Column(String(500))
# Timestamps
created_at = Column(DateTime, default=datetime.now, index=True)
# Relationship
user = relationship('User', backref='notifications')
def mark_as_read(self):
self.is_read = True
self.read_at = datetime.now()
# ============================================================
# GOOGLE BUSINESS PROFILE AUDIT
# ============================================================
class GBPAudit(Base):
"""
Google Business Profile audit results for companies.
Tracks completeness scores and provides improvement recommendations.
"""
__tablename__ = 'gbp_audits'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Audit timestamp
audit_date = Column(DateTime, default=datetime.now, nullable=False, index=True)
# Completeness scoring (0-100)
completeness_score = Column(Integer)
# Field-by-field status tracking
# Example: {"name": {"status": "complete", "value": "Company Name"}, "phone": {"status": "missing"}, ...}
fields_status = Column(JSONB)
# AI-generated recommendations
# Example: [{"priority": "high", "field": "description", "recommendation": "Add a detailed business description..."}, ...]
recommendations = Column(JSONB)
# Individual field scores (for detailed breakdown)
has_name = Column(Boolean, default=False)
has_address = Column(Boolean, default=False)
has_phone = Column(Boolean, default=False)
has_website = Column(Boolean, default=False)
has_hours = Column(Boolean, default=False)
has_categories = Column(Boolean, default=False)
has_photos = Column(Boolean, default=False)
has_description = Column(Boolean, default=False)
has_services = Column(Boolean, default=False)
has_reviews = Column(Boolean, default=False)
# Photo counts
photo_count = Column(Integer, default=0)
logo_present = Column(Boolean, default=False)
cover_photo_present = Column(Boolean, default=False)
# Review metrics
review_count = Column(Integer, default=0)
average_rating = Column(Numeric(2, 1))
# Google Place data
google_place_id = Column(String(100))
google_maps_url = Column(String(500))
# Audit metadata
audit_source = Column(String(50), default='manual') # manual, automated, api
audit_version = Column(String(20), default='1.0')
audit_errors = Column(Text)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = relationship('Company', backref='gbp_audits')
def __repr__(self):
return f'<GBPAudit company_id={self.company_id} score={self.completeness_score}>'
@property
def score_category(self):
"""Return score category: excellent, good, needs_work, poor"""
if self.completeness_score is None:
return 'unknown'
if self.completeness_score >= 90:
return 'excellent'
elif self.completeness_score >= 70:
return 'good'
elif self.completeness_score >= 50:
return 'needs_work'
else:
return 'poor'
# ============================================================
# IT INFRASTRUCTURE AUDIT
# ============================================================
class ITAudit(Base):
"""
IT infrastructure audit for companies.
Tracks IT infrastructure, security posture, and collaboration readiness.
Used for cross-company collaboration matching.
"""
__tablename__ = 'it_audits'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'),
nullable=False, index=True)
# Audit timestamp and metadata
audit_date = Column(DateTime, default=datetime.now, nullable=False, index=True)
audit_source = Column(String(50), default='form') # form, api_sync
audited_by = Column(Integer, ForeignKey('users.id'))
# === SCORES (0-100) ===
overall_score = Column(Integer)
completeness_score = Column(Integer)
security_score = Column(Integer)
collaboration_score = Column(Integer)
maturity_level = Column(String(20)) # basic, developing, established, advanced
# === SECTION 1: IT CONTACT ===
has_it_manager = Column(Boolean, default=False)
it_outsourced = Column(Boolean, default=False)
it_provider_name = Column(String(255))
it_contact_name = Column(String(255))
it_contact_email = Column(String(255))
# === SECTION 2: CLOUD & IDENTITY ===
has_azure_ad = Column(Boolean, default=False)
azure_tenant_name = Column(String(255))
azure_user_count = Column(String(20)) # Range: 1-10, 11-50, 51-100, 100+
has_m365 = Column(Boolean, default=False)
m365_plans = Column(ARRAY(String)) # Business Basic, Business Standard, E3, E5, etc.
teams_usage = Column(ARRAY(String)) # chat, meetings, files, phone
has_google_workspace = Column(Boolean, default=False)
# === SECTION 3: SERVER INFRASTRUCTURE ===
server_count = Column(String(20)) # Range: 0, 1-3, 4-10, 10+
server_types = Column(ARRAY(String)) # physical, vm_onprem, cloud_iaas
virtualization_platform = Column(String(50)) # none, vmware, hyperv, proxmox, kvm
server_os = Column(ARRAY(String)) # windows_server, linux_ubuntu, linux_debian, linux_rhel
network_firewall_brand = Column(String(100))
# === SECTION 4: ENDPOINTS ===
employee_count = Column(String(20)) # Range: 1-10, 11-50, 51-100, 100+
computer_count = Column(String(20)) # Range: 1-10, 11-50, 51-100, 100+
desktop_os = Column(ARRAY(String)) # windows_10, windows_11, macos, linux
has_mdm = Column(Boolean, default=False)
mdm_solution = Column(String(50)) # intune, jamf, other
# === SECTION 5: SECURITY ===
antivirus_solution = Column(String(50)) # none, windows_defender, eset, kaspersky, other
has_edr = Column(Boolean, default=False)
edr_solution = Column(String(100)) # microsoft_defender_atp, crowdstrike, sentinelone, other
has_vpn = Column(Boolean, default=False)
vpn_solution = Column(String(50)) # ipsec, wireguard, openvpn, fortinet, other
has_mfa = Column(Boolean, default=False)
mfa_scope = Column(ARRAY(String)) # email, vpn, erp, all_apps
# === SECTION 6: BACKUP & DISASTER RECOVERY ===
backup_solution = Column(String(50)) # none, veeam, acronis, pbs, azure_backup, other
backup_targets = Column(ARRAY(String)) # local_nas, offsite, cloud, tape
backup_frequency = Column(String(20)) # daily, weekly, monthly, continuous
has_proxmox_pbs = Column(Boolean, default=False)
has_dr_plan = Column(Boolean, default=False)
# === SECTION 7: MONITORING ===
monitoring_solution = Column(String(50)) # none, zabbix, prtg, nagios, datadog, other
zabbix_integration = Column(JSONB) # {hostname: '', agent_installed: bool, templates: []}
# === SECTION 8: BUSINESS APPS ===
ticketing_system = Column(String(50)) # none, freshdesk, zendesk, jira_service, other
erp_system = Column(String(50)) # none, sap, microsoft_dynamics, enova, optima, other
crm_system = Column(String(50)) # none, salesforce, hubspot, pipedrive, other
document_management = Column(String(50)) # none, sharepoint, google_drive, dropbox, other
# === SECTION 9: ACTIVE DIRECTORY ===
has_local_ad = Column(Boolean, default=False)
ad_domain_name = Column(String(255))
has_ad_azure_sync = Column(Boolean, default=False) # Azure AD Connect / Cloud Sync
# === COLLABORATION FLAGS (for matching algorithm) ===
open_to_shared_licensing = Column(Boolean, default=False)
open_to_backup_replication = Column(Boolean, default=False)
open_to_teams_federation = Column(Boolean, default=False)
open_to_shared_monitoring = Column(Boolean, default=False)
open_to_collective_purchasing = Column(Boolean, default=False)
open_to_knowledge_sharing = Column(Boolean, default=False)
# === RAW DATA & METADATA ===
form_data = Column(JSONB) # Full form submission for reference
recommendations = Column(JSONB) # AI-generated recommendations
audit_errors = Column(Text) # Any errors during audit processing
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company = relationship('Company', backref='it_audits')
auditor = relationship('User', foreign_keys=[audited_by])
def __repr__(self):
return f'<ITAudit company_id={self.company_id} score={self.overall_score}>'
@property
def maturity_label(self):
"""Return Polish label for maturity level"""
labels = {
'basic': 'Podstawowy',
'developing': 'Rozwijający się',
'established': 'Ugruntowany',
'advanced': 'Zaawansowany'
}
return labels.get(self.maturity_level, 'Nieznany')
@property
def score_category(self):
"""Return score category: excellent, good, needs_work, poor"""
if self.overall_score is None:
return 'unknown'
if self.overall_score >= 80:
return 'excellent'
elif self.overall_score >= 60:
return 'good'
elif self.overall_score >= 40:
return 'needs_work'
else:
return 'poor'
@property
def collaboration_flags_count(self):
"""Count how many collaboration flags are enabled"""
flags = [
self.open_to_shared_licensing,
self.open_to_backup_replication,
self.open_to_teams_federation,
self.open_to_shared_monitoring,
self.open_to_collective_purchasing,
self.open_to_knowledge_sharing
]
return sum(1 for f in flags if f)
class ITCollaborationMatch(Base):
"""
IT collaboration matches between companies.
Stores potential collaboration opportunities discovered by the matching algorithm.
Match types: shared_licensing, backup_replication, teams_federation,
shared_monitoring, collective_purchasing, knowledge_sharing
"""
__tablename__ = 'it_collaboration_matches'
id = Column(Integer, primary_key=True)
company_a_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'),
nullable=False, index=True)
company_b_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'),
nullable=False, index=True)
# Match details
match_type = Column(String(50), nullable=False, index=True)
# Types: shared_licensing, backup_replication, teams_federation,
# shared_monitoring, collective_purchasing, knowledge_sharing
match_reason = Column(Text) # Human-readable explanation of why this is a match
match_score = Column(Integer) # 0-100 strength of the match
# Status: suggested, contacted, in_progress, completed, declined
status = Column(String(20), default='suggested', index=True)
# Shared attributes that led to this match (JSONB for flexibility)
# Example: {"m365_plans": ["E3", "E5"], "has_proxmox_pbs": true}
shared_attributes = Column(JSONB)
# Timestamps
created_at = Column(DateTime, default=datetime.now, index=True)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company_a = relationship('Company', foreign_keys=[company_a_id],
backref='collaboration_matches_as_a')
company_b = relationship('Company', foreign_keys=[company_b_id],
backref='collaboration_matches_as_b')
__table_args__ = (
UniqueConstraint('company_a_id', 'company_b_id', 'match_type',
name='uq_it_collab_match_pair_type'),
)
def __repr__(self):
return f'<ITCollaborationMatch {self.company_a_id}<->{self.company_b_id} type={self.match_type}>'
@property
def match_type_label(self):
"""Return Polish label for match type"""
labels = {
'shared_licensing': 'Współdzielone licencje',
'backup_replication': 'Replikacja backupów',
'teams_federation': 'Federacja Teams',
'shared_monitoring': 'Wspólny monitoring',
'collective_purchasing': 'Zakupy grupowe',
'knowledge_sharing': 'Wymiana wiedzy'
}
return labels.get(self.match_type, self.match_type)
@property
def status_label(self):
"""Return Polish label for status"""
labels = {
'suggested': 'Sugerowane',
'contacted': 'Skontaktowano',
'in_progress': 'W trakcie',
'completed': 'Zakończone',
'declined': 'Odrzucone'
}
return labels.get(self.status, self.status)
# ============================================================
# MEMBERSHIP FEES
# ============================================================
class MembershipFee(Base):
"""
Membership fee records for companies.
Tracks monthly payments from Norda Biznes members.
"""
__tablename__ = 'membership_fees'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Period identification
fee_year = Column(Integer, nullable=False) # e.g., 2026
fee_month = Column(Integer, nullable=False) # 1-12
# Fee details
amount = Column(Numeric(10, 2), nullable=False) # Amount due in PLN
amount_paid = Column(Numeric(10, 2), default=0) # Amount actually paid
# Payment status: pending, paid, partial, overdue, waived
status = Column(String(20), default='pending', index=True)
# Payment tracking
payment_date = Column(Date)
payment_method = Column(String(50)) # transfer, cash, card, other
payment_reference = Column(String(100)) # Bank transfer reference
# Admin tracking
recorded_by = Column(Integer, ForeignKey('users.id'))
recorded_at = Column(DateTime)
notes = Column(Text)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company = relationship('Company', backref='membership_fees')
recorded_by_user = relationship('User', foreign_keys=[recorded_by])
__table_args__ = (
UniqueConstraint('company_id', 'fee_year', 'fee_month', name='uq_company_fee_period'),
)
@property
def is_fully_paid(self):
return (self.amount_paid or 0) >= self.amount
@property
def outstanding_amount(self):
return max(0, float(self.amount) - float(self.amount_paid or 0))
class MembershipFeeConfig(Base):
"""
Configuration for membership fees.
Allows variable amounts per company or category.
"""
__tablename__ = 'membership_fee_config'
id = Column(Integer, primary_key=True)
# Scope: global, category, or company
scope = Column(String(20), nullable=False) # 'global', 'category', 'company'
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
company_id = Column(Integer, ForeignKey('companies.id'), nullable=True)
monthly_amount = Column(Numeric(10, 2), nullable=False)
valid_from = Column(Date, nullable=False)
valid_until = Column(Date) # NULL = currently active
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
notes = Column(Text)
# Relationships
category = relationship('Category')
company = relationship('Company')
# ============================================================
# ZIELONY OKRĘG PRZEMYSŁOWY KASZUBIA (ZOPK)
# ============================================================
class ZOPKProject(Base):
"""
Sub-projects within ZOPK initiative.
Examples: offshore wind, nuclear plant, data centers, hydrogen labs
"""
__tablename__ = 'zopk_projects'
id = Column(Integer, primary_key=True)
slug = Column(String(100), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
description = Column(Text)
# Project details
project_type = Column(String(50)) # energy, infrastructure, technology, defense
status = Column(String(50), default='planned') # planned, in_progress, completed
start_date = Column(Date)
end_date = Column(Date)
# Location info
location = Column(String(255))
region = Column(String(100)) # Wejherowo, Rumia, Gdynia, etc.
# Key metrics
estimated_investment = Column(Numeric(15, 2)) # Investment amount in PLN
estimated_jobs = Column(Integer) # Number of jobs created
# Visual
icon = Column(String(50)) # CSS icon class or emoji
color = Column(String(20)) # HEX color for badges
# Display order
sort_order = Column(Integer, default=0)
is_featured = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class ZOPKStakeholder(Base):
"""
Key people and organizations involved in ZOPK.
Politicians, coordinators, companies, institutions.
"""
__tablename__ = 'zopk_stakeholders'
id = Column(Integer, primary_key=True)
# Person or organization
stakeholder_type = Column(String(20), nullable=False) # person, organization
name = Column(String(255), nullable=False)
# Role and affiliation
role = Column(String(255)) # Koordynator, Minister, Starosta, etc.
organization = Column(String(255)) # MON, Starostwo Wejherowskie, etc.
# Contact (optional, public info only)
email = Column(String(255))
phone = Column(String(50))
website = Column(String(500))
# Social media
linkedin_url = Column(String(500))
twitter_url = Column(String(500))
# Photo/logo
photo_url = Column(String(500))
# Description
bio = Column(Text)
# Categorization
category = Column(String(50)) # government, local_authority, business, academic
importance = Column(Integer, default=0) # For sorting (higher = more important)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
project_links = relationship('ZOPKStakeholderProject', back_populates='stakeholder')
__table_args__ = (
UniqueConstraint('name', name='uq_zopk_stakeholder_name'),
)
class ZOPKStakeholderProject(Base):
"""Link table between stakeholders and projects"""
__tablename__ = 'zopk_stakeholder_projects'
id = Column(Integer, primary_key=True)
stakeholder_id = Column(Integer, ForeignKey('zopk_stakeholders.id', ondelete='CASCADE'), nullable=False)
project_id = Column(Integer, ForeignKey('zopk_projects.id', ondelete='CASCADE'), nullable=False)
role_in_project = Column(String(255)) # e.g., "Koordynator", "Inwestor"
created_at = Column(DateTime, default=datetime.now)
stakeholder = relationship('ZOPKStakeholder', back_populates='project_links')
project = relationship('ZOPKProject', backref='stakeholder_links')
__table_args__ = (
UniqueConstraint('stakeholder_id', 'project_id', name='uq_stakeholder_project'),
)
class ZOPKNews(Base):
"""
News articles about ZOPK with approval workflow.
Can be fetched automatically or added manually.
"""
__tablename__ = 'zopk_news'
id = Column(Integer, primary_key=True)
# Source information
title = Column(String(500), nullable=False)
description = Column(Text)
url = Column(String(1000), nullable=False)
source_name = Column(String(200)) # Portal name: trojmiasto.pl, etc.
source_domain = Column(String(200)) # Domain: trojmiasto.pl
# Article details
published_at = Column(DateTime)
author = Column(String(255))
image_url = Column(String(1000))
# Categorization
news_type = Column(String(50)) # news, announcement, interview, press_release
project_id = Column(Integer, ForeignKey('zopk_projects.id')) # Link to sub-project
# AI Analysis
relevance_score = Column(Numeric(3, 2)) # 0.00-1.00
sentiment = Column(String(20)) # positive, neutral, negative
ai_summary = Column(Text) # AI-generated summary
keywords = Column(StringArray) # Extracted keywords
# Cross-verification (multi-source confidence)
confidence_score = Column(Integer, default=1) # 1-5, increases with source confirmations
source_count = Column(Integer, default=1) # Number of sources that found this story
sources_list = Column(StringArray) # List of sources: ['brave', 'google_news', 'rss_trojmiasto']
title_hash = Column(String(64), index=True) # For fuzzy title matching (normalized)
is_auto_verified = Column(Boolean, default=False) # True if 3+ sources confirmed
# AI Relevance Evaluation (Gemini)
ai_relevant = Column(Boolean) # True = relevant to ZOPK, False = not relevant, NULL = not evaluated
ai_relevance_score = Column(Integer) # 1-5 stars: 1=weak match, 5=perfect match
ai_evaluation_reason = Column(Text) # AI's explanation of relevance decision
ai_evaluated_at = Column(DateTime) # When AI evaluation was performed
ai_model = Column(String(100)) # Which AI model was used (e.g., gemini-3-flash-preview)
# Moderation workflow
status = Column(String(20), default='pending', index=True) # pending, approved, rejected, auto_approved
moderated_by = Column(Integer, ForeignKey('users.id'))
moderated_at = Column(DateTime)
rejection_reason = Column(Text)
# Source tracking
source_type = Column(String(50), default='manual') # manual, brave_search, rss, scraper
fetch_job_id = Column(String(100)) # ID of the fetch job that found this
# Deduplication
url_hash = Column(String(64), unique=True, index=True) # SHA256 of URL
is_featured = Column(Boolean, default=False)
views_count = Column(Integer, default=0)
# Full content (scraped from source URL) - for knowledge extraction
full_content = Column(Text) # Full article text (without HTML, ads, navigation)
content_scraped_at = Column(DateTime) # When content was scraped
scrape_status = Column(String(20), default='pending', index=True) # pending, scraped, failed, skipped
scrape_error = Column(Text) # Error message if scraping failed
scrape_attempts = Column(Integer, default=0) # Number of scraping attempts
content_word_count = Column(Integer) # Word count of scraped content
content_language = Column(String(10), default='pl') # pl, en
# Knowledge extraction status
knowledge_extracted = Column(Boolean, default=False, index=True) # True if chunks/facts/entities extracted
knowledge_extracted_at = Column(DateTime) # When knowledge was extracted
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
project = relationship('ZOPKProject', backref='news_items')
moderator = relationship('User', foreign_keys=[moderated_by])
class ZOPKResource(Base):
"""
Resources: documents, links, images, videos related to ZOPK.
Knowledge base materials.
"""
__tablename__ = 'zopk_resources'
id = Column(Integer, primary_key=True)
# Resource identification
title = Column(String(255), nullable=False)
description = Column(Text)
# Resource type
resource_type = Column(String(50), nullable=False) # link, document, image, video, map
# URL or file path
url = Column(String(1000))
file_path = Column(String(500)) # For uploaded files
file_size = Column(Integer)
mime_type = Column(String(100))
# Thumbnail
thumbnail_url = Column(String(1000))
# Categorization
category = Column(String(50)) # official, media, research, presentation
project_id = Column(Integer, ForeignKey('zopk_projects.id'))
# Tags for search
tags = Column(StringArray)
# Source
source_name = Column(String(255))
source_date = Column(Date)
# Moderation
status = Column(String(20), default='approved', index=True) # pending, approved, rejected
uploaded_by = Column(Integer, ForeignKey('users.id'))
is_featured = Column(Boolean, default=False)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
project = relationship('ZOPKProject', backref='resources')
uploader = relationship('User', foreign_keys=[uploaded_by])
class ZOPKCompanyLink(Base):
"""
Links between ZOPK projects and Norda Biznes member companies.
Shows which local companies can benefit or collaborate.
"""
__tablename__ = 'zopk_company_links'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
project_id = Column(Integer, ForeignKey('zopk_projects.id', ondelete='CASCADE'), nullable=False)
# Type of involvement
link_type = Column(String(50), nullable=False) # potential_supplier, partner, investor, beneficiary
# Description of potential collaboration
collaboration_description = Column(Text)
# Relevance scoring
relevance_score = Column(Integer) # 1-100
# Status
status = Column(String(20), default='suggested') # suggested, confirmed, active, completed
# Admin notes
admin_notes = Column(Text)
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company = relationship('Company', backref='zopk_project_links')
project = relationship('ZOPKProject', backref='company_links')
creator = relationship('User', foreign_keys=[created_by])
__table_args__ = (
UniqueConstraint('company_id', 'project_id', 'link_type', name='uq_company_project_link'),
)
class ZOPKNewsFetchJob(Base):
"""
Tracking for automated news fetch jobs.
Records when and what was searched.
"""
__tablename__ = 'zopk_news_fetch_jobs'
id = Column(Integer, primary_key=True)
job_id = Column(String(100), unique=True, nullable=False, index=True)
# Job configuration
search_query = Column(String(500))
search_api = Column(String(50)) # brave, google, bing
date_range_start = Column(Date)
date_range_end = Column(Date)
# Results
results_found = Column(Integer, default=0)
results_new = Column(Integer, default=0) # New (not duplicates)
results_approved = Column(Integer, default=0)
# Status
status = Column(String(20), default='pending') # pending, running, completed, failed
error_message = Column(Text)
# Timing
started_at = Column(DateTime)
completed_at = Column(DateTime)
# Trigger
triggered_by = Column(String(50)) # cron, manual, admin
triggered_by_user = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
# Relationships
user = relationship('User', foreign_keys=[triggered_by_user])
# ============================================================
# ZOPK KNOWLEDGE BASE (AI-powered, with pgvector)
# ============================================================
class ZOPKKnowledgeChunk(Base):
"""
Knowledge chunks extracted from approved ZOPK news articles.
Each chunk is a semantically coherent piece of text with embedding vector
for similarity search (RAG - Retrieval Augmented Generation).
Best practices:
- Chunk size: 500-1000 tokens with ~100 token overlap
- Embedding model: text-embedding-004 (768 dimensions)
"""
__tablename__ = 'zopk_knowledge_chunks'
id = Column(Integer, primary_key=True)
# Source tracking
source_news_id = Column(Integer, ForeignKey('zopk_news.id'), nullable=False, index=True)
# Chunk content
content = Column(Text, nullable=False) # The actual text chunk
content_clean = Column(Text) # Cleaned/normalized version for processing
chunk_index = Column(Integer) # Position in the original article (0, 1, 2...)
token_count = Column(Integer) # Approximate token count
# Semantic embedding (pgvector)
# Using 768 dimensions for Google text-embedding-004
# Will be stored as: embedding vector(768)
embedding = Column(Text) # Stored as JSON string, converted to vector for queries
# AI-extracted metadata
chunk_type = Column(String(50)) # narrative, fact, quote, statistic, event, definition
summary = Column(Text) # 1-2 sentence summary
keywords = Column(PG_ARRAY(String(100)) if not IS_SQLITE else Text) # Extracted keywords
language = Column(String(10), default='pl') # pl, en
# Context information
context_date = Column(Date) # Date the information refers to (not article date)
context_location = Column(String(255)) # Geographic location if mentioned
# Quality & relevance
importance_score = Column(Integer) # 1-5, how important this information is
confidence_score = Column(Numeric(3, 2)) # 0.00-1.00, AI confidence in extraction
# Moderation
is_verified = Column(Boolean, default=False) # Human verified
verified_by = Column(Integer, ForeignKey('users.id'))
verified_at = Column(DateTime)
# Processing metadata
extraction_model = Column(String(100)) # gemini-3-flash-preview, gpt-4, etc.
extracted_at = Column(DateTime, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
source_news = relationship('ZOPKNews', backref='knowledge_chunks')
verifier = relationship('User', foreign_keys=[verified_by])
class ZOPKKnowledgeEntity(Base):
"""
Named entities extracted from ZOPK knowledge base.
Entities are deduplicated and enriched across all sources.
Types: company, person, place, organization, project, technology
"""
__tablename__ = 'zopk_knowledge_entities'
id = Column(Integer, primary_key=True)
# Entity identification
entity_type = Column(String(50), nullable=False, index=True)
name = Column(String(255), nullable=False)
normalized_name = Column(String(255), index=True) # Lowercase, no special chars (for dedup)
aliases = Column(PG_ARRAY(String(255)) if not IS_SQLITE else Text) # Alternative names
# Description
description = Column(Text) # AI-generated description
short_description = Column(String(500)) # One-liner
# Linking to existing data
company_id = Column(Integer, ForeignKey('companies.id')) # Link to Norda company if exists
zopk_project_id = Column(Integer, ForeignKey('zopk_projects.id')) # Link to ZOPK project
external_url = Column(String(1000)) # Wikipedia, company website, etc.
# Entity metadata (JSONB for flexibility)
# Note: 'metadata' is reserved in SQLAlchemy, using 'entity_metadata'
entity_metadata = Column(PG_JSONB if not IS_SQLITE else Text) # {role: "CEO", founded: 2020, ...}
# Statistics
mentions_count = Column(Integer, default=0)
first_mentioned_at = Column(DateTime)
last_mentioned_at = Column(DateTime)
# Embedding for entity similarity
embedding = Column(Text) # Entity description embedding
# Quality
is_verified = Column(Boolean, default=False)
merged_into_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id')) # For deduplication
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company = relationship('Company', foreign_keys=[company_id])
zopk_project = relationship('ZOPKProject', foreign_keys=[zopk_project_id])
merged_into = relationship('ZOPKKnowledgeEntity', remote_side=[id], foreign_keys=[merged_into_id])
class ZOPKKnowledgeFact(Base):
"""
Structured facts extracted from knowledge chunks.
Facts are atomic, verifiable pieces of information.
Examples:
- "ZOPK otrzymał 500 mln PLN dofinansowania w 2024"
- "Port Gdynia jest głównym partnerem projektu"
- "Projekt zakłada utworzenie 5000 miejsc pracy"
"""
__tablename__ = 'zopk_knowledge_facts'
id = Column(Integer, primary_key=True)
# Source
source_chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id'), nullable=False, index=True)
source_news_id = Column(Integer, ForeignKey('zopk_news.id'), index=True)
# Fact content
fact_type = Column(String(50), nullable=False) # statistic, event, statement, decision, milestone
subject = Column(String(255)) # Who/what the fact is about
predicate = Column(String(100)) # Action/relation type
object = Column(Text) # The actual information
full_text = Column(Text, nullable=False) # Complete fact as sentence
# Structured data (for queryable facts)
numeric_value = Column(Numeric(20, 2)) # If fact contains number
numeric_unit = Column(String(50)) # PLN, EUR, jobs, MW, etc.
date_value = Column(Date) # If fact refers to specific date
# Context
context = Column(Text) # Surrounding context for disambiguation
citation = Column(Text) # Original quote if applicable
# Entities involved (denormalized for quick access)
entities_involved = Column(PG_JSONB if not IS_SQLITE else Text) # [{id: 1, name: "...", type: "company"}, ...]
# Quality & verification
confidence_score = Column(Numeric(3, 2)) # AI confidence
is_verified = Column(Boolean, default=False)
contradicts_fact_id = Column(Integer, ForeignKey('zopk_knowledge_facts.id')) # If contradicted
# Embedding for fact similarity
embedding = Column(Text)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
source_chunk = relationship('ZOPKKnowledgeChunk', backref='facts')
source_news = relationship('ZOPKNews', backref='facts')
contradicted_by = relationship('ZOPKKnowledgeFact', remote_side=[id], foreign_keys=[contradicts_fact_id])
class ZOPKKnowledgeEntityMention(Base):
"""
Links between knowledge chunks and entities.
Tracks where each entity is mentioned and in what context.
"""
__tablename__ = 'zopk_knowledge_entity_mentions'
id = Column(Integer, primary_key=True)
chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id'), nullable=False, index=True)
entity_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True)
# Mention details
mention_text = Column(String(500)) # Exact text that matched the entity
mention_type = Column(String(50)) # direct, reference, pronoun
start_position = Column(Integer) # Character position in chunk
end_position = Column(Integer)
# Context
sentiment = Column(String(20)) # positive, neutral, negative
role_in_context = Column(String(100)) # subject, object, beneficiary, partner
confidence = Column(Numeric(3, 2)) # Entity linking confidence
created_at = Column(DateTime, default=datetime.now)
# Relationships
chunk = relationship('ZOPKKnowledgeChunk', backref='entity_mentions')
entity = relationship('ZOPKKnowledgeEntity', backref='mentions')
__table_args__ = (
UniqueConstraint('chunk_id', 'entity_id', 'start_position', name='uq_chunk_entity_position'),
)
class ZOPKKnowledgeRelation(Base):
"""
Relationships between entities discovered in the knowledge base.
Forms a knowledge graph of ZOPK ecosystem.
Examples:
- Company A → "partner" → Company B
- Person X → "CEO of" → Company Y
- Project Z → "funded by" → Organization W
"""
__tablename__ = 'zopk_knowledge_relations'
id = Column(Integer, primary_key=True)
# Entities involved
entity_a_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True)
entity_b_id = Column(Integer, ForeignKey('zopk_knowledge_entities.id'), nullable=False, index=True)
# Relation definition
relation_type = Column(String(100), nullable=False) # partner, investor, supplier, competitor, subsidiary, employs
relation_subtype = Column(String(100)) # More specific: strategic_partner, minority_investor
is_bidirectional = Column(Boolean, default=False) # True for "partners", False for "invests in"
# Evidence
source_chunk_id = Column(Integer, ForeignKey('zopk_knowledge_chunks.id'))
source_fact_id = Column(Integer, ForeignKey('zopk_knowledge_facts.id'))
evidence_text = Column(Text) # Quote proving the relation
# Temporal aspects
valid_from = Column(Date) # When relation started
valid_until = Column(Date) # When relation ended (NULL = still valid)
is_current = Column(Boolean, default=True)
# Strength & confidence
strength = Column(Integer) # 1-5, how strong the relation is
confidence = Column(Numeric(3, 2)) # AI confidence in the relation
mention_count = Column(Integer, default=1) # How many times this relation was found
# Quality
is_verified = Column(Boolean, default=False)
verified_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
entity_a = relationship('ZOPKKnowledgeEntity', foreign_keys=[entity_a_id], backref='relations_as_subject')
entity_b = relationship('ZOPKKnowledgeEntity', foreign_keys=[entity_b_id], backref='relations_as_object')
source_chunk = relationship('ZOPKKnowledgeChunk', backref='discovered_relations')
source_fact = relationship('ZOPKKnowledgeFact', backref='relation_evidence')
verifier = relationship('User', foreign_keys=[verified_by])
__table_args__ = (
UniqueConstraint('entity_a_id', 'entity_b_id', 'relation_type', name='uq_entity_relation'),
)
class ZOPKKnowledgeExtractionJob(Base):
"""
Tracks knowledge extraction jobs from approved articles.
One job per article, tracks progress and results.
"""
__tablename__ = 'zopk_knowledge_extraction_jobs'
id = Column(Integer, primary_key=True)
job_id = Column(String(100), unique=True, nullable=False, index=True)
# Source
news_id = Column(Integer, ForeignKey('zopk_news.id'), nullable=False, index=True)
# Configuration
extraction_model = Column(String(100)) # gemini-3-flash-preview
chunk_size = Column(Integer, default=800) # Target tokens per chunk
chunk_overlap = Column(Integer, default=100) # Overlap tokens
# Results
chunks_created = Column(Integer, default=0)
entities_extracted = Column(Integer, default=0)
facts_extracted = Column(Integer, default=0)
relations_discovered = Column(Integer, default=0)
# Costs
tokens_used = Column(Integer, default=0)
cost_cents = Column(Numeric(10, 4), default=0)
# Status
status = Column(String(20), default='pending') # pending, running, completed, failed
error_message = Column(Text)
progress_percent = Column(Integer, default=0)
# Timing
started_at = Column(DateTime)
completed_at = Column(DateTime)
# Trigger
triggered_by = Column(String(50)) # auto (on approval), manual, batch
triggered_by_user = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
# Relationships
news = relationship('ZOPKNews', backref='extraction_jobs')
user = relationship('User', foreign_keys=[triggered_by_user])
# ============================================================
# AI USAGE TRACKING MODELS
# ============================================================
class AIUsageLog(Base):
"""
Individual AI API call logs.
Tracks tokens, costs, and performance for each Gemini API request.
"""
__tablename__ = 'ai_usage_logs'
id = Column(Integer, primary_key=True)
# Request info
request_type = Column(String(50), nullable=False) # chat, news_evaluation, user_creation, image_analysis
model = Column(String(100), nullable=False) # gemini-3-flash-preview, gemini-3-pro-preview, etc.
# Token counts
tokens_input = Column(Integer, default=0)
tokens_output = Column(Integer, default=0)
# Note: tokens_total is a generated column in PostgreSQL
# Cost (in USD cents for precision)
cost_cents = Column(Numeric(10, 4), default=0)
# Context
user_id = Column(Integer, ForeignKey('users.id'))
company_id = Column(Integer, ForeignKey('companies.id'))
related_entity_type = Column(String(50)) # zopk_news, chat_message, company, etc.
related_entity_id = Column(Integer)
# Request details
prompt_length = Column(Integer)
response_length = Column(Integer)
response_time_ms = Column(Integer) # How long the API call took
# Status
success = Column(Boolean, default=True)
error_message = Column(Text)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
# Relationships
user = relationship('User', foreign_keys=[user_id])
company = relationship('Company', foreign_keys=[company_id])
class AIUsageDaily(Base):
"""
Pre-aggregated daily AI usage statistics.
Auto-updated by PostgreSQL trigger on ai_usage_logs insert.
"""
__tablename__ = 'ai_usage_daily'
id = Column(Integer, primary_key=True)
date = Column(Date, unique=True, nullable=False)
# Request counts by type
chat_requests = Column(Integer, default=0)
news_evaluation_requests = Column(Integer, default=0)
user_creation_requests = Column(Integer, default=0)
image_analysis_requests = Column(Integer, default=0)
other_requests = Column(Integer, default=0)
total_requests = Column(Integer, default=0)
# Token totals
total_tokens_input = Column(Integer, default=0)
total_tokens_output = Column(Integer, default=0)
total_tokens = Column(Integer, default=0)
# Cost (in USD cents)
total_cost_cents = Column(Numeric(10, 4), default=0)
# Performance
avg_response_time_ms = Column(Integer)
error_count = Column(Integer, default=0)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class AIRateLimit(Base):
"""
Rate limit tracking for AI API quota management.
"""
__tablename__ = 'ai_rate_limits'
id = Column(Integer, primary_key=True)
# Limit type
limit_type = Column(String(50), nullable=False) # daily, hourly, per_minute
limit_scope = Column(String(50), nullable=False) # global, user, ip
scope_identifier = Column(String(255)) # user_id, ip address, or NULL for global
# Limits
max_requests = Column(Integer, nullable=False)
current_requests = Column(Integer, default=0)
# Reset
reset_at = Column(DateTime, nullable=False)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
__table_args__ = (
UniqueConstraint('limit_type', 'limit_scope', 'scope_identifier', name='uq_rate_limit'),
)
# ============================================================
# KRS DATA - OSOBY POWIĄZANE Z FIRMAMI
# ============================================================
class Person(Base):
"""
Osoby powiązane z firmami (zarząd, wspólnicy, prokurenci).
Dane pobierane z odpisów KRS.
"""
__tablename__ = 'people'
id = Column(Integer, primary_key=True)
pesel = Column(String(11), unique=True, nullable=True) # NULL dla osób prawnych
imiona = Column(String(255), nullable=False)
nazwisko = Column(String(255), nullable=False)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company_roles = relationship('CompanyPerson', back_populates='person')
def full_name(self):
return f"{self.imiona} {self.nazwisko}"
def __repr__(self):
return f"<Person {self.full_name()}>"
class CompanyPerson(Base):
"""
Relacja osoba-firma (zarząd, wspólnicy, prokurenci).
Umożliwia śledzenie powiązań między osobami a firmami.
"""
__tablename__ = 'company_people'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False)
person_id = Column(Integer, ForeignKey('people.id', ondelete='CASCADE'), nullable=False)
# Rola w firmie
role = Column(String(50), nullable=False) # PREZES ZARZĄDU, CZŁONEK ZARZĄDU, WSPÓLNIK
role_category = Column(String(20), nullable=False) # zarzad, wspolnik, prokurent
# Dane dodatkowe (dla wspólników)
shares_count = Column(Integer)
shares_value = Column(Numeric(12, 2))
shares_percent = Column(Numeric(5, 2))
# Źródło danych
source = Column(String(100), default='ekrs.ms.gov.pl')
source_document = Column(String(255)) # np. "odpis_pelny_0000725183.pdf"
fetched_at = Column(DateTime)
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
company = relationship('Company', backref='people_roles')
person = relationship('Person', back_populates='company_roles')
__table_args__ = (
UniqueConstraint('company_id', 'person_id', 'role_category', 'role', name='uq_company_person_role'),
)
def __repr__(self):
return f"<CompanyPerson {self.person.full_name()} - {self.role} @ {self.company.name}>"
# ============================================================
# KRS AUDIT
# ============================================================
class KRSAudit(Base):
"""
KRS audit history - tracks PDF downloads and data extraction.
Each audit represents one extraction run from EKRS.
"""
__tablename__ = 'krs_audits'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
# Audit timing
audit_date = Column(DateTime, default=datetime.now, nullable=False, index=True)
# PDF source info
pdf_filename = Column(String(255)) # np. "odpis_pelny_0000882964.pdf"
pdf_path = Column(Text) # full path to stored PDF
pdf_downloaded_at = Column(DateTime)
# Extraction status
status = Column(String(20), default='pending', index=True) # pending, downloading, parsing, completed, error
progress_percent = Column(Integer, default=0)
progress_message = Column(Text)
error_message = Column(Text)
# Extracted data summary
extracted_krs = Column(String(10))
extracted_nazwa = Column(Text)
extracted_nip = Column(String(10))
extracted_regon = Column(String(14))
extracted_forma_prawna = Column(String(255))
extracted_data_rejestracji = Column(Date)
extracted_kapital_zakladowy = Column(Numeric(15, 2))
extracted_waluta = Column(String(3), default='PLN')
extracted_liczba_udzialow = Column(Integer)
extracted_sposob_reprezentacji = Column(Text)
# Counts for quick stats
zarzad_count = Column(Integer, default=0)
wspolnicy_count = Column(Integer, default=0)
prokurenci_count = Column(Integer, default=0)
pkd_count = Column(Integer, default=0)
# Full parsed data as JSON
parsed_data = Column(JSONB)
# Audit metadata
audit_version = Column(String(20), default='1.0')
audit_source = Column(String(50), default='ekrs.ms.gov.pl')
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = relationship('Company', backref='krs_audits')
def __repr__(self):
return f'<KRSAudit company_id={self.company_id} status={self.status}>'
@property
def status_label(self):
"""Human-readable status label in Polish"""
labels = {
'pending': 'Oczekuje',
'downloading': 'Pobieranie PDF',
'parsing': 'Przetwarzanie',
'completed': 'Ukończony',
'error': 'Błąd'
}
return labels.get(self.status, self.status)
class CompanyPKD(Base):
"""
PKD codes for companies (Polska Klasyfikacja Działalności).
Multiple PKD codes per company allowed - one is marked as primary.
"""
__tablename__ = 'company_pkd'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
pkd_code = Column(String(10), nullable=False, index=True) # np. "62.03.Z"
pkd_description = Column(Text)
is_primary = Column(Boolean, default=False) # przeważający PKD
source = Column(String(50), default='ekrs') # ekrs, ceidg
# Timestamps
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationship
company = relationship('Company', backref='pkd_codes')
__table_args__ = (
UniqueConstraint('company_id', 'pkd_code', name='uq_company_pkd'),
)
def __repr__(self):
primary = ' (główny)' if self.is_primary else ''
return f'<CompanyPKD {self.pkd_code}{primary}>'
class CompanyFinancialReport(Base):
"""
Financial reports (sprawozdania finansowe) filed with KRS.
Tracks report periods and filing dates.
"""
__tablename__ = 'company_financial_reports'
id = Column(Integer, primary_key=True)
company_id = Column(Integer, ForeignKey('companies.id', ondelete='CASCADE'), nullable=False, index=True)
period_start = Column(Date)
period_end = Column(Date)
filed_at = Column(Date)
report_type = Column(String(50), default='annual') # annual, quarterly
source = Column(String(50), default='ekrs')
# Timestamps
created_at = Column(DateTime, default=datetime.now)
# Relationship
company = relationship('Company', backref='financial_reports')
__table_args__ = (
UniqueConstraint('company_id', 'period_start', 'period_end', 'report_type', name='uq_company_financial_report'),
)
def __repr__(self):
return f'<CompanyFinancialReport {self.period_start} - {self.period_end}>'
# ============================================================
# USER BLOCKS - BLOKOWANIE UŻYTKOWNIKÓW
# ============================================================
class UserBlock(Base):
"""
Blokowanie użytkowników - zablokowany użytkownik nie może wysyłać wiadomości.
"""
__tablename__ = 'user_blocks'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
blocked_user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
created_at = Column(DateTime, default=datetime.now)
reason = Column(Text, nullable=True) # optional reason
# Relationships
user = relationship('User', foreign_keys=[user_id], backref='blocks_created')
blocked_user = relationship('User', foreign_keys=[blocked_user_id], backref='blocked_by')
def __repr__(self):
return f'<UserBlock {self.user_id} -> {self.blocked_user_id}>'
# ============================================================
# USER ANALYTICS - SESJE I AKTYWNOŚĆ
# ============================================================
class UserSession(Base):
"""
Sesje użytkowników portalu.
Śledzi czas trwania sesji, urządzenie, lokalizację i aktywność.
"""
__tablename__ = 'user_sessions'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
session_id = Column(String(100), unique=True, nullable=False, index=True)
# Czas sesji
started_at = Column(DateTime, nullable=False, default=datetime.now)
ended_at = Column(DateTime, nullable=True)
last_activity_at = Column(DateTime, nullable=False, default=datetime.now)
duration_seconds = Column(Integer, nullable=True)
# Urządzenie
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
device_type = Column(String(20), nullable=True) # desktop, mobile, tablet
browser = Column(String(50), nullable=True)
browser_version = Column(String(20), nullable=True)
os = Column(String(50), nullable=True)
os_version = Column(String(20), nullable=True)
# Lokalizacja (z IP)
country = Column(String(100), nullable=True)
city = Column(String(100), nullable=True)
region = Column(String(100), nullable=True)
# Metryki sesji
page_views_count = Column(Integer, default=0)
clicks_count = Column(Integer, default=0)
# UTM Parameters (kampanie marketingowe)
utm_source = Column(String(255), nullable=True) # google, facebook, newsletter
utm_medium = Column(String(255), nullable=True) # cpc, email, social, organic
utm_campaign = Column(String(255), nullable=True) # nazwa kampanii
utm_term = Column(String(255), nullable=True) # słowo kluczowe (PPC)
utm_content = Column(String(255), nullable=True) # wariant reklamy
created_at = Column(DateTime, default=datetime.now)
# Relationships
user = relationship('User', backref='analytics_sessions')
page_views = relationship('PageView', back_populates='session', cascade='all, delete-orphan')
def __repr__(self):
return f"<UserSession {self.session_id[:8]}... user={self.user_id}>"
class PageView(Base):
"""
Wyświetlenia stron przez użytkowników.
Śledzi odwiedzone strony i czas spędzony na każdej z nich.
"""
__tablename__ = 'page_views'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Strona
url = Column(String(2000), nullable=False)
path = Column(String(500), nullable=False, index=True)
page_title = Column(String(500), nullable=True)
referrer = Column(String(2000), nullable=True)
# Czas
viewed_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
time_on_page_seconds = Column(Integer, nullable=True)
# Scroll depth (%)
scroll_depth_percent = Column(Integer, nullable=True) # 0-100
# Performance metrics (Web Vitals)
dom_content_loaded_ms = Column(Integer, nullable=True)
load_time_ms = Column(Integer, nullable=True)
first_paint_ms = Column(Integer, nullable=True)
first_contentful_paint_ms = Column(Integer, nullable=True)
# Kontekst
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
created_at = Column(DateTime, default=datetime.now)
# Relationships
session = relationship('UserSession', back_populates='page_views')
clicks = relationship('UserClick', back_populates='page_view', cascade='all, delete-orphan')
def __repr__(self):
return f"<PageView {self.path}>"
class UserClick(Base):
"""
Kliknięcia elementów na stronach.
Śledzi interakcje użytkowników z elementami UI.
"""
__tablename__ = 'user_clicks'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='CASCADE'), nullable=True)
page_view_id = Column(Integer, ForeignKey('page_views.id', ondelete='CASCADE'), nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Element kliknięty
element_type = Column(String(50), nullable=True) # button, link, card, nav, form
element_id = Column(String(100), nullable=True)
element_text = Column(String(255), nullable=True)
element_class = Column(String(500), nullable=True)
target_url = Column(String(2000), nullable=True)
# Pozycja kliknięcia
x_position = Column(Integer, nullable=True)
y_position = Column(Integer, nullable=True)
clicked_at = Column(DateTime, nullable=False, default=datetime.now, index=True)
# Relationships
page_view = relationship('PageView', back_populates='clicks')
def __repr__(self):
return f"<UserClick {self.element_type} at {self.clicked_at}>"
class AnalyticsDaily(Base):
"""
Dzienne statystyki agregowane.
Automatycznie aktualizowane przez trigger PostgreSQL.
"""
__tablename__ = 'analytics_daily'
id = Column(Integer, primary_key=True)
date = Column(Date, unique=True, nullable=False, index=True)
# Sesje
total_sessions = Column(Integer, default=0)
unique_users = Column(Integer, default=0)
new_users = Column(Integer, default=0)
returning_users = Column(Integer, default=0)
anonymous_sessions = Column(Integer, default=0)
# Aktywność
total_page_views = Column(Integer, default=0)
total_clicks = Column(Integer, default=0)
avg_session_duration_seconds = Column(Integer, nullable=True)
avg_pages_per_session = Column(Numeric(5, 2), nullable=True)
# Urządzenia
desktop_sessions = Column(Integer, default=0)
mobile_sessions = Column(Integer, default=0)
tablet_sessions = Column(Integer, default=0)
# Engagement
bounce_rate = Column(Numeric(5, 2), nullable=True)
# Nowe metryki (Analytics Expansion 2026-01-30)
conversions_count = Column(Integer, default=0)
searches_count = Column(Integer, default=0)
searches_no_results = Column(Integer, default=0)
avg_scroll_depth = Column(Numeric(5, 2), nullable=True)
js_errors_count = Column(Integer, default=0)
# Rozkłady (JSONB)
utm_breakdown = Column(JSONBType, nullable=True) # {"google": 10, "facebook": 5}
conversions_breakdown = Column(JSONBType, nullable=True) # {"register": 2, "contact_click": 15}
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f"<AnalyticsDaily {self.date}>"
class PopularPagesDaily(Base):
"""
Popularne strony - dzienne agregaty.
"""
__tablename__ = 'popular_pages_daily'
id = Column(Integer, primary_key=True)
date = Column(Date, nullable=False, index=True)
path = Column(String(500), nullable=False)
page_title = Column(String(500), nullable=True)
views_count = Column(Integer, default=0)
unique_visitors = Column(Integer, default=0)
avg_time_seconds = Column(Integer, nullable=True)
__table_args__ = (
UniqueConstraint('date', 'path', name='uq_popular_pages_date_path'),
)
def __repr__(self):
return f"<PopularPagesDaily {self.date} {self.path}>"
class SearchQuery(Base):
"""
Historia wyszukiwań użytkowników w portalu.
Śledzi zapytania, wyniki i interakcje.
Created: 2026-01-30
"""
__tablename__ = 'search_queries'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Zapytanie
query = Column(String(500), nullable=False)
query_normalized = Column(String(500), nullable=True) # lowercase, bez znaków specjalnych
# Wyniki
results_count = Column(Integer, default=0)
has_results = Column(Boolean, default=True)
# Interakcja z wynikami
clicked_result_position = Column(Integer, nullable=True) # 1-based
clicked_company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
# Kontekst
search_type = Column(String(50), default='main') # main, chat, autocomplete
filters_used = Column(JSONBType, nullable=True) # {"category": "IT", "city": "Wejherowo"}
# Timing
searched_at = Column(DateTime, nullable=False, default=datetime.now)
time_to_click_ms = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.now)
def __repr__(self):
return f"<SearchQuery '{self.query[:30]}...' results={self.results_count}>"
class ConversionEvent(Base):
"""
Kluczowe konwersje: rejestracje, kontakty z firmami, RSVP na wydarzenia.
Created: 2026-01-30
"""
__tablename__ = 'conversion_events'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Typ konwersji
event_type = Column(String(50), nullable=False) # register, login, contact_click, rsvp, message, classified
event_category = Column(String(50), nullable=True) # engagement, acquisition, activation
# Kontekst
company_id = Column(Integer, ForeignKey('companies.id', ondelete='SET NULL'), nullable=True)
target_type = Column(String(50), nullable=True) # email, phone, website, rsvp_event
target_value = Column(String(500), nullable=True)
# Źródło konwersji
source_page = Column(String(500), nullable=True)
referrer = Column(String(500), nullable=True)
# Dodatkowe dane
event_metadata = Column(JSONBType, nullable=True)
# Timing
converted_at = Column(DateTime, nullable=False, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
# Relationships
company = relationship('Company', backref='conversion_events')
def __repr__(self):
return f"<ConversionEvent {self.event_type} at {self.converted_at}>"
class JSError(Base):
"""
Błędy JavaScript zgłaszane z przeglądarek użytkowników.
Created: 2026-01-30
"""
__tablename__ = 'js_errors'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey('user_sessions.id', ondelete='SET NULL'), nullable=True)
# Błąd
message = Column(Text, nullable=False)
source = Column(String(500), nullable=True) # URL pliku JS
lineno = Column(Integer, nullable=True)
colno = Column(Integer, nullable=True)
stack = Column(Text, nullable=True)
# Kontekst
url = Column(String(2000), nullable=True)
user_agent = Column(String(500), nullable=True)
# Agregacja
error_hash = Column(String(64), nullable=True) # SHA256 dla grupowania
occurred_at = Column(DateTime, nullable=False, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
def __repr__(self):
return f"<JSError '{self.message[:50]}...'>"
class PopularSearchesDaily(Base):
"""
Popularne wyszukiwania - dzienne agregaty.
Created: 2026-01-30
"""
__tablename__ = 'popular_searches_daily'
id = Column(Integer, primary_key=True)
date = Column(Date, nullable=False, index=True)
query_normalized = Column(String(500), nullable=False)
search_count = Column(Integer, default=0)
unique_users = Column(Integer, default=0)
click_count = Column(Integer, default=0)
avg_results_count = Column(Numeric(10, 2), nullable=True)
__table_args__ = (
UniqueConstraint('date', 'query_normalized', name='uq_popular_searches_date_query'),
)
def __repr__(self):
return f"<PopularSearchesDaily {self.date} '{self.query_normalized}'>"
class HourlyActivity(Base):
"""
Aktywność wg godziny - dla analizy wzorców czasowych.
Created: 2026-01-30
"""
__tablename__ = 'hourly_activity'
id = Column(Integer, primary_key=True)
date = Column(Date, nullable=False, index=True)
hour = Column(Integer, nullable=False) # 0-23
sessions_count = Column(Integer, default=0)
page_views_count = Column(Integer, default=0)
unique_users = Column(Integer, default=0)
__table_args__ = (
UniqueConstraint('date', 'hour', name='uq_hourly_activity_date_hour'),
)
def __repr__(self):
return f"<HourlyActivity {self.date} {self.hour}:00>"
# ============================================================
# EMAIL LOGGING
# ============================================================
class EmailLog(Base):
"""
Log wszystkich wysłanych emaili systemowych.
Śledzi:
- Emaile rejestracyjne (weryfikacja)
- Emaile resetowania hasła
- Powiadomienia systemowe
- Status dostarczenia
Created: 2026-01-14
"""
__tablename__ = 'email_logs'
id = Column(Integer, primary_key=True)
# Dane emaila
email_type = Column(String(50), nullable=False, index=True) # welcome, password_reset, notification
recipient_email = Column(String(255), nullable=False, index=True)
recipient_name = Column(String(255), nullable=True)
subject = Column(String(500), nullable=False)
# Powiązanie z użytkownikiem (opcjonalne)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
# Status
status = Column(String(20), default='pending', index=True) # pending, sent, failed
error_message = Column(Text, nullable=True)
# Metadane
sender_email = Column(String(255), nullable=True)
ip_address = Column(String(45), nullable=True) # IP requestu (jeśli dostępne)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
sent_at = Column(DateTime, nullable=True)
# Relacje
user = relationship('User', backref='email_logs')
def __repr__(self):
return f"<EmailLog {self.id} {self.email_type} -> {self.recipient_email} ({self.status})>"
# ============================================================
# SECURITY & AUDIT
# ============================================================
class AuditLog(Base):
"""
Audit log dla śledzenia działań administracyjnych.
Śledzi wszystkie wrażliwe operacje wykonywane przez adminów:
- Moderacja newsów (approve/reject)
- Zmiany składek członkowskich
- Edycja profili firm
- Zmiany uprawnień użytkowników
- Operacje na wydarzeniach
Created: 2026-01-14
"""
__tablename__ = 'audit_logs'
id = Column(Integer, primary_key=True)
# Kto wykonał akcję
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
user_email = Column(String(255), nullable=False) # Zachowane nawet po usunięciu usera
# Co zostało wykonane
action = Column(String(100), nullable=False, index=True) # np. 'news.approve', 'company.edit', 'user.ban'
entity_type = Column(String(50), nullable=False, index=True) # np. 'news', 'company', 'user', 'event'
entity_id = Column(Integer, nullable=True) # ID encji której dotyczy akcja
entity_name = Column(String(255), nullable=True) # Nazwa encji (dla czytelności)
# Szczegóły
details = Column(JSONBType, nullable=True) # Dodatkowe dane: old_value, new_value, reason
# Kontekst requestu
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True)
request_path = Column(String(500), nullable=True)
# Timestamp
created_at = Column(DateTime, default=datetime.utcnow, index=True)
# Relacje
user = relationship('User', backref='audit_logs')
def __repr__(self):
return f"<AuditLog {self.id} {self.user_email} {self.action} on {self.entity_type}:{self.entity_id}>"
class SecurityAlert(Base):
"""
Alerty bezpieczeństwa wysyłane emailem.
Śledzi:
- Zbyt wiele nieudanych logowań
- Próby dostępu do honeypotów
- Podejrzane wzorce aktywności
- Blokady kont
Created: 2026-01-14
"""
__tablename__ = 'security_alerts'
id = Column(Integer, primary_key=True)
# Typ alertu
alert_type = Column(String(50), nullable=False, index=True)
# Typy: 'brute_force', 'honeypot_hit', 'account_locked', 'suspicious_activity', 'geo_blocked'
severity = Column(String(20), nullable=False, default='medium') # low, medium, high, critical
# Kontekst
ip_address = Column(String(45), nullable=True, index=True)
user_email = Column(String(255), nullable=True)
details = Column(JSONBType, nullable=True) # Dodatkowe dane
# Status alertu
status = Column(String(20), default='new', index=True) # new, acknowledged, resolved
acknowledged_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
acknowledged_at = Column(DateTime, nullable=True)
resolution_note = Column(Text, nullable=True)
# Email notification
email_sent = Column(Boolean, default=False)
email_sent_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relacje
acknowledger = relationship('User', foreign_keys=[acknowledged_by])
def __repr__(self):
return f"<SecurityAlert {self.id} {self.alert_type} ({self.severity}) from {self.ip_address}>"
# ============================================================
# ANNOUNCEMENTS (Ogłoszenia dla członków)
# ============================================================
class Announcement(Base):
"""
Ogłoszenia i aktualności dla członków Norda Biznes.
Obsługuje różne kategorie: ogólne, wydarzenia, okazje biznesowe, od członków.
"""
__tablename__ = 'announcements'
id = Column(Integer, primary_key=True)
title = Column(String(300), nullable=False)
slug = Column(String(300), unique=True, index=True)
excerpt = Column(String(500)) # Krótki opis do listy
content = Column(Text, nullable=False) # Pełna treść (HTML)
# Kategoryzacja (obsługa wielu kategorii)
categories = Column(ARRAY(String), default=[]) # Tablica kategorii
# Wartości: internal, external, event, opportunity, partnership
# Stare pole dla kompatybilności wstecznej (do usunięcia po migracji)
category = Column(String(50), default='internal')
# Media
image_url = Column(String(1000))
external_link = Column(String(1000)) # Link do zewnętrznego źródła
# Publikacja
status = Column(String(20), default='draft', index=True)
# Wartości: draft, published, archived
published_at = Column(DateTime)
expires_at = Column(DateTime) # Opcjonalne wygaśnięcie
# Wyróżnienie
is_featured = Column(Boolean, default=False)
is_pinned = Column(Boolean, default=False)
# Statystyki
views_count = Column(Integer, default=0)
# Audyt
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
author = relationship('User', foreign_keys=[created_by])
readers = relationship('AnnouncementRead', back_populates='announcement', cascade='all, delete-orphan')
# Constants
CATEGORIES = ['internal', 'external', 'event', 'opportunity', 'partnership']
CATEGORY_LABELS = {
'internal': 'Wewnętrzne',
'external': 'Zewnętrzne',
'event': 'Wydarzenie',
'opportunity': 'Okazja biznesowa',
'partnership': 'Partnerstwo'
}
STATUSES = ['draft', 'published', 'archived']
STATUS_LABELS = {
'draft': 'Szkic',
'published': 'Opublikowane',
'archived': 'Zarchiwizowane'
}
@property
def category_label(self):
"""Zwraca polską etykietę pierwszej kategorii (kompatybilność wsteczna)"""
if self.categories:
return self.CATEGORY_LABELS.get(self.categories[0], self.categories[0])
return self.CATEGORY_LABELS.get(self.category, self.category)
@property
def categories_labels(self):
"""Zwraca listę polskich etykiet wszystkich kategorii"""
if self.categories:
return [self.CATEGORY_LABELS.get(cat, cat) for cat in self.categories]
return [self.CATEGORY_LABELS.get(self.category, self.category)]
def has_category(self, category):
"""Sprawdza czy ogłoszenie ma daną kategorię"""
if self.categories:
return category in self.categories
return self.category == category
@property
def status_label(self):
"""Zwraca polską etykietę statusu"""
return self.STATUS_LABELS.get(self.status, self.status)
@property
def is_active(self):
"""Sprawdza czy ogłoszenie jest aktywne (opublikowane i nie wygasło)"""
if self.status != 'published':
return False
if self.expires_at and self.expires_at < datetime.now():
return False
return True
def __repr__(self):
return f"<Announcement {self.id} '{self.title[:50]}' ({self.status})>"
class AnnouncementRead(Base):
"""
Śledzenie odczytów ogłoszeń (seen by).
Zapisuje kto i kiedy przeczytał dane ogłoszenie.
"""
__tablename__ = 'announcement_reads'
id = Column(Integer, primary_key=True)
announcement_id = Column(Integer, ForeignKey('announcements.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
read_at = Column(DateTime, default=datetime.now)
# Relationships
announcement = relationship('Announcement', back_populates='readers')
user = relationship('User')
# Unique constraint
__table_args__ = (
UniqueConstraint('announcement_id', 'user_id', name='uq_announcement_user_read'),
)
def __repr__(self):
return f"<AnnouncementRead announcement={self.announcement_id} user={self.user_id}>"
# ============================================================
# EXTERNAL CONTACTS (Kontakty zewnętrzne)
# ============================================================
class ExternalContact(Base):
"""
Baza kontaktów zewnętrznych - urzędy, instytucje, partnerzy projektów.
Dostępna dla wszystkich zalogowanych członków Norda Biznes.
"""
__tablename__ = 'external_contacts'
id = Column(Integer, primary_key=True)
# Dane osobowe
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
position = Column(String(200)) # Stanowisko (opcjonalne)
photo_url = Column(String(500)) # Zdjęcie osoby (opcjonalne)
# Dane kontaktowe
phone = Column(String(50))
phone_secondary = Column(String(50)) # Drugi numer telefonu
email = Column(String(255))
website = Column(String(500)) # Strona osobista/wizytówka
# Social Media
linkedin_url = Column(String(500))
facebook_url = Column(String(500))
twitter_url = Column(String(500))
# Organizacja
organization_name = Column(String(300), nullable=False)
organization_type = Column(String(50), default='other')
# Typy: government (urząd), agency (agencja), company (firma), ngo (organizacja), university (uczelnia), other
organization_address = Column(String(500))
organization_website = Column(String(500))
organization_logo_url = Column(String(500))
# Kontekst/Projekt
project_name = Column(String(300)) # Nazwa projektu (Tytani, EJ Choczewo, itp.)
project_description = Column(Text) # Krótki opis kontekstu
# Źródło kontaktu
source_type = Column(String(50)) # announcement, forum_post, manual
source_id = Column(Integer) # ID ogłoszenia lub wpisu (opcjonalne)
source_url = Column(String(500)) # URL do źródła
# Powiązane linki (artykuły, strony, dokumenty) - JSON array
# Format: [{"title": "Artykuł o...", "url": "https://...", "type": "article"}, ...]
related_links = Column(PG_JSONB, default=list)
# Tagi do wyszukiwania
tags = Column(String(500)) # Tagi oddzielone przecinkami
# Notatki
notes = Column(Text)
# Audyt
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Status
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False) # Zweryfikowany przez admina/moderatora
# Relationships
creator = relationship('User', foreign_keys=[created_by])
# Constants
ORGANIZATION_TYPES = ['government', 'agency', 'company', 'ngo', 'university', 'other']
ORGANIZATION_TYPE_LABELS = {
'government': 'Urząd',
'agency': 'Agencja',
'company': 'Firma',
'ngo': 'Organizacja pozarządowa',
'university': 'Uczelnia',
'other': 'Inne'
}
SOURCE_TYPES = ['announcement', 'forum_post', 'manual']
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
@property
def organization_type_label(self):
return self.ORGANIZATION_TYPE_LABELS.get(self.organization_type, self.organization_type)
@property
def tags_list(self):
"""Zwraca tagi jako listę."""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
@property
def has_social_media(self):
"""Sprawdza czy kontakt ma jakiekolwiek social media."""
return bool(self.linkedin_url or self.facebook_url or self.twitter_url)
@property
def has_contact_info(self):
"""Sprawdza czy kontakt ma dane kontaktowe."""
return bool(self.phone or self.email or self.website)
def __repr__(self):
return f"<ExternalContact {self.full_name} @ {self.organization_name}>"
# ============================================================
# ZOPK MILESTONES (Timeline)
# ============================================================
class ZOPKMilestone(Base):
"""
Kamienie milowe projektu ZOPK dla wizualizacji timeline.
"""
__tablename__ = 'zopk_milestones'
id = Column(Integer, primary_key=True)
title = Column(String(500), nullable=False)
description = Column(Text)
# Kategoria: nuclear, offshore, infrastructure, defense, other
category = Column(String(50), default='other')
# Daty
target_date = Column(Date) # Planowana data
actual_date = Column(Date) # Rzeczywista data (jeśli zakończone)
# Status: planned, in_progress, completed, delayed, cancelled
status = Column(String(20), default='planned')
# Źródło informacji
source_url = Column(String(1000))
source_news_id = Column(Integer, ForeignKey('zopk_news.id'))
# Wyświetlanie
icon = Column(String(50)) # emoji lub ikona
color = Column(String(20)) # kolor dla timeline
is_featured = Column(Boolean, default=False)
is_verified = Column(Boolean, default=True) # Czy zatwierdzony do wyświetlenia
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
source_news = relationship('ZOPKNews', backref='milestones')
# ============================================================
# DATABASE INITIALIZATION
# ============================================================
def init_db():
"""Initialize database - create all tables"""
# Import all models to ensure they're registered
# (already done at module level)
# Create tables (only creates if they don't exist)
Base.metadata.create_all(bind=engine)
print("Database tables created successfully!")
def drop_all_tables():
"""Drop all tables - USE WITH CAUTION!"""
Base.metadata.drop_all(bind=engine)
print("All tables dropped!")
if __name__ == '__main__':
# Test database connection
try:
init_db()
print("✅ Database initialized successfully")
# Test query
db = SessionLocal()
try:
count = db.query(Company).count()
print(f"✅ Database connected. Found {count} companies.")
finally:
db.close()
except Exception as e:
print(f"❌ Database error: {e}")