- Added UniqueConstraint on 'name' column - Constraint already applied to production database - Prevents duplicate stakeholder entries in the future Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2223 lines
81 KiB
Python
2223 lines
81 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
|
|
|
|
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
|
|
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)
|
|
|
|
# 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')
|
|
|
|
def __repr__(self):
|
|
return f'<User {self.email}>'
|
|
|
|
|
|
# ============================================================
|
|
# COMPANY DIRECTORY (existing schema from SQL)
|
|
# ============================================================
|
|
|
|
class Category(Base):
|
|
"""Company categories"""
|
|
__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)
|
|
|
|
companies = relationship('Company', back_populates='category')
|
|
|
|
|
|
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))
|
|
|
|
# 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))
|
|
|
|
# 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
|
|
|
|
# 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)
|
|
|
|
# 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']
|
|
STATUSES = ['new', 'in_progress', 'resolved', 'rejected']
|
|
|
|
CATEGORY_LABELS = {
|
|
'feature_request': 'Propozycja funkcji',
|
|
'bug': 'Błąd',
|
|
'question': 'Pytanie',
|
|
'announcement': 'Ogłoszenie'
|
|
}
|
|
|
|
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])
|
|
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')")
|
|
|
|
@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)
|
|
|
|
# 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', back_populates='forum_replies')
|
|
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 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)
|
|
|
|
# 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)
|
|
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 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-2.0-flash)
|
|
|
|
# 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)
|
|
|
|
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])
|
|
|
|
|
|
# ============================================================
|
|
# 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-2.0-flash, gemini-1.5-pro, 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}>"
|
|
|
|
|
|
# ============================================================
|
|
# 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}")
|