diff --git a/app/__init__.py b/app/__init__.py index d1bc8c2..c389aa7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -9,9 +9,15 @@ migrate = Migrate() login_manager = LoginManager() csrf = CSRFProtect() -def create_app(): +def create_app(config_overrides=None): app = Flask(__name__) app.config.from_object('app.config.Config') + if config_overrides: + app.config.update(config_overrides) + if not app.config.get('SECRET_KEY'): + if not (app.debug or app.testing): + raise RuntimeError('Задайте SECRET_KEY: без него cookie сессий можно подделать') + app.config['SECRET_KEY'] = 'dev-only-secret' # noqa: S105 — только для отладки и тестов db.init_app(app) migrate.init_app(app, db) diff --git a/app/access.py b/app/access.py new file mode 100644 index 0000000..f4e1064 --- /dev/null +++ b/app/access.py @@ -0,0 +1,20 @@ +"""Права доступа к расписанию: владелец или редактор по ссылке на редактирование.""" +from flask_login import current_user + +from app.models.schedule import Schedule +from app.models.share import Share, ShareEditor + + +def can_edit(schedule: Schedule) -> bool: + if not current_user.is_authenticated: + return False + if schedule.user_id == current_user.id: + return True + edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first() + if edit_share is None: + return False + return ShareEditor.query.filter_by(share_id=edit_share.id, user_id=current_user.id).first() is not None + + +# Просмотр по id доступен тем же, кто может редактировать; остальные смотрят по ссылке /shared/ +can_view = can_edit diff --git a/app/config.py b/app/config.py index 4ee8317..3a2f281 100644 --- a/app/config.py +++ b/app/config.py @@ -1,14 +1,22 @@ import os + from dotenv import load_dotenv load_dotenv() -class Config: - SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-in-production') - SQLALCHEMY_DATABASE_URI = ( + +def _database_url(): + if os.getenv('DATABASE_URL'): + return os.getenv('DATABASE_URL') + return ( f"postgresql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}" f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}" ) + + +class Config: + SECRET_KEY = os.getenv('SECRET_KEY') + SQLALCHEMY_DATABASE_URI = _database_url() SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ENGINE_OPTIONS = { 'pool_pre_ping': True, diff --git a/app/models/__init__.py b/app/models/__init__.py index 550828b..d4f47f1 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,3 +2,5 @@ from app.models.user import User from app.models.schedule import Schedule, SubjectConfig from app.models.metric import Metric from app.models.share import Share, ShareEditor + +__all__ = ["User", "Schedule", "SubjectConfig", "Metric", "Share", "ShareEditor"] diff --git a/app/routes/export.py b/app/routes/export.py index 242066b..d77a4e4 100644 --- a/app/routes/export.py +++ b/app/routes/export.py @@ -1,20 +1,12 @@ import urllib.parse from flask import Blueprint, Response, redirect, url_for, flash -from flask_login import login_required, current_user +from flask_login import login_required from app.models.schedule import Schedule -from app.models.share import Share -from app import db +from app.access import can_view as _can_view bp = Blueprint('export', __name__) -def _can_view(schedule: Schedule) -> bool: - if schedule.user_id == current_user.id: - return True - share = Share.query.filter_by(schedule_id=schedule.id).first() - return share is not None - - @bp.route('/schedules//export/json') @login_required def export_json(schedule_id: int): diff --git a/app/routes/schedules.py b/app/routes/schedules.py index 1c87a5a..7e2c2e7 100644 --- a/app/routes/schedules.py +++ b/app/routes/schedules.py @@ -1,10 +1,10 @@ import copy -from flask import (Blueprint, render_template, redirect, url_for, +from flask import (Blueprint, current_app, render_template, redirect, url_for, flash, request, jsonify) from flask_login import login_required, current_user from app import db, csrf from app.models.schedule import Schedule, SubjectConfig -from app.models.metric import Metric +from app.access import can_edit as _can_edit, can_view as _can_view from app.services.ai_scan import scan_image from app.services.merge import merge_schedules_data from app.services.schedule_helpers import ( @@ -46,8 +46,9 @@ def upload(): try: image_bytes = file.read() data = scan_image(image_bytes, ext, extra_prompt) - except Exception as e: - flash(f'Ошибка сканирования: {e}', 'danger') + except Exception: + current_app.logger.exception('Ошибка распознавания расписания') + flash('Не удалось распознать расписание. Попробуйте другое фото или повторите позже.', 'danger') return render_template('schedule/upload.html') schedule = Schedule(user_id=current_user.id, name=name, data=data) @@ -198,9 +199,9 @@ def rename_subject_field(schedule_id, subject_name): def _replace_slot_field(item: dict, idx: int, value): """Меняет поле слота по индексу во всех типах и датах""" - for tn, type_data in item.get('types', {}).items(): + for type_data in item.get('types', {}).values(): date_ranges = type_data.get('dates', type_data) if isinstance(type_data, dict) and 'dates' in type_data else type_data - for dr, slots in date_ranges.items(): + for slots in date_ranges.values(): for slot in slots: while len(slot) <= idx: slot.append(None) @@ -442,30 +443,3 @@ def delete_slot(schedule_id, subject_name): db.session.commit() return jsonify({'ok': True}) - -# ─── Вспомогательные функции доступа ───────────────────────────────────────── - -def _can_view(schedule: Schedule) -> bool: - if schedule.user_id == current_user.id: - return True - from app.models.share import ShareEditor, Share - edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first() - if edit_share: - editor = ShareEditor.query.filter_by( - share_id=edit_share.id, user_id=current_user.id).first() - if editor: - return True - return False - - -def _can_edit(schedule: Schedule) -> bool: - if schedule.user_id == current_user.id: - return True - from app.models.share import ShareEditor, Share - edit_share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first() - if edit_share: - editor = ShareEditor.query.filter_by( - share_id=edit_share.id, user_id=current_user.id).first() - if editor: - return True - return False diff --git a/app/routes/shares.py b/app/routes/shares.py index b6aee92..ddf9ad1 100644 --- a/app/routes/shares.py +++ b/app/routes/shares.py @@ -2,11 +2,10 @@ from flask import (Blueprint, render_template, redirect, url_for, flash, request, jsonify) from flask_login import login_required, current_user from app import db, csrf -from app.models.schedule import Schedule, SubjectConfig +from app.models.schedule import Schedule from app.models.share import Share, ShareEditor from app.models.user import User from app.services.schedule_helpers import build_week_view, get_week_dates, DAYS_DISPLAY, DAYS_RU -from app.services.merge import merge_schedules_data from datetime import date bp = Blueprint('shares', __name__) diff --git a/app/routes/subjects.py b/app/routes/subjects.py index c092608..aff7e7c 100644 --- a/app/routes/subjects.py +++ b/app/routes/subjects.py @@ -1,10 +1,11 @@ -from flask import Blueprint, render_template, request, jsonify -from flask_login import login_required, current_user +from flask import Blueprint, current_app, jsonify, render_template, request +from flask_login import login_required from sqlalchemy.orm.attributes import flag_modified from app import db, csrf from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS from app.services.ai_metrics import generate_metric_from_prompt +from app.access import can_edit as _check_edit_access bp = Blueprint('subjects', __name__) @@ -87,8 +88,9 @@ def add_metric_ai(schedule_id, subject_name): try: result = generate_metric_from_prompt(subject_name, prompt) - except Exception as e: - return jsonify({'error': str(e)}), 500 + except Exception: + current_app.logger.exception('Ошибка генерации метрики') + return jsonify({'error': 'Не удалось сгенерировать метрику, попробуйте позже'}), 500 cfg = _get_or_create_config(schedule_id, subject_name) metric = Metric(subject_config_id=cfg.id, @@ -282,15 +284,6 @@ def _get_or_create_config(schedule_id: int, subject_name: str) -> SubjectConfig: return config -def _check_edit_access(schedule: Schedule) -> bool: - if schedule.user_id == current_user.id: - return True - from app.models.share import Share, ShareEditor - share = Share.query.filter_by(schedule_id=schedule.id, share_type='edit').first() - if share: - return ShareEditor.query.filter_by( - share_id=share.id, user_id=current_user.id).first() is not None - return False def _render_metric_html(metric: Metric) -> str: diff --git a/app/services/ai_scan.py b/app/services/ai_scan.py index 409f80d..47082fe 100644 --- a/app/services/ai_scan.py +++ b/app/services/ai_scan.py @@ -1,8 +1,6 @@ import base64 import json import os -import tempfile -from pathlib import Path from openai import OpenAI from dotenv import load_dotenv diff --git a/app/services/export.py b/app/services/export.py index d5f5026..439f565 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -2,7 +2,7 @@ import csv import io import json -from flask import current_app, render_template +from flask import render_template DAYS_ORDER = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'] diff --git a/run.py b/run.py index 42d7f0c..682e8f0 100644 --- a/run.py +++ b/run.py @@ -1,9 +1,11 @@ +import os import sys + sys.dont_write_bytecode = True -from app import create_app +from app import create_app # noqa: E402 app = create_app() if __name__ == '__main__': - app.run(debug=False) + app.run(debug=os.getenv('FLASK_DEBUG') == '1')