185 lines
8.3 KiB
Python
185 lines
8.3 KiB
Python
import time
|
|
from pathlib import Path
|
|
|
|
from dotenv import load_dotenv
|
|
from flask import Flask, render_template, request, url_for
|
|
from flask_login import current_user
|
|
from sqlalchemy import inspect, text
|
|
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
from config import Config
|
|
from redis.exceptions import RedisError
|
|
from app.cli import register_cli
|
|
from app.extensions import db, migrate, login_manager, csrf, limiter
|
|
from app.logging_config import configure_logging
|
|
from app.scheduler import init_scheduler
|
|
from app.utils.formatters import pln
|
|
import hashlib
|
|
|
|
|
|
def _ensure_storage(app):
|
|
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
|
|
for key in ['ARCHIVE_PATH', 'PDF_PATH', 'BACKUP_PATH', 'CERTS_PATH']:
|
|
app.config[key].mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _ensure_column(table_name: str, column_name: str, ddl: str):
|
|
inspector = inspect(db.engine)
|
|
columns = {col['name'] for col in inspector.get_columns(table_name)} if inspector.has_table(table_name) else set()
|
|
if columns and column_name not in columns:
|
|
db.session.execute(text(f'ALTER TABLE {table_name} ADD COLUMN {ddl}'))
|
|
db.session.commit()
|
|
|
|
|
|
|
|
def _wait_for_database(app, attempts: int = 30, delay: float = 1.0) -> bool:
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
with db.engine.connect() as conn:
|
|
conn.execute(text('SELECT 1'))
|
|
if attempt > 1:
|
|
app.logger.info('Database became available after %s attempt(s).', attempt)
|
|
return True
|
|
except OperationalError:
|
|
app.logger.warning('Database not ready yet (%s/%s). Waiting...', attempt, attempts)
|
|
time.sleep(delay)
|
|
return False
|
|
|
|
def _bootstrap_database(app):
|
|
if not _wait_for_database(app):
|
|
app.logger.error('Database is still unavailable after waiting. Skipping bootstrap.')
|
|
return
|
|
|
|
try:
|
|
db.create_all()
|
|
patches = [
|
|
('user', 'theme_preference', "theme_preference VARCHAR(20) DEFAULT 'light' NOT NULL"),
|
|
('user', 'is_blocked', 'is_blocked BOOLEAN DEFAULT 0 NOT NULL'),
|
|
('user', 'force_password_change', 'force_password_change BOOLEAN DEFAULT 0 NOT NULL'),
|
|
('app_setting', 'is_encrypted', 'is_encrypted BOOLEAN DEFAULT 0 NOT NULL'),
|
|
('invoice', 'company_id', 'company_id INTEGER'),
|
|
('invoice', 'source', "source VARCHAR(32) DEFAULT 'ksef' NOT NULL"),
|
|
('invoice', 'customer_id', 'customer_id INTEGER'),
|
|
('invoice', 'issued_to_ksef_at', 'issued_to_ksef_at DATETIME'),
|
|
('invoice', 'issued_status', "issued_status VARCHAR(32) DEFAULT 'received' NOT NULL"),
|
|
('sync_log', 'company_id', 'company_id INTEGER'),
|
|
('sync_log', 'total', 'total INTEGER DEFAULT 0'),
|
|
('company', 'note', 'note TEXT'),
|
|
('company', 'regon', "regon VARCHAR(32) DEFAULT ''"),
|
|
('company', 'address', "address VARCHAR(255) DEFAULT ''"),
|
|
('customer', 'regon', "regon VARCHAR(32) DEFAULT ''"),
|
|
]
|
|
for table, col, ddl in patches:
|
|
_ensure_column(table, col, ddl)
|
|
app.logger.info('Database bootstrap checked.')
|
|
except SQLAlchemyError:
|
|
app.logger.exception('Automatic database bootstrap failed.')
|
|
|
|
|
|
def _asset_hash(app: Flask, filename: str) -> str:
|
|
static_file = Path(app.static_folder) / filename
|
|
if not static_file.exists() or not static_file.is_file():
|
|
return 'dev'
|
|
digest = hashlib.sha256(static_file.read_bytes()).hexdigest()
|
|
return digest[:12]
|
|
|
|
|
|
def create_app(config_class=Config):
|
|
load_dotenv()
|
|
app = Flask(__name__, instance_relative_config=True)
|
|
app.config.from_object(config_class)
|
|
app.config['RATELIMIT_STORAGE_URI'] = app.config.get('REDIS_URL', 'memory://')
|
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
|
_ensure_storage(app)
|
|
configure_logging(app)
|
|
db.init_app(app)
|
|
migrate.init_app(app, db)
|
|
login_manager.init_app(app)
|
|
csrf.init_app(app)
|
|
from app.services.redis_service import RedisService
|
|
if app.config.get('RATELIMIT_STORAGE_URI', '').startswith('redis://') and not RedisService.available(app):
|
|
app.logger.warning('Redis niedostępny przy starcie - limiter przełączony na memory://')
|
|
app.config['RATELIMIT_STORAGE_URI'] = 'memory://'
|
|
limiter.init_app(app)
|
|
app.jinja_env.filters['pln'] = pln
|
|
from app.models import user, invoice, sync_log, notification, setting, audit_log, company, catalog # noqa
|
|
from app.auth.routes import bp as auth_bp
|
|
from app.dashboard.routes import bp as dashboard_bp
|
|
from app.invoices.routes import bp as invoices_bp
|
|
from app.settings.routes import bp as settings_bp
|
|
from app.notifications.routes import bp as notifications_bp
|
|
from app.api.routes import bp as api_bp
|
|
from app.admin.routes import bp as admin_bp
|
|
from app.nfz.routes import bp as nfz_bp
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(dashboard_bp)
|
|
app.register_blueprint(invoices_bp)
|
|
app.register_blueprint(settings_bp)
|
|
app.register_blueprint(notifications_bp)
|
|
app.register_blueprint(admin_bp)
|
|
app.register_blueprint(nfz_bp)
|
|
app.register_blueprint(api_bp, url_prefix='/api')
|
|
register_cli(app)
|
|
|
|
with app.app_context():
|
|
if not app.config.get('TESTING'):
|
|
_bootstrap_database(app)
|
|
init_scheduler(app)
|
|
|
|
@app.context_processor
|
|
def inject_globals():
|
|
from app.models.setting import AppSetting
|
|
from app.services.company_service import CompanyService
|
|
theme = request.cookies.get('theme', 'light')
|
|
if getattr(current_user, 'is_authenticated', False):
|
|
theme = getattr(current_user, 'theme_preference', theme) or 'light'
|
|
else:
|
|
theme = AppSetting.get('ui.theme', theme)
|
|
current_company = CompanyService.get_current_company() if getattr(current_user, 'is_authenticated', False) else None
|
|
available_companies = CompanyService.available_for_user() if getattr(current_user, 'is_authenticated', False) else []
|
|
nfz_enabled = False
|
|
if getattr(current_user, 'is_authenticated', False) and current_company:
|
|
from app.services.settings_service import SettingsService
|
|
nfz_enabled = SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=current_company.id) == 'true'
|
|
status_map = {'sent': 'Wysłano', 'success': 'Sukces', 'error': 'Błąd', 'failed': 'Błąd', 'skipped': 'Pominięto', 'queued': 'Oczekuje'}
|
|
channel_map = {'email': 'E-mail', 'pushover': 'Pushover'}
|
|
return {
|
|
'app_name': 'KSeF Manager',
|
|
'theme': theme,
|
|
'read_only_mode': (__import__('app.services.settings_service', fromlist=['SettingsService']).SettingsService.read_only_enabled(company_id=current_company.id if current_company else None) if getattr(current_user, 'is_authenticated', False) else False),
|
|
'current_company': current_company,
|
|
'available_companies': available_companies,
|
|
'nfz_module_enabled': nfz_enabled,
|
|
'static_asset': lambda filename: url_for('static', filename=filename, v=_asset_hash(app, filename)),
|
|
'global_footer_text': app.config.get('APP_FOOTER_TEXT', ''),
|
|
'status_pl': lambda value: status_map.get((value or '').lower(), value or '—'),
|
|
'channel_pl': lambda value: channel_map.get((value or '').lower(), (value or '—').upper() if value else '—'),
|
|
}
|
|
|
|
@app.after_request
|
|
def cleanup_static_headers(response):
|
|
if request.path.startswith('/static/'):
|
|
response.headers.pop('Content-Disposition', None)
|
|
return response
|
|
|
|
@app.errorhandler(403)
|
|
def error_403(err):
|
|
return render_template('errors/403.html'), 403
|
|
|
|
@app.errorhandler(404)
|
|
def error_404(err):
|
|
return render_template('errors/404.html'), 404
|
|
|
|
@app.errorhandler(RedisError)
|
|
@app.errorhandler(ConnectionError)
|
|
def error_redis(err):
|
|
db.session.rollback()
|
|
return render_template('errors/503.html', message='Usługa cache jest chwilowo niedostępna. Aplikacja korzysta z trybu awaryjnego.'), 503
|
|
|
|
@app.errorhandler(500)
|
|
def error_500(err):
|
|
db.session.rollback()
|
|
return render_template('errors/500.html'), 500
|
|
|
|
return app
|