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