""" 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'' # ============================================================ # 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 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) # Źródło danych (tracking) source = Column(String(255)) # np. 'kalendarz_norda_2026', 'manual', 'api' source_note = Column(Text) # Pełna informacja o źródle # Relationships speaker_company = relationship('Company') creator = relationship('User', foreign_keys=[created_by]) attendees = relationship('EventAttendee', back_populates='event', cascade='all, delete-orphan') @property def attendee_count(self): return len(self.attendees) @property def is_past(self): from datetime import date return self.event_date < date.today() class EventAttendee(Base): """RSVP na wydarzenia""" __tablename__ = 'event_attendees' id = Column(Integer, primary_key=True) event_id = Column(Integer, ForeignKey('norda_events.id'), nullable=False) user_id = Column(Integer, ForeignKey('users.id'), nullable=False) status = Column(String(20), default='confirmed') # confirmed, maybe, declined registered_at = Column(DateTime, default=datetime.now) event = relationship('NordaEvent', back_populates='attendees') user = relationship('User') # ============================================================ # PRIVATE MESSAGES # ============================================================ class PrivateMessage(Base): """Wiadomości prywatne między członkami""" __tablename__ = 'private_messages' id = Column(Integer, primary_key=True) sender_id = Column(Integer, ForeignKey('users.id'), nullable=False) recipient_id = Column(Integer, ForeignKey('users.id'), nullable=False) subject = Column(String(255)) content = Column(Text, nullable=False) is_read = Column(Boolean, default=False) read_at = Column(DateTime) # Dla wątków konwersacji parent_id = Column(Integer, ForeignKey('private_messages.id')) created_at = Column(DateTime, default=datetime.now) sender = relationship('User', foreign_keys=[sender_id], backref='sent_messages') recipient = relationship('User', foreign_keys=[recipient_id], backref='received_messages') parent = relationship('PrivateMessage', remote_side=[id]) # ============================================================ # B2B CLASSIFIEDS # ============================================================ class Classified(Base): """Ogłoszenia B2B - Szukam/Oferuję""" __tablename__ = 'classifieds' id = Column(Integer, primary_key=True) author_id = Column(Integer, ForeignKey('users.id'), nullable=False) company_id = Column(Integer, ForeignKey('companies.id')) # Typ ogłoszenia listing_type = Column(String(20), nullable=False) # 'szukam', 'oferuje' category = Column(String(50), nullable=False) # uslugi, produkty, wspolpraca, praca, inne title = Column(String(255), nullable=False) description = Column(Text, nullable=False) # Opcjonalne szczegóły budget_info = Column(String(255)) # "do negocjacji", "5000-10000 PLN" location_info = Column(String(255)) # Wejherowo, Cała Polska, Online # Status is_active = Column(Boolean, default=True) is_ai_generated = Column(Boolean, default=False) 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}")