nordabiz/database.py
Maciej Pienczyn 41997a15e9 auto-claude: subtask-1-1 - Add google_opening_hours (JSONB) and google_photos_count (INTEGER) columns to CompanyWebsiteAnalysis model
- Added google_opening_hours Column(JSONB) for storing GBP opening hours
- Added google_photos_count Column(Integer) for storing GBP photos count
- Both columns added to GOOGLE BUSINESS section alongside existing google_* columns
2026-01-08 22:57:21 +01:00

1339 lines
48 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
Author: Norda Biznes Development Team
Created: 2025-11-23
Updated: 2026-01-08 (GBP Audit Tool)
"""
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
DATABASE_URL = os.getenv(
'DATABASE_URL',
'postgresql://nordabiz_app:NordaBiz2025Secure@localhost:5432/nordabiz'
)
# Determine if we're using SQLite
IS_SQLITE = DATABASE_URL.startswith('sqlite')
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)
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')
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))
# 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
# === 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)
# Status
is_pinned = Column(Boolean, default=False)
is_locked = 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)
# Relationships
author = relationship('User', back_populates='forum_topics')
replies = relationship('ForumReply', back_populates='topic', cascade='all, delete-orphan', order_by='ForumReply.created_at')
@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
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)
# 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')
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)
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)
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'
# ============================================================
# 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')
# ============================================================
# 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}")