diff --git a/.env b/.env new file mode 100644 index 0000000..e0274f7 --- /dev/null +++ b/.env @@ -0,0 +1,26 @@ +# ═══ Gitea webhook ═══ +GITEA_WEBHOOK_SECRET=176f1c8bfb1784e904b8ad875df8b9d14daa16d8e5aaf228ec19f251120ab481 + +# ═══ Gitea API ═══ +# Получи токен: Gitea → Settings → Applications → Generate Token (scopes: read:repository, read:user) +GITEA_API_TOKEN= 66697efe-d88b-41fe-b3d5-6498d978e1fa + +# ═══ Gitea OAuth (были в config.py, теперь в env) ═══ +GITEA_CLIENT_ID=66697efe-d88b-41fe-b3d5-6498d978e1fa +GITEA_CLIENT_SECRET=gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a + +# ═══ Google Calendar sync ═══ +GOOGLE_CALENDAR_ID=korneshoc@gmail.com +GOOGLE_CREDENTIALS_FILE=/opt/morozovka-portal/google_credentials.json + +# ═══ SMTP (пока пусто → письма в journalctl) ═══ +# SMTP_SERVER=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASSWORD= +# SMTP_FROM=noreply@morozovka.space + +# ═══ LLM (пока выключен) ═══ +LLM_ENABLED=false +LLM_API_BASE=http://localhost:11434/v1 +LLM_MODEL=qwen2.5:14b diff --git a/README.md b/README.md new file mode 100644 index 0000000..531ebd2 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# Morozovka Platform + +Единая платформа команды Никиты Сергеевича Морозова (30+ человек). +Развёрнута на базе **Gitea** + собственный **Flask-портал**. +Заменяет ручное управление расписанием, каталог проектов, файлохранилище, мастер-классы. + +## Что внутри + +- **Расписание** с публичной страницей `/schedule`, статусом-баннером, ICS-экспортом и rule-based ассистентом +- **Проекты** с канбан-досками, granular-правами участников, интеграцией с Gitea +- **Мастер-классы** с регистрацией, посещаемостью, материалами +- **Файлохранилище v2.0** — пространства → папки → файлы с версиями и ACL +- **Общая лента** `/feed` из всех проектов и МК +- **Дашборд** участника +- **Онбординг-тур** для новичков +- **Уведомления-колокольчик** на всех страницах + +## Стек + +| Слой | Технология | +|---|---| +| Backend | Python 3.11, Flask 3.1, SQLModel, Gunicorn | +| БД | PostgreSQL 15 | +| Миграции | Alembic | +| Reverse proxy | Nginx (внешний + внутренний) | +| Git/SSO | Gitea 1.22 | +| VPN | OpenVPN + socat (Малина) | +| Frontend | Jinja2 + vanilla JS + общий `main.css` | + +## Быстрый старт (для разработчика) + +```bash +# 1. Клон репозитория +cd /opt +git clone http://192.168.50.3:3000/git_admin/morozovka-portal.git +cd morozovka-portal + +# 2. venv + зависимости +python3 -m venv venv +./venv/bin/pip install -r requirements.txt + +# 3. .env (см. INSTALL.md) +cp .env.example .env +nano .env + +# 4. Миграции +export PATH=./venv/bin:$PATH +alembic upgrade head + +# 5. Запуск (dev) +./venv/bin/flask --app app.main run --host 0.0.0.0 --port 8000 + +# 6. Тесты +bash tests/run_smoke.sh +``` + +## Структура проекта + +``` +/opt/morozovka-portal/ +├── app/ +│ ├── main.py — Flask app, регистрация модулей, healthz +│ ├── config.py — все настройки (env + .env через python-dotenv) +│ ├── db.py — SQLAlchemy engine +│ ├── models.py — SQLModel: 27 таблиц +│ ├── helpers.py — общие фильтры, декораторы, permissions, email, upload +│ ├── auth.py — Gitea OAuth +│ ├── assistant.py — rule-based ассистент (regex) +│ ├── llm_assistant.py — LLM-ассистент (готов, но выключен) +│ ├── gitea_api.py — Gitea API client с in-memory кэшем +│ ├── google_sync.py — Google Calendar → Event (cron 15 мин) +│ ├── modules/ — 11 модулей роутов (register(app)) +│ ├── static/ +│ │ ├── main.css — общие стили (~38 КБ) +│ │ └── notif_bell.js — колокольчик уведомлений +│ └── templates/ — 26 Jinja2-шаблонов (25 extends base.html) +├── alembic/ — миграции (4 ревизии) +├── tests/ +│ ├── run_smoke.sh — 94 автотеста без GUI +│ └── last_run.log — последний прогон +├── docs/ — документация (эта папка) +├── requirements.txt +└── .env — секреты (не коммитить!) +``` + +## Документация + +| Файл | О чём | +|---|---| +| `docs/INSTALL.md` | Установка с нуля | +| `docs/ARCHITECTURE.md` | Архитектура, модули, роли, фичи | +| `docs/API.md` | Все роуты (HTTP endpoints + JSON API) | +| `docs/DATABASE.md` | Схема БД, 27 таблиц, миграции | +| `docs/OPERATIONS.md` | Эксплуатация: логи, бэкапы, troubleshooting | +| `docs/USER_GUIDE.md` | Гайд для пользователей | +| `docs/SECURITY.md` | Что есть / чего нет / план | +| `docs/ROADMAP.md` | Что сделано / что дальше | + +## Тесты + +```bash +cd /opt/morozovka-portal +bash tests/run_smoke.sh +``` + +**Ожидаемо:** `PASS: 94, FAIL: 0` (за 3 секунды). Скрипт создаёт изолированного `smoke_test_` пользователя, прогоняет все модули, чистит за собой. + +## Лицензия + +Внутренний проект команды morozovka-bridge. Не для распространения. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e1e4400 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,117 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +#sqlalchemy.url = driver://user:pass@localhost/dbname +sqlalchemy.url = + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..7bb8e13 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,116 @@ +"""Alembic env — SQLModel + app.config.DATABASE_URL + защита autogenerate.""" +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app import config as app_config # noqa: E402 +from app import models # noqa: E402,F401 + +config = context.config +config.set_main_option("sqlalchemy.url", app_config.DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + + +# ── Таблицы, существовавшие до Alembic ─────────────────────────────── +_EXISTING_TABLES = { + "event", "eventsubscriber", "globalstatus", "notification", + "portalfile", "project", "projectmember", "projectpost", + "projectpostattachment", "projectsubscription", "projecttask", + "user", "usersession", "workshop", "workshopassistant", + "workshopfeedback", "workshopmaterial", "workshoppost", + "workshoppostattachment", "workshopregistration", + "workshopsession", "workshopsubscription", +} + + +def include_object(obj, name, type_, reflected, compare_to): + """ + FK/index/unique для существующих таблиц — игнорируются + (модели vs БД расходятся в именах/ondelete, это косметика). + Отражённое в БД, но отсутствующее в модели — не дропаем. + Новые таблицы/колонки — разрешены. + """ + if type_ in ("foreign_key_constraint", "index", "unique_constraint"): + parent = getattr(obj, "table", None) + parent_name = getattr(parent, "name", None) + if parent_name in _EXISTING_TABLES: + return False + if reflected: + return False + if reflected and compare_to is None: + return False + return True + + +def render_item(type_, obj, autogen_context): + """ + SQLModel использует свои типы (AutoString, UTCDateTime). В миграции их + нельзя писать как `sqlmodel.sql.sqltypes.AutoString()` — не будет + импортировано. Рендерим как нативные SQLAlchemy типы. + + Покрываемые типы: + • AutoString → sa.String() (str-поля) + • UTCDateTime → sa.DateTime(timezone=True) (datetime-поля) + • Integer, Boolean, Float — нативные, не трогаем + """ + if type_ != "type": + return False + try: + import sqlmodel + except ImportError: + return False + if isinstance(obj, sqlmodel.sql.sqltypes.UTCDateTime): + return "sa.DateTime(timezone=True)" + if isinstance(obj, sqlmodel.sql.sqltypes.AutoString): + return "sa.String()" + return False + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + include_object=include_object, + render_item=render_item, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + include_object=include_object, + render_item=render_item, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0aab74aaa3fc_v2_0_filespace_filefolder_acls_.py b/alembic/versions/0aab74aaa3fc_v2_0_filespace_filefolder_acls_.py new file mode 100644 index 0000000..ad90710 --- /dev/null +++ b/alembic/versions/0aab74aaa3fc_v2_0_filespace_filefolder_acls_.py @@ -0,0 +1,147 @@ +"""v2.0: FileSpace, FileFolder, ACLs, FileVersion + extend PortalFile + +Revision ID: 0aab74aaa3fc +Revises: 9be3c5145b6c +Create Date: 2026-10-21 05:35:52.013459 + +Почищено вручную: + • sqlmodel.sql.sqltypes.AutoString() → sa.String() + • sqlmodel.sql.sqltypes.UTCDateTime() → sa.DateTime(timezone=True) + • server_default='1' для portalfile.version — иначе ADD COLUMN NOT NULL + упадёт на 5 существующих строках в portalfile + • server_default для created_at / role / visibility / booleans — + защита от неполных INSERT в будущем + • UNIQUE(space_id, user_id) и UNIQUE(folder_id, user_id) для ACL — + одна запись прав на пользователя в пространстве/папке + • FK portalfile.space_id → filespace.id (ON DELETE SET NULL) + и portalfile.folder_id → filefolder.id (ON DELETE SET NULL) — + autogenerate не сгенерил их из-за include_object-фильтра на существующих таблицах +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '0aab74aaa3fc' +down_revision: Union[str, None] = '9be3c5145b6c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ── 1. filespace ──────────────────────────────────────────────── + op.create_table( + 'filespace', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('slug', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=True), + sa.Column('visibility', sa.String(), nullable=False, server_default='internal'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_filespace_slug'), 'filespace', ['slug'], unique=True) + + # ── 2. filefolder ─────────────────────────────────────────────── + op.create_table( + 'filefolder', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('space_id', sa.Integer(), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ), + sa.ForeignKeyConstraint(['parent_id'], ['filefolder.id'], ), + sa.ForeignKeyConstraint(['space_id'], ['filespace.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + + # ── 3. filespaceacl ───────────────────────────────────────────── + op.create_table( + 'filespaceacl', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('space_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(), nullable=False, server_default='viewer'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['space_id'], ['filespace.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('space_id', 'user_id', name='uq_filespaceacl_user'), + ) + + # ── 4. filefolderacl ──────────────────────────────────────────── + op.create_table( + 'filefolderacl', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('folder_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('can_view', sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column('can_upload', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('can_delete', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('can_manage', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['folder_id'], ['filefolder.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('folder_id', 'user_id', name='uq_filefolderacl_user'), + ) + + # ── 5. fileversion ────────────────────────────────────────────── + op.create_table( + 'fileversion', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('file_id', sa.Integer(), nullable=False), + sa.Column('version_num', sa.Integer(), nullable=False, server_default='1'), + sa.Column('stored_name', sa.String(), nullable=False), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('uploaded_by', sa.Integer(), nullable=False), + sa.Column('uploaded_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('comment', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['file_id'], ['portalfile.id'], ), + sa.ForeignKeyConstraint(['uploaded_by'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + + # ── 6. portalfile: новые колонки ──────────────────────────────── + op.add_column('portalfile', sa.Column('space_id', sa.Integer(), nullable=True)) + op.add_column('portalfile', sa.Column('folder_id', sa.Integer(), nullable=True)) + op.add_column('portalfile', sa.Column('description', sa.String(), nullable=True)) + op.add_column('portalfile', sa.Column('tags', sa.String(), nullable=True)) + # NOT NULL + server_default='1' — 5 существующих строк получат version=1 + op.add_column('portalfile', sa.Column('version', sa.Integer(), nullable=False, server_default='1')) + op.add_column('portalfile', sa.Column('current_version_id', sa.Integer(), nullable=True)) + + # ── 7. FK для новых колонок (autogenerate их не сгенерил) ─────── + op.create_foreign_key( + 'portalfile_space_id_fkey', 'portalfile', 'filespace', + ['space_id'], ['id'], ondelete='SET NULL', + ) + op.create_foreign_key( + 'portalfile_folder_id_fkey', 'portalfile', 'filefolder', + ['folder_id'], ['id'], ondelete='SET NULL', + ) + + +def downgrade() -> None: + op.drop_constraint('portalfile_folder_id_fkey', 'portalfile', type_='foreignkey') + op.drop_constraint('portalfile_space_id_fkey', 'portalfile', type_='foreignkey') + op.drop_column('portalfile', 'current_version_id') + op.drop_column('portalfile', 'version') + op.drop_column('portalfile', 'tags') + op.drop_column('portalfile', 'description') + op.drop_column('portalfile', 'folder_id') + op.drop_column('portalfile', 'space_id') + op.drop_table('fileversion') + op.drop_table('filefolderacl') + op.drop_table('filespaceacl') + op.drop_table('filefolder') + op.drop_index(op.f('ix_filespace_slug'), table_name='filespace') + op.drop_table('filespace') diff --git a/alembic/versions/9be3c5145b6c_add_useronboarding_table_for_tutorial_.py b/alembic/versions/9be3c5145b6c_add_useronboarding_table_for_tutorial_.py new file mode 100644 index 0000000..043ff3e --- /dev/null +++ b/alembic/versions/9be3c5145b6c_add_useronboarding_table_for_tutorial_.py @@ -0,0 +1,51 @@ +"""add UserOnboarding table for tutorial progress + +Revision ID: 9be3c5145b6c +Revises: efda7d432855 +Create Date: 2026-10-21 04:54:17.207271 + +ВАЖНО: миграция почищена вручную. +Autogenerate добавил лишние create_foreign_key / drop_index / create_index +для существующих таблиц (косметические расхождения модели vs БД: +имена FK, ondelete=CASCADE). Эти операции опасны — потеряли бы CASCADE +и индексы. Оставлено только реальное изменение: новая таблица. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '9be3c5145b6c' +down_revision: Union[str, None] = 'efda7d432855' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'useronboarding', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('visited_schedule', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('visited_projects', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('visited_workshops', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('visited_files', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('visited_feed', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('visited_profile', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='useronboarding_user_id_fkey'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + op.f('ix_useronboarding_user_id'), + 'useronboarding', + ['user_id'], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index(op.f('ix_useronboarding_user_id'), table_name='useronboarding') + op.drop_table('useronboarding') diff --git a/alembic/versions/efda7d432855_baseline_current_schema_22_tables.py b/alembic/versions/efda7d432855_baseline_current_schema_22_tables.py new file mode 100644 index 0000000..65ae7ef --- /dev/null +++ b/alembic/versions/efda7d432855_baseline_current_schema_22_tables.py @@ -0,0 +1,32 @@ +"""baseline: current schema (22 tables) — NO-OP + +Revision ID: efda7d432855 +Revises: +Create Date: 2026-10-21 04:34:20.028390 + +ВАЖНО: помечена как применённая через `alembic stamp head`, но +фактически ничего не выполняет. Это baseline — точка отсчёта. + +Существующая БД уже была создана через SQLModel.create_all() до +внедрения Alembic. Схема зафиксирована «как есть». Все будущие +изменения — через новые миграции поверх этого baseline. + +Autogenerate при первом запуске нашёл расхождения (FK без CASCADE, +unique constraint vs unique index) — эти операции НЕ применены, +потому что привели бы к потере CASCADE и индексов. Правило на будущее: +при autogenerate — вручную чистить миграцию от drop'ов существующего. +""" +from typing import Sequence, Union + +revision: str = 'efda7d432855' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/assistant.py.bak b/app/assistant.py.bak deleted file mode 100644 index b862a6b..0000000 --- a/app/assistant.py.bak +++ /dev/null @@ -1,110 +0,0 @@ -import re -from datetime import datetime, timezone, timedelta -import pytz -from sqlmodel import Session, select -from .models import Event, GlobalStatus -from .db import engine - -MSK = pytz.timezone("Europe/Moscow") -def _msk(dt): - if dt is None: return None - if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(MSK) - -def get_response(text: str) -> str: - """Принимает вопрос, определяет интент и возвращает ответ.""" - text = text.lower().strip() - - with Session(engine) as db_session: - # Получаем текущий статус - current_status = db_session.exec(select(GlobalStatus)).first() - - # --- ИНТЕНТ: ГДЕ СЕЙЧАС / СТАТУС --- - if re.search(r'где|статус|сейчас', text): - if current_status.status == 'on_place': - return "Никита Сергеевич сейчас на месте (в аудитории)." - elif current_status.status == 'delayed': - return f"Никита Сергеевич опаздывает на {current_status.delay_minutes} мин. Причина: {current_status.message or 'не указана'}." - elif current_status.status == 'away': - return "Никита Сергеевич вне вуза." - elif current_status.status == 'cancelled': - return f"Занятия отменены! {current_status.message or ''}" - else: - return "Никита Сергеевич сейчас свободен / вне расписания." - - # --- ИНТЕНТ: ОПАЗДЫВАЕТ? --- - if re.search(r'опаздыва|задержива', text): - if current_status.status == 'delayed': - return f"Да, опаздывает на {current_status.delay_minutes} мин. Причина: {current_status.message or 'не указана'}." - else: - return "По текущему статусу, он не опаздывает." - - # --- ИНТЕНТ: КОГДА СЛЕДУЮЩАЯ ПАРА / БЛИЖАЙШЕЕ --- - if re.search(r'когда|следующ|ближайш', text): - now = datetime.now(timezone.utc) - next_event = db_session.exec( - select(Event) - .where(Event.is_public == True) - .where(Event.start_time >= now) - .order_by(Event.start_time) - .limit(1) - ).first() - - if next_event: - local_time = next_event.start_time.strftime('%d.%m в %H:%M') - return f"Ближайшее событие: {next_event.title} ({local_time}). Место: {next_event.location or 'не указано'}." - else: - return "В ближайшее время событий в расписании нет." - - # --- ИНТЕНТ: РАСПИСАНИЕ НА СЕГОДНЯ --- - if re.search(r'сегодня|седня', text): - # Берем начало сегодняшнего дня и конец сегодняшнего дня - now = datetime.now(timezone.utc) - start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) - end_of_day = start_of_day + timedelta(days=1) - - events = db_session.exec( - select(Event) - .where(Event.is_public == True) - .where(Event.start_time >= start_of_day) - .where(Event.start_time < end_of_day) - .order_by(Event.start_time) - ).all() - - if not events: - return "На сегодня событий в расписании нет." - - res = "Расписание на сегодня:\n" - for e in events: - res += f"• {e.start_time.strftime('%H:%M')} - {e.title} ({e.location or 'без места'})\n" - return res - - # --- ИНТЕНТ: РАСПИСАНИЕ НА НЕДЕЛЮ --- - if re.search(r'недел|7 дней', text): - now = datetime.now(timezone.utc) - end_of_week = now + timedelta(days=7) - - events = db_session.exec( - select(Event) - .where(Event.is_public == True) - .where(Event.start_time >= now) - .where(Event.start_time <= end_of_week) - .order_by(Event.start_time) - ).all() - - if not events: - return "На ближайшую неделю событий нет." - - res = "Расписание на неделю:\n" - for e in events[:5]: # Показываем первые 5, чтобы не спамить - res += f"• {e.start_time.strftime('%d.%m %H:%M')} - {e.title}\n" - if len(events) > 5: - res += "...и другие события (смотрите на странице)." - return res - - # --- ИНТЕНТ: КОНТАКТЫ --- - if re.search(r'связаться|контакт|телеграм|почта|кабинет', text): - return "Связаться с Никитой Сергеевичем можно по почте: freezer.nik@gmail.com (или в Telegram)." - - # --- ЕСЛИ НИЧЕГО НЕ ПОНЯЛ --- - return "Извините, я пока не понимаю этот вопрос. Попробуйте спросить: 'Где сейчас?', 'Когда следующая пара?', 'Что сегодня?' или 'Как связаться?'." diff --git a/app/auth.py.bak b/app/auth.py.bak deleted file mode 100644 index 3ea27cc..0000000 --- a/app/auth.py.bak +++ /dev/null @@ -1,24 +0,0 @@ -import requests -from . import config - -def get_gitea_token(code: str): - """Меняет временный код на access token.""" - url = f"{config.GITEA_URL}/login/oauth/access_token" - data = { - "client_id": config.GITEA_CLIENT_ID, - "client_secret": config.GITEA_CLIENT_SECRET, - "code": code, - "grant_type": "authorization_code", - "redirect_uri": "https://portal.morozovka.space/auth/callback" - } - response = requests.post(url, data=data, headers={"Accept": "application/json"}) - return response.json().get("access_token") - -def get_gitea_user(token: str): - """Получает данные пользователя из Gitea по токену.""" - url = f"{config.GITEA_URL}/api/v1/user" - headers = {"Authorization": f"token {token}"} - response = requests.get(url, headers=headers) - if response.status_code == 200: - return response.json() - return None diff --git a/app/config.py b/app/config.py index 8fd6f38..0cdfb2a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,8 +1,27 @@ import os from pathlib import Path +# ── Загрузка .env ───────────────────────────────────────────────────── +# Раньше .env не читался вообще (gunicorn не читает, systemd override сломан). +# Теперь явно грузим его ДО os.getenv(). Путь — /opt/morozovka-portal/.env BASE_DIR = Path(__file__).resolve().parent.parent +_ENV_FILE = BASE_DIR / ".env" +try: + from dotenv import load_dotenv + if _ENV_FILE.is_file(): + load_dotenv(_ENV_FILE, override=False) # override=False — systemd Environment важнее +except ImportError: + # python-dotenv не установлен — просто читаем вручную (без парсинга кавычек, нам хватит) + if _ENV_FILE.is_file(): + for line in _ENV_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + k, v = k.strip(), v.strip().strip('"').strip("'") + os.environ.setdefault(k, v) +# ── База данных ─────────────────────────────────────────────────────── POSTGRES_USER = os.getenv("POSTGRES_USER", "morozovka") POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "rf-slf-1945") POSTGRES_DB = os.getenv("POSTGRES_DB", "morozovka_portal") @@ -11,23 +30,42 @@ POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" +# ── Gitea ───────────────────────────────────────────────────────────── GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000") GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space") -GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa" -GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a" - -# Personal Access Token для Gitea API (для чтения приватных репо). -# Создать: https://git.morozovka.space → Settings → Applications → Generate New Token -# Scopes: read:repository, read:user -# Пусто — работает только для публичных репозиториев. +GITEA_CLIENT_ID = os.getenv("GITEA_CLIENT_ID", "66697efe-d88b-41fe-b3d5-6498d978e1fa") +GITEA_CLIENT_SECRET = os.getenv("GITEA_CLIENT_SECRET", "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a") GITEA_API_TOKEN = os.getenv("GITEA_API_TOKEN", "") +# Секрет для проверки подписи вебхуков Gitea. +# ВАЖНО: если пусто — вебхук принимается БЕЗ проверки подписи. +GITEA_WEBHOOK_SECRET = os.getenv("GITEA_WEBHOOK_SECRET", "") + +# ── Сессии / подписи ────────────────────────────────────────────────── WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026") -SMTP_SERVER = os.getenv("SMTP_SERVER", "") +# ── SMTP ────────────────────────────────────────────────────────────── +SMTP_SERVER = os.getenv("SMTP_SERVER", "") SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_USER = os.getenv("SMTP_USER", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space") GITEA_REDIRECT_URI = os.getenv("GITEA_REDIRECT_URI", "https://portal.morozovka.space/auth/callback") + +# ── Google Calendar sync ────────────────────────────────────────────── +# Раньше CALENDAR_ID был захардкожен в google_sync.py — вынесли в env. +GOOGLE_CALENDAR_ID = os.getenv("GOOGLE_CALENDAR_ID", "korneshoc@gmail.com") +GOOGLE_CREDENTIALS_FILE = os.getenv( + "GOOGLE_CREDENTIALS_FILE", + str(BASE_DIR / "google_credentials.json"), +) + +# ── LLM ─────────────────────────────────────────────────────────────── LLM_ENABLED = os.getenv("LLM_ENABLED", "false").lower() == "true" +LLM_API_BASE = os.getenv("LLM_API_BASE", "http://localhost:11434/v1") +LLM_API_KEY = os.getenv("LLM_API_KEY", "ollama") +LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:14b") +LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "1024")) +LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0.2")) +LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "120")) +LLM_MAX_HISTORY = int(os.getenv("LLM_MAX_HISTORY", "10")) diff --git a/app/config.py.bak b/app/config.py.bak deleted file mode 100644 index 2f8671f..0000000 --- a/app/config.py.bak +++ /dev/null @@ -1,27 +0,0 @@ -import os -from pathlib import Path - -BASE_DIR = Path(__file__).resolve().parent.parent - -POSTGRES_USER = os.getenv("POSTGRES_USER", "morozovka") -POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "rf-slf-1945") -POSTGRES_DB = os.getenv("POSTGRES_DB", "morozovka_portal") -POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1") -POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") - -# Добавляем psycopg2 в URL -DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" - -GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000") -GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space") -GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa" -GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a" - -WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026") - -# Настройки SMTP (если пусто, письма будут падать в консоль) -SMTP_SERVER = os.getenv("SMTP_SERVER", "") -SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) -SMTP_USER = os.getenv("SMTP_USER", "") -SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") -SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space") diff --git a/app/gitea_api.py b/app/gitea_api.py index 0812a1d..d6e0051 100644 --- a/app/gitea_api.py +++ b/app/gitea_api.py @@ -5,7 +5,7 @@ import requests from . import config _cache = {} -CACHE_TTL = 300 # 5 минут +CACHE_TTL = 300 def _headers(): @@ -16,11 +16,9 @@ def _headers(): def _cache_get(key): entry = _cache.get(key) - if not entry: - return None + if not entry: return None ts, data = entry - if time.time() - ts > CACHE_TTL: - return None + if time.time() - ts > CACHE_TTL: return None return data @@ -28,18 +26,23 @@ def _cache_set(key, data): _cache[key] = (time.time(), data) +def invalidate_cache(repo_full_name): + """Сбросить весь кэш для указанного репо (owner/name).""" + keys_to_drop = [k for k in _cache if f":{repo_full_name}:" in k or k.endswith(f":{repo_full_name}")] + for k in keys_to_drop: + _cache.pop(k, None) + return len(keys_to_drop) + + def _api_get(path, params=None): - """Возвращает (data, total) или (None, None) при ошибке.""" url = f"{config.GITEA_URL}/api/v1{path}" try: r = requests.get(url, headers=_headers(), params=params, timeout=5) if r.status_code == 200: total_header = r.headers.get("X-Total-Count") total = int(total_header) if total_header and total_header.isdigit() else None - try: - data = r.json() - except Exception: - data = None + try: data = r.json() + except Exception: data = None return data, total return None, None except Exception as e: @@ -48,44 +51,37 @@ def _api_get(path, params=None): def extract_repo_from_url(url): - """https://git.morozovka.space/owner/repo(.git)? → owner/repo""" - if not url: - return None + if not url: return None m = re.search(r"[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", url.strip()) - if m: - return f"{m.group(1)}/{m.group(2)}" + if m: return f"{m.group(1)}/{m.group(2)}" return None def get_repo_info(owner, repo): - key = f"repo:{owner}/{repo}" + key = f"repo:{owner}/{repo}:info" cached = _cache_get(key) - if cached is not None: - return cached + if cached is not None: return cached data, _ = _api_get(f"/repos/{owner}/{repo}") _cache_set(key, data) return data def get_latest_commits(owner, repo, limit=5): - key = f"commits:{owner}/{repo}:{limit}" + key = f"repo:{owner}/{repo}:commits:{limit}" cached = _cache_get(key) - if cached is not None: - return cached - data, _ = _api_get( - f"/repos/{owner}/{repo}/commits", - params={"limit": limit, "page": 1, "stat": "false"}, - ) + if cached is not None: return cached + data, _ = _api_get(f"/repos/{owner}/{repo}/commits", + params={"limit": limit, "page": 1, "stat": "false"}) result = [] if isinstance(data, list): for c in data[:limit]: - commit_obj = c.get("commit") or {} - author_obj = commit_obj.get("author") or {} + co = c.get("commit") or {} + ao = co.get("author") or {} result.append({ "sha_short": (c.get("sha") or "")[:7], - "message": (commit_obj.get("message") or "").split("\n")[0][:120], - "author": author_obj.get("name") or "", - "date": author_obj.get("date") or "", + "message": (co.get("message") or "").split("\n")[0][:120], + "author": ao.get("name") or "", + "date": ao.get("date") or "", "html_url": c.get("html_url") or "", }) _cache_set(key, result) @@ -93,52 +89,38 @@ def get_latest_commits(owner, repo, limit=5): def get_open_issues_count(owner, repo): - key = f"issues:{owner}/{repo}" + key = f"repo:{owner}/{repo}:issues" cached = _cache_get(key) - if cached is not None: - return cached - _, total = _api_get( - f"/repos/{owner}/{repo}/issues", - params={"state": "open", "type": "issues", "limit": 1}, - ) + if cached is not None: return cached + _, total = _api_get(f"/repos/{owner}/{repo}/issues", + params={"state": "open", "type": "issues", "limit": 1}) _cache_set(key, total or 0) return total or 0 def get_open_prs_count(owner, repo): - key = f"prs:{owner}/{repo}" + key = f"repo:{owner}/{repo}:prs" cached = _cache_get(key) - if cached is not None: - return cached - _, total = _api_get( - f"/repos/{owner}/{repo}/pulls", - params={"state": "open", "limit": 1}, - ) + if cached is not None: return cached + _, total = _api_get(f"/repos/{owner}/{repo}/pulls", + params={"state": "open", "limit": 1}) _cache_set(key, total or 0) return total or 0 def get_project_stats(gitea_repo): - """gitea_repo: 'owner/repo'. Возвращает dict для JSON-ответа.""" if not gitea_repo or "/" not in gitea_repo: return {"ok": False, "error": "no_repo", "message": "Не указан репозиторий проекта."} - owner, repo = gitea_repo.split("/", 1) info = get_repo_info(owner, repo) if info is None: - # 404 или 401 — не смогли прочитать if not config.GITEA_API_TOKEN: return {"ok": False, "error": "auth_required", "message": "Репозиторий приватный или не найден. " "Настройте GITEA_API_TOKEN в config.py."} return {"ok": False, "error": "api_unreachable", "message": "Не удалось получить данные из Gitea."} - - commits = get_latest_commits(owner, repo, limit=5) - open_issues = get_open_issues_count(owner, repo) - open_prs = get_open_prs_count(owner, repo) - return { "ok": True, "repo": { @@ -150,7 +132,7 @@ def get_project_stats(gitea_repo): "forks": info.get("forks_count", 0), "is_private": bool(info.get("private")), }, - "commits": commits, - "open_issues": open_issues, - "open_prs": open_prs, + "commits": get_latest_commits(owner, repo, limit=5), + "open_issues": get_open_issues_count(owner, repo), + "open_prs": get_open_prs_count(owner, repo), } diff --git a/app/google_sync.py b/app/google_sync.py index ba8bf89..0bc0836 100644 --- a/app/google_sync.py +++ b/app/google_sync.py @@ -1,37 +1,71 @@ +"""Синхронизация Google Calendar → локальные Event. Запускается таймером.""" +import fcntl import os from datetime import datetime, timezone, timedelta + from google.oauth2 import service_account from googleapiclient.discovery import build from sqlmodel import Session, select + +from . import config from .db import engine from .models import Event SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] -SERVICE_ACCOUNT_FILE = '/opt/morozovka-portal/google_credentials.json' -# !!! ВАЖНО: Укажи здесь свой реальный email, к которому привязан календарь !!! -CALENDAR_ID = 'korneshoc@gmail.com' # <--- ИЗМЕНИ ЭТО +_LOCK_PATH = "/tmp/morozovka-google-sync.lock" + + +def _try_lock(): + """Не даём двум копиям sync работать одновременно (таймер + ручной запуск).""" + f = open(_LOCK_PATH, "w") + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + f.close() + return None + return f + def sync_google_calendar(): - """Забирает события из Google Calendar и обновляет локальную БД.""" + lock = _try_lock() + if lock is None: + print("[Google Sync] Уже выполняется другой процесс, выходим.", flush=True) + return + + try: + _sync_locked() + finally: + try: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + lock.close() + except Exception: + pass + + +def _sync_locked(): print("[Google Sync] Начинаем синхронизацию...", flush=True) - + + if not os.path.isfile(config.GOOGLE_CREDENTIALS_FILE): + print(f"[Google Sync] Нет файла credentials: {config.GOOGLE_CREDENTIALS_FILE}", flush=True) + return + creds = service_account.Credentials.from_service_account_file( - SERVICE_ACCOUNT_FILE, scopes=SCOPES) + config.GOOGLE_CREDENTIALS_FILE, scopes=SCOPES) service = build('calendar', 'v3', credentials=creds) - # Берем события начиная с 30 дней назад (чтобы точно захватить тесты) time_min = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() - - print(f"[Google Sync] Запрашиваем календарь: {CALENDAR_ID}", flush=True) + calendar_id = config.GOOGLE_CALENDAR_ID + + print(f"[Google Sync] Запрашиваем календарь: {calendar_id}", flush=True) events_result = service.events().list( - calendarId=CALENDAR_ID, + calendarId=calendar_id, timeMin=time_min, maxResults=100, singleEvents=True, - orderBy='startTime' + orderBy='startTime', ).execute() - + google_events = events_result.get('items', []) print(f"[Google Sync] Найдено событий в Google API: {len(google_events)}", flush=True) @@ -39,19 +73,18 @@ def sync_google_calendar(): with Session(engine) as session: for g_event in google_events: g_id = g_event['id'] - - # Парсим время + start = g_event['start'].get('dateTime', g_event['start'].get('date')) end = g_event['end'].get('dateTime', g_event['end'].get('date')) - - if 'T' not in start: + if 'T' not in start: continue start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) - # Ищем событие в БД по google_event_id - existing = session.exec(select(Event).where(Event.google_event_id == g_id)).first() + existing = session.exec( + select(Event).where(Event.google_event_id == g_id) + ).first() if existing: if getattr(existing, 'manually_edited', False): @@ -63,7 +96,7 @@ def sync_google_calendar(): existing.description = g_event.get('description') session.add(existing) else: - new_event = Event( + session.add(Event( google_event_id=g_id, title=g_event.get('summary', 'Без названия'), event_type='lecture', @@ -71,11 +104,10 @@ def sync_google_calendar(): end_time=end_dt, location=g_event.get('location'), description=g_event.get('description'), - is_public=True - ) - session.add(new_event) + is_public=True, + )) count += 1 - + session.commit() - - print(f"[Google Sync] Успешно сохранено новых событий в БД: {count}", flush=True) + + print(f"[Google Sync] Новых событий: {count}", flush=True) diff --git a/app/google_sync.py.bak b/app/google_sync.py.bak deleted file mode 100644 index 9eceac4..0000000 --- a/app/google_sync.py.bak +++ /dev/null @@ -1,79 +0,0 @@ -import os -from datetime import datetime, timezone, timedelta -from google.oauth2 import service_account -from googleapiclient.discovery import build -from sqlmodel import Session, select -from .db import engine -from .models import Event - -SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] -SERVICE_ACCOUNT_FILE = '/opt/morozovka-portal/google_credentials.json' - -# !!! ВАЖНО: Укажи здесь свой реальный email, к которому привязан календарь !!! -CALENDAR_ID = 'korneshoc@gmail.com' # <--- ИЗМЕНИ ЭТО - -def sync_google_calendar(): - """Забирает события из Google Calendar и обновляет локальную БД.""" - print("[Google Sync] Начинаем синхронизацию...", flush=True) - - creds = service_account.Credentials.from_service_account_file( - SERVICE_ACCOUNT_FILE, scopes=SCOPES) - service = build('calendar', 'v3', credentials=creds) - - # Берем события начиная с 30 дней назад (чтобы точно захватить тесты) - time_min = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() - - print(f"[Google Sync] Запрашиваем календарь: {CALENDAR_ID}", flush=True) - events_result = service.events().list( - calendarId=CALENDAR_ID, - timeMin=time_min, - maxResults=100, - singleEvents=True, - orderBy='startTime' - ).execute() - - google_events = events_result.get('items', []) - print(f"[Google Sync] Найдено событий в Google API: {len(google_events)}", flush=True) - - count = 0 - with Session(engine) as session: - for g_event in google_events: - g_id = g_event['id'] - - # Парсим время - start = g_event['start'].get('dateTime', g_event['start'].get('date')) - end = g_event['end'].get('dateTime', g_event['end'].get('date')) - - if 'T' not in start: - continue - - start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) - end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) - - # Ищем событие в БД по google_event_id - existing = session.exec(select(Event).where(Event.google_event_id == g_id)).first() - - if existing: - existing.title = g_event.get('summary', 'Без названия') - existing.start_time = start_dt - existing.end_time = end_dt - existing.location = g_event.get('location') - existing.description = g_event.get('description') - session.add(existing) - else: - new_event = Event( - google_event_id=g_id, - title=g_event.get('summary', 'Без названия'), - event_type='lecture', - start_time=start_dt, - end_time=end_dt, - location=g_event.get('location'), - description=g_event.get('description'), - is_public=True - ) - session.add(new_event) - count += 1 - - session.commit() - - print(f"[Google Sync] Успешно сохранено новых событий в БД: {count}", flush=True) diff --git a/app/helpers.py b/app/helpers.py new file mode 100644 index 0000000..86fc949 --- /dev/null +++ b/app/helpers.py @@ -0,0 +1,628 @@ +"""Общие хелперы, константы, декораторы, утилиты для всего приложения.""" +import os +import uuid +import smtplib +import pytz +import threading +from datetime import datetime, timezone, timedelta +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from functools import wraps +from itsdangerous import URLSafeTimedSerializer +from flask import session, redirect, url_for +from sqlmodel import Session, select +from .db import engine +from .models import ( + User, Event, GlobalStatus, Project, PortalFile, UserSession, + ProjectMember, EventSubscriber, ProjectTask, + ProjectPost, ProjectPostAttachment, ProjectSubscription, + Workshop, WorkshopAssistant, WorkshopSession, WorkshopRegistration, + WorkshopMaterial, WorkshopFeedback, WorkshopPost, + WorkshopPostAttachment, WorkshopSubscription, Notification, + # ── v2.0 ──────────────────────────────────────────────── + FileSpace, FileFolder, FileSpaceACL, FileFolderACL, FileVersion, +) +from . import config, gitea_api + +serializer = URLSafeTimedSerializer(config.WEB_SECRET) +MSK = pytz.timezone("Europe/Moscow") +IMAGE_EXTS = {"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"} + + +# ══════════════════════════════════════════════════════════════════════ +# ФИЛЬТРЫ И MSK +# ══════════════════════════════════════════════════════════════════════ + +def to_msk(dt): + if dt is None: return None + if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(MSK) + + +def msk_strftime(dt, fmt): + if dt is None: return "" + return to_msk(dt).strftime(fmt) + + +def date_ru(d): + if not d: return "" + try: + return datetime.strptime(str(d)[:10], "%Y-%m-%d").strftime("%d.%m.%Y") + except Exception: + return str(d) + + +def is_image(filename): + if not filename or "." not in filename: return False + return filename.rsplit(".", 1)[-1].lower() in IMAGE_EXTS + + +def file_icon(filename): + if not filename or "." not in filename: return "📎" + ext = filename.rsplit(".", 1)[-1].lower() + if ext in IMAGE_EXTS: return "🖼" + if ext == "pdf": return "📄" + if ext in {"doc", "docx", "odt", "rtf"}: return "📝" + if ext in {"xls", "xlsx", "ods", "csv"}: return "📊" + if ext in {"ppt", "pptx", "odp"}: return "📽" + if ext in {"zip", "rar", "7z", "tar", "gz", "bz2"}: return "🗜" + if ext in {"mp3", "wav", "ogg", "flac", "m4a"}: return "🎵" + if ext in {"mp4", "mov", "avi", "mkv", "webm"}: return "🎬" + if ext in {"py", "js", "ts", "html", "css", "json", "yml", "yaml", "md", "txt"}: return "📃" + return "📎" + +def format_size(size_bytes): + """Человекочитаемый размер: '22.3 КБ', '5.1 МБ', '512 Б'.""" + if size_bytes is None: + return "—" + try: + n = int(size_bytes) + except (TypeError, ValueError): + return "—" + if n < 1024: + return f"{n} Б" + if n < 1024 * 1024: + return f"{n / 1024:.1f} КБ" + if n < 1024 * 1024 * 1024: + return f"{n / 1024 / 1024:.1f} МБ" + return f"{n / 1024 / 1024 / 1024:.2f} ГБ" + + +def register_filters(app): + app.add_template_filter(msk_strftime, "msk_strftime") + app.add_template_filter(date_ru, "date_ru") + app.add_template_filter(is_image, "is_image") + app.add_template_filter(file_icon, "file_icon") + app.add_template_filter(format_size, "format_size") + +# ══════════════════════════════════════════════════════════════════════ +# КОНСТАНТЫ +# ══════════════════════════════════════════════════════════════════════ + +STATUS_LABELS = {"free": "Свободен / Вне расписания", "on_place": "На месте", + "away": "Вне вуза", "cancelled": "Отменено"} +PROJECT_ROLE_RANK = {"viewer": 1, "editor": 2, "manager": 3} +TASK_STATUSES = ["backlog", "todo", "in_progress", "review", "done"] +TASK_STATUS_LABELS = {"backlog": "Backlog", "todo": "To Do", "in_progress": "In Progress", + "review": "Review", "done": "Done"} +TASK_PRIORITIES = ["low", "medium", "high", "critical"] +WORKSHOP_CATEGORIES = ["soldering", "embroidery", "embedded", "linux", "other"] +WORKSHOP_CATEGORY_LABELS = {"soldering": "Пайка", "embroidery": "Бисероплетение", + "embedded": "Embedded", "linux": "Linux", "other": "Другое"} +WORKSHOP_DIFFICULTIES = ["beginner", "intermediate", "advanced"] +WORKSHOP_DIFFICULTY_LABELS = {"beginner": "Новичок", "intermediate": "Средний", + "advanced": "Продвинутый"} +WORKSHOP_STATUSES = ["draft", "published", "archived"] +WORKSHOP_STATUS_LABELS = {"draft": "Черновик", "published": "Опубликован", "archived": "В архиве"} +WORKSHOP_VISIBILITIES = ["public", "internal"] +WORKSHOP_VISIBILITY_LABELS = {"public": "Публичный", "internal": "Внутренний"} +RECURRENCE_PRESETS = [ + ("once", "Разово"), ("on_demand", "По объявлению"), + ("weekly:mon", "1×/нед (пн)"), ("weekly:tue", "1×/нед (вт)"), + ("weekly:thu", "1×/нед (чт)"), ("weekly:sat", "1×/нед (сб)"), + ("twice_weekly:tue,thu", "2×/нед (вт, чт)"), + ("twice_weekly:thu,sat", "2×/нед (чт, сб)"), + ("twice_weekly:mon,wed", "2×/нед (пн, ср)"), +] +RECURRENCE_LABELS = dict(RECURRENCE_PRESETS) + + +# ══════════════════════════════════════════════════════════════════════ +# ДЕКОРАТОРЫ +# ══════════════════════════════════════════════════════════════════════ + +def require_role(allowed_roles): + if isinstance(allowed_roles, str): allowed_roles = [allowed_roles] + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if session.get("role") not in allowed_roles: + return "403: Доступ запрещен.", 403 + return f(*args, **kwargs) + return decorated_function + return decorator + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get("user_id"): + return redirect(url_for("login")) + return f(*args, **kwargs) + return decorated_function + + +# ══════════════════════════════════════════════════════════════════════ +# ОБЩИЕ ХЕЛПЕРЫ +# ══════════════════════════════════════════════════════════════════════ + +def parse_datetime(dt_str): + if not dt_str: return None + clean_str = dt_str.replace('T', ' ').strip() + if len(clean_str) > 16: clean_str = clean_str[:16] + naive = datetime.strptime(clean_str, "%Y-%m-%d %H:%M") + return MSK.localize(naive).astimezone(timezone.utc) + + +def normalize_tags(raw): + if not raw: return None + parts = {t.strip().lower() for t in raw.split(",") if t.strip()} + return ",".join(sorted(parts)) if parts else None + + +def status_label(status_val, delay): + if status_val == "delayed": return f"Опаздываю на {delay} мин" + return STATUS_LABELS.get(status_val, status_val) + + +def event_summary_text(ev): + start = to_msk(ev.start_time).strftime('%d.%m.%Y %H:%M') + end = to_msk(ev.end_time).strftime('%H:%M') + lines = [f"Название: {ev.title}", f"Тип: {ev.event_type}", f"Когда: {start} – {end}"] + if ev.location: lines.append(f"Место: {ev.location}") + if ev.description: lines.append(f"Описание: {ev.description}") + return "\n".join(lines) + + +def project_permissions(project, user_id, global_role, membership): + is_admin = global_role == "admin" + is_owner = user_id is not None and project.owner_id == user_id + if is_admin or is_owner: + return { + "is_admin": is_admin, "is_owner": is_owner, "project_role": "manager", + "can_manage": True, "can_edit": True, "can_delete": True, "can_upload": True, + "can_view_files": True, "can_edit_files": True, "can_view_repo": True, + "can_post_feed": True, "can_edit_own_posts": True, "can_edit_any_post": True, + "can_manage_members": True, "can_edit_project": True, + } + if not membership: + return { + "is_admin": False, "is_owner": False, "project_role": None, + "can_manage": False, "can_edit": False, "can_delete": False, "can_upload": False, + "can_view_files": False, "can_edit_files": False, "can_view_repo": False, + "can_post_feed": False, "can_edit_own_posts": False, "can_edit_any_post": False, + "can_manage_members": False, "can_edit_project": False, + } + m = membership + return { + "is_admin": False, "is_owner": False, "project_role": m.role, + "can_manage": m.can_manage_members, + "can_edit": m.can_edit_files or m.can_post_feed, + "can_delete": False, + "can_upload": m.can_edit_files, + "can_view_files": m.can_view_files, + "can_edit_files": m.can_edit_files, + "can_view_repo": m.can_view_repo, + "can_post_feed": m.can_post_feed, + "can_edit_own_posts": m.can_edit_own_posts, + "can_edit_any_post": m.can_edit_any_post, + "can_manage_members": m.can_manage_members, + "can_edit_project": m.can_manage_members, + } + + +def workshop_permissions(workshop, user_id, global_role, is_assistant=False): + is_admin = global_role == "admin" + is_lead = user_id is not None and workshop.lead_id == user_id + can_manage = is_admin or is_lead or is_assistant + return { + "is_admin": is_admin, + "is_lead": is_lead, + "is_assistant": is_assistant, + "can_manage": can_manage, + "can_delete": can_manage, + "can_delete_workshop": is_admin or is_lead, + "can_manage_assistants": is_admin or is_lead, + "can_post": can_manage, + } + + +def _ws_assistant_ids(db_session, workshop_id): + rows = db_session.exec(select(WorkshopAssistant) + .where(WorkshopAssistant.workshop_id == workshop_id)).all() + return {r.user_id for r in rows} + + +def _ws_assistants_full(db_session, workshop_id): + return db_session.exec( + select(WorkshopAssistant, User) + .join(User, WorkshopAssistant.user_id == User.id) + .where(WorkshopAssistant.workshop_id == workshop_id) + .order_by(User.username) + ).all() + + +def _ws_perms_for(db_session, ws, user_id, global_role): + is_assistant = (user_id in _ws_assistant_ids(db_session, ws.id)) if user_id else False + return workshop_permissions(ws, user_id, global_role, is_assistant) + + +def _ws_registration(db_session, workshop_id, user_id): + if not user_id: return None + return db_session.exec(select(WorkshopRegistration) + .where(WorkshopRegistration.workshop_id == workshop_id) + .where(WorkshopRegistration.user_id == user_id)).first() + + +def _ws_session_helpers(db_session, workshop_id): + return db_session.exec(select(WorkshopSession) + .where(WorkshopSession.workshop_id == workshop_id) + .order_by(WorkshopSession.start_time)).all() + + +def _ws_count_registered(db_session, workshop_id): + regs = db_session.exec(select(WorkshopRegistration) + .where(WorkshopRegistration.workshop_id == workshop_id) + .where(WorkshopRegistration.status.in_(("registered", "attended")))).all() + return len(regs) + + +def _ws_subscription(db_session, workshop_id, user_id): + if not user_id: return None + return db_session.exec(select(WorkshopSubscription) + .where(WorkshopSubscription.workshop_id == workshop_id) + .where(WorkshopSubscription.user_id == user_id)).first() + + +def _project_subscription(db_session, project_id, user_id): + if not user_id: return None + return db_session.exec(select(ProjectSubscription) + .where(ProjectSubscription.project_id == project_id) + .where(ProjectSubscription.user_id == user_id)).first() + + +def _ws_subscriber_ids(db_session, workshop_id): + subs = db_session.exec(select(WorkshopSubscription) + .where(WorkshopSubscription.workshop_id == workshop_id)).all() + return [s.user_id for s in subs if s.user_id] + + +def _project_subscriber_ids(db_session, project_id): + subs = db_session.exec(select(ProjectSubscription) + .where(ProjectSubscription.project_id == project_id)).all() + return [s.user_id for s in subs if s.user_id] + + +def get_all_tags(db_session): + projects = db_session.exec(select(Project)).all() + tags = set() + for p in projects: + if p.tags: + for t in p.tags.split(","): + t = t.strip().lower() + if t: tags.add(t) + return sorted(tags) + + +def resolve_gitea_repo(form_data, current_url=None, current_repo=None): + raw_repo = (form_data.get("gitea_repo") or "").strip() or None + raw_url = (form_data.get("gitea_url") or "").strip() or None + if raw_repo: + raw_repo = raw_repo.strip("/") + if "/" in raw_repo: return raw_repo + if raw_url: + parsed = gitea_api.extract_repo_from_url(raw_url) + if parsed: return parsed + return None + + +def get_user_subscription(db_session, user): + if not user or (not user.id and not user.email): return None + from sqlalchemy import or_ + conds = [] + if user.id: conds.append(EventSubscriber.user_id == user.id) + if user.email: conds.append(EventSubscriber.email == user.email) + if not conds: return None + return db_session.exec(select(EventSubscriber).where(or_(*conds))).first() + + +def _save_uploaded_files(file_objs, user_id, project_id=None, upload_folder=None, + space_id=None, folder_id=None, description=None, tags=None): + """ + Сохранить файлы. Поддерживает v2.0 (пространства/папки/версии). + + Если space_id задан — создаётся FileVersion(version_num=1) и + current_version_id указывает на неё (файл «версионированный»). + Иначе — старая логика (current_version_id=None, блоб в file.stored_name). + """ + saved_ids = [] + folder = upload_folder or "/var/lib/gitea/portal-files" + for file in file_objs: + if not file or not file.filename: + continue + ext = os.path.splitext(file.filename)[1] + stored_name = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(folder, stored_name) + try: + file.save(filepath) + size_bytes = os.path.getsize(filepath) + except Exception as e: + print(f"[upload] save failed: {e}", flush=True) + continue + try: + with Session(engine) as s: + pf = PortalFile( + original_name=file.filename, + stored_name=stored_name, + size_bytes=size_bytes, + uploaded_by=user_id, + project_id=project_id, + space_id=space_id, + folder_id=folder_id, + description=description, + tags=tags, + version=1, + ) + s.add(pf) + s.flush() + # Версия 1 — только для файлов в пространстве + if space_id is not None: + fv = FileVersion( + file_id=pf.id, + version_num=1, + stored_name=stored_name, + size_bytes=size_bytes, + uploaded_by=user_id, + comment="Первая версия", + ) + s.add(fv) + s.flush() + pf.current_version_id = fv.id + s.add(pf) + s.commit() + saved_ids.append(pf.id) + except Exception as e: + print(f"[upload] db failed: {e}", flush=True) + try: + os.remove(filepath) + except Exception: + pass + return saved_ids + +# ══════════════════════════════════════════════════════════════════════ +# EMAIL И УВЕДОМЛЕНИЯ +# ══════════════════════════════════════════════════════════════════════ + +def send_email(to_email, subject, body): + if not config.SMTP_SERVER: + print("\n" + "="*60, flush=True) + print(f"📧 EMAIL ДЛЯ: {to_email}\nТема: {subject}\n{body}", flush=True) + print("="*60 + "\n", flush=True) + return True + try: + msg = MIMEMultipart(); msg['From'] = config.SMTP_FROM + msg['To'] = to_email; msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT) + server.starttls(); server.login(config.SMTP_USER, config.SMTP_PASSWORD) + server.send_message(msg); server.quit() + return True + except Exception as e: + print(f"Ошибка отправки email: {e}", flush=True); return False + + +def notify_user(user_id, title, body=None, link_url=None, kind="info"): + if not user_id: return + try: + with Session(engine) as s: + s.add(Notification(user_id=user_id, title=title, body=body, link_url=link_url, kind=kind)) + s.commit() + except Exception as e: + print(f"[notify_user] {e}", flush=True) + + +def notify_users(user_ids, title, body=None, link_url=None, kind="info"): + for uid in set(uid for uid in user_ids if uid): + notify_user(uid, title, body, link_url, kind) + + +def _notify_subscribers_sync(subject, body, kind): + try: + with Session(engine) as db_session: + subscribers = db_session.exec(select(EventSubscriber) + .where(EventSubscriber.is_confirmed == True)).all() + for sub in subscribers: + if kind == "status" and not sub.notify_status: continue + if kind == "event" and not sub.notify_events: continue + unsubscribe_url = f"https://portal.morozovka.space/unsubscribe/{sub.unsubscribe_token}" + full_body = body + "\n\n---\nhttps://portal.morozovka.space/schedule\nОтписаться: " + unsubscribe_url + send_email(sub.email, subject, full_body) + except Exception as e: + print(f"[notify] Ошибка рассылки: {e}", flush=True) + + +def notify_subscribers(subject, body, kind): + threading.Thread(target=_notify_subscribers_sync, args=(subject, body, kind), daemon=True).start() + + +# ══════════════════════════════════════════════════════════════════════ +# v2.0: ПРОСТРАНСТВА ФАЙЛОВ +# ══════════════════════════════════════════════════════════════════════ + +import re as _re_for_slug + + +def _slugify(text): + """Транслитерация + slug. Простая — для русских названий.""" + _map = { + 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z', + 'и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r', + 'с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'ts','ч':'ch','ш':'sh','щ':'sch', + 'ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya', + } + s = (text or "").lower().strip() + s = "".join(_map.get(c, c) for c in s) + s = _re_for_slug.sub(r'[^a-z0-9]+', '-', s).strip('-') + return s or f"space-{uuid.uuid4().hex[:8]}" + + +def _unique_space_slug(db_session, base): + """Гарантировать уникальность slug — добавить -2/-3/... при коллизии.""" + slug = base + i = 2 + while db_session.exec(select(FileSpace).where(FileSpace.slug == slug)).first(): + slug = f"{base}-{i}" + i += 1 + return slug + + +def _get_space_acl(db_session, space_id, user_id): + if not user_id: + return None + return db_session.exec( + select(FileSpaceACL) + .where(FileSpaceACL.space_id == space_id) + .where(FileSpaceACL.user_id == user_id) + ).first() + + +def _get_folder_acl(db_session, folder_id, user_id): + if not user_id: + return None + return db_session.exec( + select(FileFolderACL) + .where(FileFolderACL.folder_id == folder_id) + .where(FileFolderACL.user_id == user_id) + ).first() + + +def get_space_permissions(space, user_id, global_role, acl_entry=None): + """Права на пространство. Возвращает dict.""" + is_admin = global_role == "admin" + is_owner = user_id is not None and space.owner_id == user_id + if is_admin or is_owner: + return {"can_view": True, "can_upload": True, "can_delete": True, + "can_manage": True, "is_owner": is_owner, "is_admin": is_admin} + if acl_entry: + role = acl_entry.role + return { + "can_view": True, + "can_upload": role in ("editor", "manager"), + "can_delete": role in ("editor", "manager"), + "can_manage": role == "manager", + "is_owner": False, + "is_admin": False, + } + # Нет ACL — по видимости + can_view = False + if space.visibility == "public": + can_view = True + elif space.visibility == "internal" and user_id: + can_view = True + return {"can_view": can_view, "can_upload": False, "can_delete": False, + "can_manage": False, "is_owner": False, "is_admin": is_admin} + + +def get_folder_permissions(folder, user_id, global_role, space_perms, acl_entry=None): + """ + Права на папку. Если ACL на папке нет — наследуются от пространства. + """ + if global_role == "admin" or space_perms.get("is_owner") or space_perms.get("can_manage"): + return {"can_view": True, "can_upload": True, "can_delete": True, "can_manage": True} + if acl_entry: + return { + "can_view": acl_entry.can_view, + "can_upload": acl_entry.can_upload, + "can_delete": acl_entry.can_delete, + "can_manage": acl_entry.can_manage, + } + # Наследование от пространства + return { + "can_view": space_perms.get("can_view", False), + "can_upload": space_perms.get("can_upload", False), + "can_delete": space_perms.get("can_delete", False), + "can_manage": False, # управление ACL папки — только явно + } + + +def check_file_access(db_session, file, user_id, global_role): + """ + Права на конкретный файл. + Если file.space_id задан — наследуем от папки/пространства. + Если нет — старые правила (uploaded_by / admin / project member). + """ + # Файл без пространства — старая логика + if file.space_id is None: + is_admin = global_role == "admin" + is_uploader = user_id is not None and file.uploaded_by == user_id + return { + "can_view": bool(user_id) or is_admin, # залогиненным — видно + "can_download": bool(user_id) or is_admin, + "can_delete": is_admin or is_uploader, + "can_edit": is_admin or is_uploader, + } + + space = db_session.get(FileSpace, file.space_id) + if not space: + return {"can_view": False, "can_download": False, "can_delete": False, "can_edit": False} + + space_acl = _get_space_acl(db_session, space.id, user_id) + space_perms = get_space_permissions(space, user_id, global_role, space_acl) + + folder_perms = space_perms + if file.folder_id: + folder = db_session.get(FileFolder, file.folder_id) + if folder: + folder_acl = _get_folder_acl(db_session, folder.id, user_id) + folder_perms = get_folder_permissions(folder, user_id, global_role, space_perms, folder_acl) + + can_edit = folder_perms.get("can_upload", False) or (user_id == file.uploaded_by) + can_delete = folder_perms.get("can_delete", False) or (user_id == file.uploaded_by) + return { + "can_view": folder_perms.get("can_view", False), + "can_download": folder_perms.get("can_view", False), + "can_delete": can_delete or global_role == "admin", + "can_edit": can_edit or global_role == "admin", + } + + +def build_breadcrumbs(db_session, space=None, folder=None): + """ + Собирает хлебные крошки до папки (или до пространства). + Возвращает список dict: [{id, name, url, type}] + Первым всегда идёт «Файлы» → /files. + """ + crumbs = [{"name": "Файлы", "url": "/files", "type": "root"}] + if space: + crumbs.append({ + "name": space.name, + "url": f"/files/space/{space.id}", + "type": "space", + }) + if folder and space: + chain = [] + cur = folder + seen = set() + while cur and cur.id not in seen: + seen.add(cur.id) + chain.append(cur) + if cur.parent_id is None: + break + cur = db_session.get(FileFolder, cur.parent_id) + for f in reversed(chain): + crumbs.append({ + "name": f.name, + "url": f"/files/folder/{f.id}", + "type": "folder", + }) + return crumbs diff --git a/app/main.py b/app/main.py index 7fed881..aba904c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,1970 +1,99 @@ -import os -import uuid -import smtplib -import pytz -import threading -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from itsdangerous import URLSafeTimedSerializer -from werkzeug.security import generate_password_hash, check_password_hash -from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory, make_response -from sqlalchemy import or_ -from icalendar import Calendar, Event as ICalEvent -from .db import init_db, engine -from . import config, auth, assistant, gitea_api, llm_assistant +from flask import Flask, jsonify, redirect, url_for, session, render_template +from .db import engine +from . import config +from .helpers import register_filters +from .models import UserSession, GlobalStatus, UserOnboarding from sqlmodel import Session, select -from .models import ( - User, Event, GlobalStatus, Project, PortalFile, UserSession, - ProjectMember, EventSubscriber, ProjectTask, - Workshop, WorkshopSession, WorkshopRegistration, - WorkshopMaterial, WorkshopFeedback, Notification, +from .modules import ( + auth_routes, + notifications_routes, + schedule_routes, + projects_routes, + workshops_routes, + files_routes, + admin_routes, + gitea_routes, + feed_routes, + onboarding_routes, + dashboard_routes, ) -from datetime import datetime, timezone, timedelta -from functools import wraps app = Flask(__name__) app.secret_key = config.WEB_SECRET -serializer = URLSafeTimedSerializer(app.secret_key) - -MSK = pytz.timezone("Europe/Moscow") - - -# ══════════════════════════════════════════════════════════════════════ -# БАЗОВЫЕ ХЕЛПЕРЫ И ДЕКОРАТОРЫ (должны быть ДО всех @app.route) -# ══════════════════════════════════════════════════════════════════════ - -def to_msk(dt): - if dt is None: return None - if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(MSK) - - -@app.template_filter("msk_strftime") -def _msk_strftime(dt, fmt): - if dt is None: return "" - return to_msk(dt).strftime(fmt) - - -@app.template_filter("date_ru") -def _date_ru(d): - if not d: return "" - try: - return datetime.strptime(str(d)[:10], "%Y-%m-%d").strftime("%d.%m.%Y") - except Exception: - return str(d) - - -def require_role(allowed_roles): - if isinstance(allowed_roles, str): - allowed_roles = [allowed_roles] - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if session.get("role") not in allowed_roles: - return "403: Доступ запрещен.", 403 - return f(*args, **kwargs) - return decorated_function - return decorator - - -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not session.get("user_id"): - return redirect(url_for("login")) - return f(*args, **kwargs) - return decorated_function - - -def parse_datetime(dt_str): - if not dt_str: return None - clean_str = dt_str.replace('T', ' ').strip() - if len(clean_str) > 16: clean_str = clean_str[:16] - naive = datetime.strptime(clean_str, "%Y-%m-%d %H:%M") - return MSK.localize(naive).astimezone(timezone.utc) - - -def normalize_tags(raw): - if not raw: return None - parts = {t.strip().lower() for t in raw.split(",") if t.strip()} - return ",".join(sorted(parts)) if parts else None - - -def status_label(status_val, delay): - if status_val == "delayed": - return f"Опаздываю на {delay} мин" - return STATUS_LABELS.get(status_val, status_val) - - -def event_summary_text(ev): - start = to_msk(ev.start_time).strftime('%d.%m.%Y %H:%M') - end = to_msk(ev.end_time).strftime('%H:%M') - lines = [f"Название: {ev.title}", f"Тип: {ev.event_type}", f"Когда: {start} – {end}"] - if ev.location: lines.append(f"Место: {ev.location}") - if ev.description: lines.append(f"Описание: {ev.description}") - return "\n".join(lines) - - -def project_permissions(project, user_id, global_role, membership): - is_admin = global_role == "admin" - is_owner = user_id is not None and project.owner_id == user_id - project_role = None - if is_owner: - project_role = "manager" - elif membership: - project_role = membership.role - can_manage = is_admin or is_owner or project_role == "manager" - can_edit = can_manage or project_role == "editor" - can_delete = is_admin or is_owner - can_upload = can_edit - return { - "is_admin": is_admin, - "is_owner": is_owner, - "project_role": project_role, - "can_manage": can_manage, - "can_edit": can_edit, - "can_delete": can_delete, - "can_upload": can_upload, - } - - -def workshop_permissions(workshop, user_id, global_role): - is_admin = global_role in ("admin", "professor") - is_lead = user_id is not None and workshop.lead_id == user_id - can_manage = is_admin or is_lead - return { - "is_admin": global_role == "admin", - "is_lead": is_lead, - "can_manage": can_manage, - "can_delete": global_role == "admin" or is_lead, - } - - -def _ws_registration(db_session, workshop_id, user_id): - if not user_id: - return None - return db_session.exec( - select(WorkshopRegistration) - .where(WorkshopRegistration.workshop_id == workshop_id) - .where(WorkshopRegistration.user_id == user_id) - ).first() - - -def _ws_session_helpers(db_session, workshop_id): - return db_session.exec( - select(WorkshopSession) - .where(WorkshopSession.workshop_id == workshop_id) - .order_by(WorkshopSession.start_time) - ).all() - - -def _ws_count_registered(db_session, workshop_id): - regs = db_session.exec( - select(WorkshopRegistration) - .where(WorkshopRegistration.workshop_id == workshop_id) - .where(WorkshopRegistration.status.in_(("registered", "attended"))) - ).all() - return len(regs) - - -def get_all_tags(db_session): - projects = db_session.exec(select(Project)).all() - tags = set() - for p in projects: - if p.tags: - for t in p.tags.split(","): - t = t.strip().lower() - if t: tags.add(t) - return sorted(tags) - - -def resolve_gitea_repo(form_data, current_url=None, current_repo=None): - raw_repo = (form_data.get("gitea_repo") or "").strip() or None - raw_url = (form_data.get("gitea_url") or "").strip() or None - if raw_repo: - raw_repo = raw_repo.strip("/") - if "/" in raw_repo: - return raw_repo - if raw_url: - parsed = gitea_api.extract_repo_from_url(raw_url) - if parsed: - return parsed - return None - - -def get_user_subscription(db_session, user): - if not user: - return None - if not user.id and not user.email: - return None - conds = [] - if user.id: - conds.append(EventSubscriber.user_id == user.id) - if user.email: - conds.append(EventSubscriber.email == user.email) - if not conds: - return None - return db_session.exec(select(EventSubscriber).where(or_(*conds))).first() - - -# ══════════════════════════════════════════════════════════════════════ -# КОНСТАНТЫ -# ══════════════════════════════════════════════════════════════════════ - -STATUS_LABELS = { - "free": "Свободен / Вне расписания", - "on_place": "На месте", - "away": "Вне вуза", - "cancelled": "Отменено", -} - -PROJECT_ROLE_RANK = {"viewer": 1, "editor": 2, "manager": 3} - -TASK_STATUSES = ["backlog", "todo", "in_progress", "review", "done"] -TASK_STATUS_LABELS = { - "backlog": "Backlog", - "todo": "To Do", - "in_progress": "In Progress", - "review": "Review", - "done": "Done", -} -TASK_PRIORITIES = ["low", "medium", "high", "critical"] - -WORKSHOP_CATEGORIES = ["soldering", "embroidery", "embedded", "linux", "other"] -WORKSHOP_CATEGORY_LABELS = { - "soldering": "Пайка", - "embroidery": "Бисероплетение", - "embedded": "Embedded", - "linux": "Linux", - "other": "Другое", -} -WORKSHOP_DIFFICULTIES = ["beginner", "intermediate", "advanced"] -WORKSHOP_DIFFICULTY_LABELS = { - "beginner": "Новичок", - "intermediate": "Средний", - "advanced": "Продвинутый", -} -WORKSHOP_STATUSES = ["draft", "published", "archived"] -WORKSHOP_STATUS_LABELS = { - "draft": "Черновик", - "published": "Опубликован", - "archived": "В архиве", -} -WORKSHOP_VISIBILITIES = ["public", "internal"] -WORKSHOP_VISIBILITY_LABELS = { - "public": "Публичный", - "internal": "Внутренний", -} - - -# ══════════════════════════════════════════════════════════════════════ -# НАСТРОЙКА ПРИЛОЖЕНИЯ И СТАРТ -# ══════════════════════════════════════════════════════════════════════ - -UPLOAD_FOLDER = '/var/lib/gitea/portal-files' -app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['UPLOAD_FOLDER'] = '/var/lib/gitea/portal-files' app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 with app.app_context(): - init_db() - with Session(engine) as db_session: - status = db_session.exec(select(GlobalStatus)).first() - if not status: - db_session.add(GlobalStatus(status="free", message="Вне расписания")) - db_session.commit() + # Схему создаёт Alembic. Seed базового GlobalStatus — если пусто. + try: + with Session(engine) as db_session: + if not db_session.exec(select(GlobalStatus)).first(): + db_session.add(GlobalStatus(status="free", message="Вне расписания")) + db_session.commit() + except Exception as e: + print(f"[main] seed GlobalStatus skipped: {e}", flush=True) @app.before_request def validate_session(): if "session_id" in session: with Session(engine) as db_session: - db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first() + db_sess = db_session.exec( + select(UserSession).where(UserSession.session_token == session["session_id"]) + ).first() if not db_sess: session.clear() return redirect(url_for("login")) -# ══════════════════════════════════════════════════════════════════════ -# EMAIL + УВЕДОМЛЕНИЯ (in-app) -# ══════════════════════════════════════════════════════════════════════ +register_filters(app) -def send_email(to_email, subject, body): - if not config.SMTP_SERVER: - print("\n" + "="*60, flush=True) - print(f"📧 EMAIL ДЛЯ: {to_email}", flush=True) - print(f"Тема: {subject}", flush=True) - print(body, flush=True) - print("="*60 + "\n", flush=True) - return True - try: - msg = MIMEMultipart() - msg['From'] = config.SMTP_FROM - msg['To'] = to_email - msg['Subject'] = subject - msg.attach(MIMEText(body, 'plain')) - server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT) - server.starttls() - server.login(config.SMTP_USER, config.SMTP_PASSWORD) - server.send_message(msg) - server.quit() - return True - except Exception as e: - print(f"Ошибка отправки email: {e}", flush=True) - return False +auth_routes.register(app) +notifications_routes.register(app) +schedule_routes.register(app) +projects_routes.register(app) +workshops_routes.register(app) +files_routes.register(app) +admin_routes.register(app) +gitea_routes.register(app) +feed_routes.register(app) +onboarding_routes.register(app) +dashboard_routes.register(app) -def notify_user(user_id, title, body=None, link_url=None, kind="info"): - if not user_id: - return - try: - with Session(engine) as s: - s.add(Notification(user_id=user_id, title=title, body=body, link_url=link_url, kind=kind)) - s.commit() - except Exception as e: - print(f"[notify_user] {e}", flush=True) - - -def notify_users(user_ids, title, body=None, link_url=None, kind="info"): - for uid in set(uid for uid in user_ids if uid): - notify_user(uid, title, body, link_url, kind) - - -def _notify_subscribers_sync(subject, body, kind): - try: - with Session(engine) as db_session: - subscribers = db_session.exec( - select(EventSubscriber).where(EventSubscriber.is_confirmed == True) - ).all() - for sub in subscribers: - if kind == "status" and not sub.notify_status: continue - if kind == "event" and not sub.notify_events: continue - unsubscribe_url = f"https://portal.morozovka.space/unsubscribe/{sub.unsubscribe_token}" - full_body = ( - body + "\n\n---\n" - + "Открыть расписание: https://portal.morozovka.space/schedule\n" - + f"Отписаться: {unsubscribe_url}\n" - ) - send_email(sub.email, subject, full_body) - except Exception as e: - print(f"[notify] Ошибка рассылки: {e}", flush=True) - - -def notify_subscribers(subject, body, kind): - threading.Thread(target=_notify_subscribers_sync, args=(subject, body, kind), daemon=True).start() - - -# ══════════════════════════════════════════════════════════════════════ -# БАЗОВЫЕ РОУТЫ -# ══════════════════════════════════════════════════════════════════════ - @app.route("/healthz") def healthz(): try: from sqlalchemy import text + from .llm_assistant import is_enabled as llm_is_enabled with engine.connect() as conn: conn.execute(text("SELECT 1")) - return jsonify({ - "status": "ok", - "db": "ok", - "llm": "enabled" if llm_assistant.is_enabled() else "disabled", - }) + return jsonify({"status": "ok", "db": "ok", + "llm": "enabled" if llm_is_enabled() else "disabled"}) except Exception as e: return jsonify({"status": "error", "db": str(e)}), 503 @app.route("/") def index(): - return render_template("index.html", username=session.get("username")) + user_id = session.get("user_id") + onboarding_completed = False + if user_id: + try: + with Session(engine) as s: + ob = s.exec(select(UserOnboarding).where(UserOnboarding.user_id == user_id)).first() + onboarding_completed = bool(ob and ob.completed_at) + except Exception: + pass + return render_template("index.html", onboarding_completed=onboarding_completed) @app.route("/api/public-status") def api_public_status(): - with Session(engine) as db_session: - s = db_session.exec(select(GlobalStatus)).first() - if not s: + with Session(engine) as s: + gs = s.exec(select(GlobalStatus)).first() + if not gs: return jsonify({"status": "free", "delay_minutes": 0, "message": None}) - return jsonify({ - "status": s.status, - "delay_minutes": s.delay_minutes or 0, - "message": s.message, - "updated_at": s.updated_at.isoformat() if s.updated_at else None, - }) - - -# ══════════════════════════════════════════════════════════════════════ -# УВЕДОМЛЕНИЯ — API -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/api/notifications") -@login_required -def api_notifications_list(): - uid = session.get("user_id") - with Session(engine) as s: - items = s.exec( - select(Notification).where(Notification.user_id == uid) - .order_by(Notification.created_at.desc()).limit(20) - ).all() - unread = s.exec( - select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False) - ).all() - return jsonify({ - "items": [{ - "id": n.id, "title": n.title, "body": n.body, "link_url": n.link_url, - "kind": n.kind, "is_read": n.is_read, - "created_at": n.created_at.isoformat() + "Z", - } for n in items], - "unread": len(unread), - }) - - -@app.route("/api/notifications/count") -@login_required -def api_notifications_count(): - uid = session.get("user_id") - with Session(engine) as s: - unread = s.exec( - select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False) - ).all() - return jsonify({"unread": len(unread)}) - - -@app.route("/api/notifications//read", methods=["POST"]) -@login_required -def api_notifications_read(nid): - uid = session.get("user_id") - with Session(engine) as s: - n = s.get(Notification, nid) - if not n or n.user_id != uid: - return jsonify({"ok": False}), 403 - n.is_read = True - s.add(n) - s.commit() - return jsonify({"ok": True}) - - -@app.route("/api/notifications/read-all", methods=["POST"]) -@login_required -def api_notifications_read_all(): - uid = session.get("user_id") - with Session(engine) as s: - for n in s.exec(select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False)).all(): - n.is_read = True - s.add(n) - s.commit() - return jsonify({"ok": True}) - - -@app.route("/notifications") -@login_required -def notifications_page(): - uid = session.get("user_id") - with Session(engine) as s: - items = s.exec( - select(Notification).where(Notification.user_id == uid) - .order_by(Notification.created_at.desc()).limit(100) - ).all() - return render_template("notifications_page.html", notifications=items) - - -# ══════════════════════════════════════════════════════════════════════ -# АУТЕНТИФИКАЦИЯ -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/register", methods=["GET", "POST"]) -def register(): - if request.method == "POST": - username = request.form.get("username") - email = request.form.get("email") - password = request.form.get("password") - full_name = request.form.get("full_name") - organization = request.form.get("organization") - with Session(engine) as db_session: - if db_session.exec(select(User).where(User.username == username)).first(): - return render_template("register.html", error="Логин уже занят") - if db_session.exec(select(User).where(User.email == email)).first(): - return render_template("register.html", error="Email уже используется") - new_user = User( - username=username, email=email, - password_hash=generate_password_hash(password), - full_name=full_name, organization=organization, - role="student", is_verified=True, - ) - db_session.add(new_user) - db_session.commit() - return render_template("login.html", msg="Регистрация успешна. Можете войти.") - return render_template("register.html") - - -@app.route("/verify_email/") -def verify_email(token): - try: email = serializer.loads(token, salt='email-confirm', max_age=86400) - except Exception: return "Ссылка недействительна или устарела.", 400 - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.email == email)).first() - if user: - user.is_verified = True - db_session.add(user) - db_session.commit() - return redirect(url_for("login", msg="Почта подтверждена! Теперь можно войти.")) - return "Пользователь не найден.", 404 - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.username == username)).first() - if not user or not user.password_hash or not check_password_hash(user.password_hash, password): - return render_template("login.html", error="Неверный логин или пароль") - if not user.is_verified: - return render_template("login.html", error="Подтвердите почту перед входом") - session_token = str(uuid.uuid4()) - db_session.add(UserSession( - session_token=session_token, user_id=user.id, - ip_address=request.remote_addr, user_agent=request.user_agent.string, - )) - db_session.commit() - session["user_id"] = user.id - session["username"] = user.username - session["role"] = user.role - session["session_id"] = session_token - return redirect(url_for("index")) - return render_template("login.html", msg=request.args.get("msg")) - - -@app.route("/login/gitea") -def login_gitea(): - gitea_auth_url = ( - f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" - f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" - f"&redirect_uri={config.GITEA_REDIRECT_URI}" - f"&scope=read:user" - ) - return redirect(gitea_auth_url) - - -@app.route("/auth/callback") -def auth_callback(): - code = request.args.get("code") - if not code: return "Ошибка авторизации Gitea", 400 - token = auth.get_gitea_token(code) - if not token: return "Ошибка токена Gitea", 400 - gitea_user = auth.get_gitea_user(token) - if not gitea_user: return "Ошибка профиля Gitea", 400 - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() - if not user: - user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first() - if user: - user.gitea_id = gitea_user["id"] - else: - existing_admin = db_session.exec(select(User).where(User.role == "admin")).first() - bootstrap_role = "admin" if not existing_admin else "student" - user = User( - gitea_id=gitea_user["id"], username=gitea_user["login"], - full_name=gitea_user.get("full_name"), email=gitea_user.get("email"), - role=bootstrap_role, is_verified=True, - ) - db_session.add(user) - db_session.commit() - session_token = str(uuid.uuid4()) - db_session.add(UserSession( - session_token=session_token, user_id=user.id, - ip_address=request.remote_addr, user_agent=request.user_agent.string, - )) - db_session.commit() - session["user_id"] = user.id - session["username"] = user.username - session["role"] = user.role - session["session_id"] = session_token - return redirect(url_for("index")) - - -@app.route("/logout") -def logout(): - if "session_id" in session: - with Session(engine) as db_session: - db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first() - if db_sess: - db_session.delete(db_sess) - db_session.commit() - session.clear() - return redirect(url_for("index")) - - -@app.route("/forgot_password", methods=["GET", "POST"]) -def forgot_password(): - if request.method == "POST": - email = request.form.get("email") - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.email == email)).first() - if user: - token = serializer.dumps(email, salt='password-reset') - reset_url = url_for('reset_password', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") - send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n") - return render_template("login.html", msg="Если такой email есть, инструкция отправлена.") - return render_template("forgot_password.html") - - -@app.route("/reset_password/", methods=["GET", "POST"]) -def reset_password(token): - try: email = serializer.loads(token, salt='password-reset', max_age=3600) - except Exception: return "Ссылка недействительна или устарела.", 400 - if request.method == "POST": - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.email == email)).first() - if user: - user.password_hash = generate_password_hash(request.form.get("password")) - db_session.add(user) - db_session.commit() - return redirect(url_for("login", msg="Пароль успешно изменен!")) - return render_template("reset_password.html", token=token) - - -@app.route("/sessions") -@login_required -def sessions_list(): - with Session(engine) as db_session: - sessions = db_session.exec( - select(UserSession).where(UserSession.user_id == session.get("user_id")) - .order_by(UserSession.created_at.desc()) - ).all() - return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id")) - - -@app.route("/sessions/logout_all", methods=["POST"]) -@login_required -def logout_all(): - with Session(engine) as db_session: - for s in db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all(): - db_session.delete(s) - db_session.commit() - session.clear() - return redirect(url_for("login", msg="Вы вышли со всех устройств.")) - - -@app.route("/profile") -@login_required -def profile(): - with Session(engine) as db_session: - user = db_session.get(User, session.get("user_id")) - subscription = get_user_subscription(db_session, user) - return render_template("profile.html", user=user, subscription=subscription) - - -# ══════════════════════════════════════════════════════════════════════ -# ПОДПИСКИ -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/subscribe/toggle", methods=["POST"]) -@login_required -def subscribe_toggle(): - action = request.form.get("action") - notify_status = request.form.get("notify_status") == "on" - notify_events = request.form.get("notify_events") == "on" - next_url = request.form.get("next") or url_for("profile") - if not next_url.startswith("/"): next_url = url_for("profile") - with Session(engine) as db_session: - user = db_session.get(User, session.get("user_id")) - if not user or not user.email: - return redirect(next_url) - existing = get_user_subscription(db_session, user) - if action == "unsubscribe": - if existing: - db_session.delete(existing) - db_session.commit() - return redirect(next_url) - if existing: - existing.email = user.email - existing.user_id = user.id - existing.notify_status = notify_status - existing.notify_events = notify_events - existing.is_confirmed = True - existing.confirm_token = None - db_session.add(existing) - else: - db_session.add(EventSubscriber( - email=user.email, user_id=user.id, - unsubscribe_token=str(uuid.uuid4()), - is_confirmed=True, confirm_token=None, - notify_status=notify_status, notify_events=notify_events, - )) - db_session.commit() - return redirect(next_url) - - -@app.route("/subscribe/confirm/") -def subscribe_confirm(token): - with Session(engine) as db_session: - sub = db_session.exec(select(EventSubscriber).where(EventSubscriber.confirm_token == token)).first() - if not sub: - return render_template("subscribe_status.html", title="Ссылка недействительна", - message="Возможно, ссылка уже использована или устарела.", ok=False) - sub.is_confirmed = True - sub.confirm_token = None - db_session.add(sub) - db_session.commit() - return render_template("subscribe_status.html", title="Подписка подтверждена!", - message=f"Вы будете получать уведомления на {sub.email}.", - ok=True, unsubscribe_token=sub.unsubscribe_token) - - -@app.route("/unsubscribe/", methods=["GET", "POST"]) -def unsubscribe(token): - with Session(engine) as db_session: - sub = db_session.exec(select(EventSubscriber).where(EventSubscriber.unsubscribe_token == token)).first() - if not sub: - return render_template("subscribe_status.html", title="Ссылка недействительна", - message="Такой подписки нет.", ok=False) - if request.method == "POST": - db_session.delete(sub) - db_session.commit() - return render_template("subscribe_status.html", title="Вы отписаны", - message="Больше не будет присылать уведомления.", ok=True) - return render_template("subscribe_status.html", title="Отписаться от уведомлений?", - message=f"Вы перестанете получать письма на {sub.email}.", - ok=True, show_confirm_form=True, - confirm_action=url_for("unsubscribe", token=token)) - - -# ══════════════════════════════════════════════════════════════════════ -# РАСПИСАНИЕ -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/schedule") -def public_schedule(): - with Session(engine) as db_session: - current_status = db_session.exec(select(GlobalStatus)).first() - now = datetime.now(timezone.utc) - events = db_session.exec( - select(Event).where(Event.is_public == True) - .where(Event.start_time >= now - timedelta(days=180)) - .where(Event.start_time <= now + timedelta(days=365)) - .order_by(Event.start_time) - ).all() - my_subscription = None - current_user = None - if session.get("user_id"): - current_user = db_session.get(User, session["user_id"]) - if current_user: - my_subscription = get_user_subscription(db_session, current_user) - events_json = [ - {"id": e.id, "title": e.title, "type": e.event_type, - "start": to_msk(e.start_time).isoformat(), "end": to_msk(e.end_time).isoformat(), - "location": e.location, "description": e.description} - for e in events - ] - return render_template("schedule.html", status=current_status, events_json=events_json, - my_subscription=my_subscription, current_user=current_user, - msg=request.args.get("msg"), error=request.args.get("error")) - - -@app.route("/schedule/export.ics") -def export_ics(): - with Session(engine) as db_session: - events = db_session.exec(select(Event).where(Event.is_public == True)).all() - cal = Calendar() - cal.add('prodid', '-//Morozovka Portal//mxm.dk//') - cal.add('version', '2.0') - for ev in events: - ievent = ICalEvent() - ievent.add('summary', ev.title) - ievent.add('dtstart', ev.start_time.astimezone(pytz.utc)) - ievent.add('dtend', ev.end_time.astimezone(pytz.utc)) - if ev.location: ievent.add('location', ev.location) - if ev.description: ievent.add('description', ev.description) - cal.add_component(ievent) - response = make_response(cal.to_ical()) - response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics" - response.headers["Content-Type"] = "text/calendar; charset=utf-8" - return response - - -@app.route("/api/assistant", methods=["POST"]) -def api_assistant(): - data = request.get_json() or {} - message = (data.get("message") or "").strip() - if not message: - return jsonify({"error": "Нет сообщения"}), 400 - history = data.get("history") or [] - - if llm_assistant.is_enabled(): - try: - reply = llm_assistant.chat(message, history) - if reply: - return jsonify({"reply": reply, "source": "llm"}) - except Exception as e: - print(f"[llm] fallback to regex: {e}", flush=True) - - return jsonify({"reply": assistant.get_response(message), "source": "regex"}) - - -@app.route("/admin/schedule") -@require_role(["admin", "professor"]) -def admin_schedule(): - with Session(engine) as db_session: - current_status = db_session.exec(select(GlobalStatus)).first() - events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).all() - return render_template("admin_schedule.html", status=current_status, events=events) - - -@app.route("/admin/schedule/status", methods=["POST"]) -@require_role(["admin", "professor"]) -def update_status(): - status_val = request.form.get("status") - delay = int(request.form.get("delay_minutes") or 0) - message = request.form.get("message") - with Session(engine) as db_session: - current_status = db_session.exec(select(GlobalStatus)).first() - if current_status: - old_label = status_label(current_status.status, current_status.delay_minutes) - current_status.status = status_val - current_status.delay_minutes = delay - current_status.message = message - current_status.updated_at = datetime.now(timezone.utc) - db_session.add(current_status) - db_session.commit() - new_label = status_label(status_val, delay) - subject = f"[Расписание] Статус: {new_label}" - body = f"Новый статус: {new_label}\nБыло: {old_label}\n" - if message: body += f"Сообщение: {message}\n" - body += f"Обновлено: {datetime.now(MSK).strftime('%d.%m.%Y %H:%M')}\n" - notify_subscribers(subject, body, kind="status") - return redirect(url_for("admin_schedule")) - - -@app.route("/admin/schedule/event/add", methods=["POST"]) -@require_role(["admin", "professor"]) -def add_event(): - title = request.form.get("title") - event_type = request.form.get("event_type") - start_time = parse_datetime(request.form.get("start_time")) - end_time = parse_datetime(request.form.get("end_time")) - with Session(engine) as db_session: - ev = Event(title=title, event_type=event_type, start_time=start_time, end_time=end_time, - location=request.form.get("location"), description=request.form.get("description"), - is_public=request.form.get("is_public") == "on") - db_session.add(ev) - db_session.commit() - db_session.refresh(ev) - if ev.is_public: - notify_subscribers(f"[Расписание] Новое событие: {ev.title}", - "Добавлено новое событие в расписание.\n\n" + event_summary_text(ev), - kind="event") - return redirect(url_for("admin_schedule")) - - -@app.route("/admin/schedule/event//edit", methods=["GET", "POST"]) -@require_role(["admin", "professor"]) -def edit_event(event_id): - with Session(engine) as db_session: - event = db_session.get(Event, event_id) - if not event: return "Событие не найдено", 404 - if request.method == "POST": - event.title = request.form.get("title") - event.event_type = request.form.get("event_type") - event.start_time = parse_datetime(request.form.get("start_time")) - event.end_time = parse_datetime(request.form.get("end_time")) - event.location = request.form.get("location") - event.description = request.form.get("description") - event.is_public = request.form.get("is_public") == "on" - event.manually_edited = True - db_session.add(event) - db_session.commit() - if event.is_public: - notify_subscribers(f"[Расписание] Изменено: {event.title}", - "Событие в расписании изменено.\n\n" + event_summary_text(event), - kind="event") - return redirect(url_for("admin_schedule")) - return render_template("admin_event_edit.html", event=event) - - -@app.route("/admin/schedule/event//delete", methods=["POST"]) -@require_role(["admin", "professor"]) -def delete_event(event_id): - with Session(engine) as db_session: - event = db_session.get(Event, event_id) - if event: - title_snapshot = event.title - start_snapshot = event.start_time - was_public = event.is_public - db_session.delete(event) - db_session.commit() - if was_public: - body = ( - f"Событие отменено/удалено из расписания.\n\n" - f"Название: {title_snapshot}\n" - f"Было запланировано: {to_msk(start_snapshot).strftime('%d.%m.%Y %H:%M')}\n" - ) - notify_subscribers(f"[Расписание] Удалено: {title_snapshot}", body, kind="event") - return redirect(url_for("admin_schedule")) - - -@app.route("/admin/schedule/import", methods=["GET", "POST"]) -@require_role(["admin", "professor"]) -def import_ics(): - if request.method == "POST": - if 'file' not in request.files: return "Файл не загружен", 400 - file = request.files['file'] - if file.filename == '': return "Файл не выбран", 400 - if file and file.filename.endswith('.ics'): - try: - cal = Calendar.from_ical(file.read()) - count = 0 - with Session(engine) as db_session: - for component in cal.walk(): - if component.name == "VEVENT": - summary = str(component.get('summary', 'Без названия')) - dtstart_prop = component.get('dtstart') - dtend_prop = component.get('dtend') - if not dtstart_prop or not dtend_prop: continue - dtstart = dtstart_prop.dt - dtend = dtend_prop.dt - if not isinstance(dtstart, datetime): - dtstart = datetime.combine(dtstart, datetime.min.time()) - if not isinstance(dtend, datetime): - dtend = datetime.combine(dtend, datetime.min.time()) - if dtstart.tzinfo is None: dtstart = dtstart.replace(tzinfo=timezone.utc) - if dtend.tzinfo is None: dtend = dtend.replace(tzinfo=timezone.utc) - new_event = Event( - title=summary, event_type='lecture', - start_time=dtstart.astimezone(timezone.utc), - end_time=dtend.astimezone(timezone.utc), - location=str(component.get('location', '')), - description=str(component.get('description', '')), - is_public=True - ) - db_session.add(new_event) - count += 1 - db_session.commit() - if count > 0: - notify_subscribers(f"[Расписание] Импортировано событий: {count}", - f"В расписание добавлено событий: {count}.\n", kind="event") - return redirect(url_for("admin_schedule")) - except Exception as e: - return f"Ошибка при чтении файла .ics: {e}", 400 - return render_template("admin_schedule_import.html") - - -# ══════════════════════════════════════════════════════════════════════ -# ПРОЕКТЫ -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/projects") -def projects_list(): - user_role = session.get("role") - user_id = session.get("user_id") - current_tag = (request.args.get("tag") or "").strip().lower() - current_status = (request.args.get("status") or "").strip() - with Session(engine) as db_session: - all_projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() - member_map = {} - if user_id: - for m in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all(): - member_map[m.project_id] = m.role - all_tags = get_all_tags(db_session) - filtered = [] - for p in all_projects: - if p.visibility == "public": - filtered.append(p) - elif p.visibility == "internal" and user_id: - filtered.append(p) - elif p.visibility == "private" and user_id and (p.owner_id == user_id or p.id in member_map or user_role == "admin"): - filtered.append(p) - if current_status: - filtered = [p for p in filtered if p.status == current_status] - if current_tag: - def has_tag(p): - if not p.tags: return False - return current_tag in [t.strip().lower() for t in p.tags.split(",")] - filtered = [p for p in filtered if has_tag(p)] - return render_template("projects.html", projects=filtered, role=user_role, user_id=user_id, - member_map=member_map, all_tags=all_tags, - current_tag=current_tag, current_status=current_status) - - -@app.route("/projects/new", methods=["GET", "POST"]) -@require_role(["admin", "professor", "lead"]) -def project_new(): - if request.method == "POST": - with Session(engine) as db_session: - new_proj = Project( - title=request.form.get("title"), - description=request.form.get("description"), - status=request.form.get("status"), - visibility=request.form.get("visibility"), - gitea_url=(request.form.get("gitea_url") or "").strip() or None, - gitea_repo=resolve_gitea_repo(request.form), - tags=normalize_tags(request.form.get("tags")), - start_date=(request.form.get("start_date") or "").strip() or None, - end_date=(request.form.get("end_date") or "").strip() or None, - contact_person=(request.form.get("contact_person") or "").strip() or None, - owner_id=session.get("user_id"), - ) - db_session.add(new_proj) - db_session.flush() - proj_id = new_proj.id - db_session.add(ProjectMember(project_id=proj_id, user_id=session.get("user_id"), role="manager")) - db_session.commit() - return redirect(url_for("project_detail", project_id=proj_id)) - return render_template("project_form.html", project=None, is_edit=False) - - -@app.route("/projects/") -def project_detail(project_id): - user_id = session.get("user_id") - user_role = session.get("role") - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - membership = None - if user_id: - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) - ).first() - perms = project_permissions(proj, user_id, user_role, membership) - if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: - return "403: Это приватный проект", 403 - if proj.visibility == "internal" and not user_id: - return redirect(url_for("login")) - members = db_session.exec( - select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) - .where(ProjectMember.project_id == project_id).order_by(User.username) - ).all() - files = db_session.exec( - select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id) - .where(PortalFile.project_id == project_id).order_by(PortalFile.uploaded_at.desc()) - ).all() - task_counts = {s: 0 for s in TASK_STATUSES} - for t in db_session.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all(): - if t.status in task_counts: task_counts[t.status] += 1 - return render_template("project_detail.html", project=proj, members=members, files=files, - perms=perms, user_id=user_id, role=user_role, - active_tab="overview", task_counts=task_counts) - - -@app.route("/projects//gitea-stats") -def project_gitea_stats(project_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: - return jsonify({"ok": False, "error": "not_found", "message": "Проект не найден"}), 404 - user_id = session.get("user_id") - user_role = session.get("role") - membership = None - if user_id: - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) - ).first() - perms = project_permissions(proj, user_id, user_role, membership) - if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: - return jsonify({"ok": False, "error": "forbidden"}), 403 - repo = proj.gitea_repo - if not repo and proj.gitea_url: - repo = gitea_api.extract_repo_from_url(proj.gitea_url) - if not repo: - return jsonify({"ok": False, "error": "no_repo", "message": "У проекта не указан репозиторий."}) - return jsonify(gitea_api.get_project_stats(repo)) - - -@app.route("/projects//edit", methods=["GET", "POST"]) -@login_required -def project_edit(project_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) - if not perms["can_manage"]: - return "403: У вас нет прав на редактирование проекта", 403 - if request.method == "POST": - proj.title = request.form.get("title") - proj.description = request.form.get("description") - proj.status = request.form.get("status") - proj.visibility = request.form.get("visibility") - proj.gitea_url = (request.form.get("gitea_url") or "").strip() or None - proj.gitea_repo = resolve_gitea_repo(request.form, proj.gitea_url, proj.gitea_repo) - proj.tags = normalize_tags(request.form.get("tags")) - proj.start_date = (request.form.get("start_date") or "").strip() or None - proj.end_date = (request.form.get("end_date") or "").strip() or None - proj.contact_person = (request.form.get("contact_person") or "").strip() or None - db_session.add(proj) - db_session.commit() - return redirect(url_for("project_detail", project_id=proj.id)) - return render_template("project_form.html", project=proj, is_edit=True) - - -@app.route("/projects//members/add", methods=["POST"]) -@login_required -def project_add_member(project_id): - username = (request.form.get("username") or "").strip() - role = request.form.get("role") or "viewer" - if role not in PROJECT_ROLE_RANK: role = "viewer" - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) - if not perms["can_manage"]: return "403: Нет прав", 403 - user_to_add = db_session.exec(select(User).where(User.username == username)).first() - if not user_to_add: return redirect(url_for("project_detail", project_id=project_id)) - existing = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_to_add.id) - ).first() - if not existing: - db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role=role)) - db_session.commit() - notify_user(user_to_add.id, - f"Вас добавили в проект «{proj.title}»", - f"Роль: {role}", - link_url=url_for("project_detail", project_id=project_id), - kind="info") - return redirect(url_for("project_detail", project_id=project_id)) - - -@app.route("/projects//members//role", methods=["POST"]) -@login_required -def project_change_member_role(project_id, user_id): - new_role = request.form.get("role") or "viewer" - if new_role not in PROJECT_ROLE_RANK: return "Ошибка: неверная роль", 400 - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - my_membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), my_membership) - if not perms["can_manage"]: return "403: Нет прав", 403 - if user_id == proj.owner_id: return "Нельзя менять роль владельца", 400 - target = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) - ).first() - if target: - target.role = new_role - db_session.add(target) - db_session.commit() - return redirect(url_for("project_detail", project_id=project_id)) - - -@app.route("/projects//members//remove", methods=["POST"]) -@login_required -def project_remove_member(project_id, user_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - my_membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), my_membership) - if not perms["can_manage"]: return "403: Нет прав", 403 - if user_id == proj.owner_id: return "Нельзя удалить владельца", 400 - to_remove = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) - ).first() - if to_remove: - db_session.delete(to_remove) - db_session.commit() - return redirect(url_for("project_detail", project_id=project_id)) - - -@app.route("/projects//delete", methods=["POST"]) -@login_required -def project_delete(project_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: - print(f"[project_delete] Проект {project_id} не найден", flush=True) - return redirect(url_for("projects_list")) - user_id = session.get("user_id") - user_role = session.get("role") - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) - ).first() - perms = project_permissions(proj, user_id, user_role, membership) - if not perms["can_delete"]: - return f"403: Удалять проект может только владелец (id={proj.owner_id}) или админ. Вы — id={user_id}, роль={user_role}.", 403 - try: - files = db_session.exec(select(PortalFile).where(PortalFile.project_id == project_id)).all() - for f in files: - f.project_id = None - db_session.add(f) - db_session.flush() - tasks = db_session.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all() - for t in tasks: - db_session.delete(t) - db_session.flush() - members = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all() - for m in members: - db_session.delete(m) - db_session.flush() - db_session.delete(proj) - db_session.commit() - except Exception as e: - db_session.rollback() - print(f"[project_delete] ОШИБКА при удалении {project_id}: {e}", flush=True) - import traceback - traceback.print_exc() - return f"Ошибка удаления: {e}", 500 - return redirect(url_for("projects_list")) - - -@app.route("/projects//files/upload", methods=["POST"]) -@login_required -def project_file_upload(project_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) - if not perms["can_upload"]: - return "403: Нет прав на загрузку файлов", 403 - try: - file = request.files.get('file') - except Exception as e: - print(f"[project_file_upload] Ошибка парсинга: {e}", flush=True) - return "Ошибка разбора запроса", 400 - if not file or file.filename == '': - return redirect(url_for("project_detail", project_id=project_id)) - original_name = file.filename - ext = os.path.splitext(original_name)[1] - stored_name = f"{uuid.uuid4().hex}{ext}" - filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) - try: - file.save(filepath) - size_bytes = os.path.getsize(filepath) - except Exception as e: - print(f"[project_file_upload] Ошибка сохранения: {e}", flush=True) - return "Ошибка сохранения файла", 500 - try: - with Session(engine) as db_session: - db_session.add(PortalFile( - original_name=original_name, stored_name=stored_name, - size_bytes=size_bytes, uploaded_by=session.get("user_id"), - project_id=project_id, - )) - db_session.commit() - except Exception as e: - print(f"[project_file_upload] Ошибка БД: {e}", flush=True) - try: os.remove(filepath) - except Exception: pass - return "Ошибка записи в БД", 500 - return redirect(url_for("project_detail", project_id=project_id)) - - -# ══════════════════════════════════════════════════════════════════════ -# КАНБАН -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/projects//board") -def project_board(project_id): - user_id = session.get("user_id") - user_role = session.get("role") - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - membership = None - if user_id: - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) - ).first() - perms = project_permissions(proj, user_id, user_role, membership) - if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: - return "403: Это приватный проект", 403 - if proj.visibility == "internal" and not user_id: - return redirect(url_for("login")) - members = db_session.exec( - select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) - .where(ProjectMember.project_id == project_id).order_by(User.username) - ).all() - tasks = db_session.exec( - select(ProjectTask).where(ProjectTask.project_id == project_id) - .order_by(ProjectTask.position, ProjectTask.id) - ).all() - user_ids = set() - for t in tasks: - if t.assignee_id: user_ids.add(t.assignee_id) - if t.created_by: user_ids.add(t.created_by) - users_map = {} - if user_ids: - for u in db_session.exec(select(User).where(User.id.in_(user_ids))).all(): - users_map[u.id] = u - columns = {s: [] for s in TASK_STATUSES} - for t in tasks: - if t.status in columns: - columns[t.status].append({ - "task": t, - "assignee": users_map.get(t.assignee_id) if t.assignee_id else None, - "author": users_map.get(t.created_by), - }) - task_counts = {s: len(columns[s]) for s in TASK_STATUSES} - return render_template("project_board.html", - project=proj, members=members, columns=columns, - perms=perms, user_id=user_id, role=user_role, - active_tab="board", task_counts=task_counts, - TASK_STATUSES=TASK_STATUSES, TASK_STATUS_LABELS=TASK_STATUS_LABELS, - TASK_PRIORITIES=TASK_PRIORITIES) - - -@app.route("/projects//tasks/new", methods=["POST"]) -@login_required -def task_new(project_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) - if not perms["can_edit"]: - return "403: Нет прав", 403 - title = (request.form.get("title") or "").strip() - if not title: - return redirect(url_for("project_board", project_id=project_id)) - status = request.form.get("status") or "backlog" - if status not in TASK_STATUSES: status = "backlog" - priority = request.form.get("priority") or "medium" - if priority not in TASK_PRIORITIES: priority = "medium" - assignee_id = request.form.get("assignee_id") - assignee_id = int(assignee_id) if assignee_id and assignee_id.isdigit() else None - max_pos = db_session.exec( - select(ProjectTask).where(ProjectTask.project_id == project_id).where(ProjectTask.status == status) - ).all() - position = max([t.position for t in max_pos], default=-1) + 1 - task = ProjectTask( - project_id=project_id, title=title, - description=(request.form.get("description") or "").strip() or None, - status=status, priority=priority, assignee_id=assignee_id, - created_by=session.get("user_id"), - due_date=(request.form.get("due_date") or "").strip() or None, - position=position, - ) - db_session.add(task) - db_session.commit() - if assignee_id and assignee_id != session.get("user_id"): - notify_user(assignee_id, - f"Вам назначена задача: {title}", - f"Проект: {proj.title}", - link_url=url_for("project_board", project_id=project_id), - kind="info") - return redirect(url_for("project_board", project_id=project_id)) - - -@app.route("/projects//tasks//edit", methods=["GET", "POST"]) -@login_required -def task_edit(project_id, task_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) - if not perms["can_edit"]: - return "403: Нет прав", 403 - task = db_session.get(ProjectTask, task_id) - if not task or task.project_id != project_id: - return "Задача не найдена", 404 - if request.method == "POST": - old_assignee = task.assignee_id - task.title = (request.form.get("title") or task.title).strip() - task.description = (request.form.get("description") or "").strip() or None - new_status = request.form.get("status") or task.status - if new_status in TASK_STATUSES: task.status = new_status - new_priority = request.form.get("priority") or task.priority - if new_priority in TASK_PRIORITIES: task.priority = new_priority - assignee = request.form.get("assignee_id") - new_assignee = int(assignee) if assignee and assignee.isdigit() else None - task.assignee_id = new_assignee - task.due_date = (request.form.get("due_date") or "").strip() or None - task.updated_at = datetime.now(timezone.utc) - db_session.add(task) - db_session.commit() - if new_assignee and new_assignee != old_assignee and new_assignee != session.get("user_id"): - notify_user(new_assignee, - f"Вам назначена задача: {task.title}", - f"Проект: {proj.title}", - link_url=url_for("project_board", project_id=project_id), - kind="info") - return redirect(url_for("project_board", project_id=project_id)) - members = db_session.exec( - select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) - .where(ProjectMember.project_id == project_id).order_by(User.username) - ).all() - return render_template("task_edit.html", project=proj, task=task, members=members, - TASK_STATUSES=TASK_STATUSES, TASK_STATUS_LABELS=TASK_STATUS_LABELS, - TASK_PRIORITIES=TASK_PRIORITIES) - - -@app.route("/projects//tasks//delete", methods=["POST"]) -@login_required -def task_delete(project_id, task_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) - if not perms["can_manage"]: - return "403: Удалять задачи может только менеджер проекта", 403 - task = db_session.get(ProjectTask, task_id) - if task and task.project_id == project_id: - db_session.delete(task) - db_session.commit() - return redirect(url_for("project_board", project_id=project_id)) - - -@app.route("/projects//tasks//move", methods=["POST"]) -@login_required -def task_move(project_id, task_id): - data = request.get_json(silent=True) or {} - new_status = data.get("status") - new_position = data.get("position", 0) - if new_status not in TASK_STATUSES: - return jsonify({"error": "invalid status"}), 400 - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return jsonify({"error": "project not found"}), 404 - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) - if not perms["can_edit"]: - return jsonify({"error": "no permission"}), 403 - task = db_session.get(ProjectTask, task_id) - if not task or task.project_id != project_id: - return jsonify({"error": "task not found"}), 404 - old_status = task.status - siblings = db_session.exec( - select(ProjectTask) - .where(ProjectTask.project_id == project_id) - .where(ProjectTask.status == new_status) - .where(ProjectTask.id != task_id) - .order_by(ProjectTask.position, ProjectTask.id) - ).all() - siblings.insert(max(0, min(int(new_position), len(siblings))), task) - for i, t in enumerate(siblings): - t.position = i - if t.id == task_id: - t.status = new_status - t.updated_at = datetime.now(timezone.utc) - db_session.add(t) - db_session.flush() - if old_status != new_status: - olds = db_session.exec( - select(ProjectTask) - .where(ProjectTask.project_id == project_id) - .where(ProjectTask.status == old_status) - .order_by(ProjectTask.position, ProjectTask.id) - ).all() - for i, t in enumerate(olds): - t.position = i - db_session.add(t) - db_session.commit() - return jsonify({"ok": True}) - - -# ══════════════════════════════════════════════════════════════════════ -# МАСТЕР-КЛАССЫ -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/workshops") -def workshops_list(): - user_id = session.get("user_id") - user_role = session.get("role") - current_cat = (request.args.get("category") or "").strip() - current_status = (request.args.get("status") or "").strip() - - with Session(engine) as db_session: - all_ws = db_session.exec(select(Workshop).order_by(Workshop.created_at.desc())).all() - visible = [] - for w in all_ws: - if w.status == "draft" and not (user_role in ("admin", "professor") or w.lead_id == user_id): - continue - if w.visibility == "internal" and not user_id: - continue - visible.append(w) - - reg_counts = {} - for w in visible: - reg_counts[w.id] = _ws_count_registered(db_session, w.id) - - next_sessions = {} - now = datetime.now(timezone.utc) - for w in visible: - sessions = _ws_session_helpers(db_session, w.id) - future = [s for s in sessions if s.start_time >= now] - next_sessions[w.id] = future[0] if future else (sessions[-1] if sessions else None) - - lead_ids = {w.lead_id for w in visible} - leads_map = {} - if lead_ids: - for u in db_session.exec(select(User).where(User.id.in_(lead_ids))).all(): - leads_map[u.id] = u - - filtered = visible - if current_cat: - filtered = [w for w in filtered if w.category == current_cat] - if current_status: - filtered = [w for w in filtered if w.status == current_status] - - can_create = user_role in ("admin", "professor", "lead") - - return render_template("workshops.html", - workshops=filtered, - reg_counts=reg_counts, - next_sessions=next_sessions, - leads_map=leads_map, - current_cat=current_cat, - current_status=current_status, - can_create=can_create, - WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, - WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, - WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, - WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, - WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS) - - -@app.route("/workshops/new", methods=["GET", "POST"]) -@require_role(["admin", "professor", "lead"]) -def workshop_new(): - if request.method == "POST": - with Session(engine) as db_session: - ws = Workshop( - title=request.form.get("title"), - description=request.form.get("description"), - program=request.form.get("program"), - lead_id=session.get("user_id"), - category=request.form.get("category") or "other", - difficulty=request.form.get("difficulty") or "beginner", - partner=(request.form.get("partner") or "").strip() or None, - status=request.form.get("status") or "draft", - visibility=request.form.get("visibility") or "public", - location=(request.form.get("location") or "").strip() or None, - materials_required=request.form.get("materials_required"), - outcome=request.form.get("outcome"), - has_certificate=request.form.get("has_certificate") == "on", - max_participants=int(request.form.get("max_participants")) if (request.form.get("max_participants") or "").strip().isdigit() else None, - cover_url=(request.form.get("cover_url") or "").strip() or None, - tags=normalize_tags(request.form.get("tags")), - ) - db_session.add(ws) - db_session.flush() - ws_id = ws.id - db_session.commit() - return redirect(url_for("workshop_detail", workshop_id=ws_id)) - return render_template("workshop_form.html", workshop=None, is_edit=False, - WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, - WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, - WORKSHOP_DIFFICULTIES=WORKSHOP_DIFFICULTIES, - WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, - WORKSHOP_STATUSES=WORKSHOP_STATUSES, - WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, - WORKSHOP_VISIBILITIES=WORKSHOP_VISIBILITIES, - WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS) - - -@app.route("/workshops/") -def workshop_detail(workshop_id): - user_id = session.get("user_id") - user_role = session.get("role") - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - if not ws: return "Мастер-класс не найден", 404 - perms = workshop_permissions(ws, user_id, user_role) - if ws.status == "draft" and not perms["can_manage"]: - return "403: Черновик доступен только ведущему", 403 - if ws.visibility == "internal" and not user_id: - return redirect(url_for("login")) - - lead = db_session.get(User, ws.lead_id) - sessions = _ws_session_helpers(db_session, workshop_id) - - registration = _ws_registration(db_session, workshop_id, user_id) - is_registered = registration is not None and registration.status in ("registered", "attended") - registered_count = _ws_count_registered(db_session, workshop_id) - is_full = ws.max_participants is not None and registered_count >= ws.max_participants - - materials = db_session.exec( - select(WorkshopMaterial).where(WorkshopMaterial.workshop_id == workshop_id) - .order_by(WorkshopMaterial.created_at.desc()) - ).all() - visible_materials = [] - for m in materials: - if m.visible_to == "registered" and not (is_registered or perms["can_manage"]): - continue - visible_materials.append(m) - - participants = [] - if perms["can_manage"]: - regs = db_session.exec( - select(WorkshopRegistration, User) - .join(User, WorkshopRegistration.user_id == User.id) - .where(WorkshopRegistration.workshop_id == workshop_id) - .order_by(WorkshopRegistration.registered_at) - ).all() - participants = regs - - material_files_map = {} - file_ids = [m.file_id for m in visible_materials if m.file_id] - if file_ids: - for f in db_session.exec(select(PortalFile).where(PortalFile.id.in_(file_ids))).all(): - material_files_map[f.id] = f - - return render_template("workshop_detail.html", - workshop=ws, lead=lead, sessions=sessions, - perms=perms, user_id=user_id, role=user_role, - registration=registration, is_registered=is_registered, - registered_count=registered_count, is_full=is_full, - materials=visible_materials, - material_files_map=material_files_map, - participants=participants, - WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, - WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, - WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, - WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS) - - -@app.route("/workshops//edit", methods=["GET", "POST"]) -@login_required -def workshop_edit(workshop_id): - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - if not ws: return "Мастер-класс не найден", 404 - perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) - if not perms["can_manage"]: - return "403: Нет прав на редактирование", 403 - if request.method == "POST": - ws.title = request.form.get("title") - ws.description = request.form.get("description") - ws.program = request.form.get("program") - ws.category = request.form.get("category") or "other" - ws.difficulty = request.form.get("difficulty") or "beginner" - ws.partner = (request.form.get("partner") or "").strip() or None - ws.status = request.form.get("status") or "draft" - ws.visibility = request.form.get("visibility") or "public" - ws.location = (request.form.get("location") or "").strip() or None - ws.materials_required = request.form.get("materials_required") - ws.outcome = request.form.get("outcome") - ws.has_certificate = request.form.get("has_certificate") == "on" - max_p = request.form.get("max_participants") - ws.max_participants = int(max_p) if (max_p or "").strip().isdigit() else None - ws.cover_url = (request.form.get("cover_url") or "").strip() or None - ws.tags = normalize_tags(request.form.get("tags")) - db_session.add(ws) - db_session.commit() - return redirect(url_for("workshop_detail", workshop_id=ws.id)) - return render_template("workshop_form.html", workshop=ws, is_edit=True, - WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, - WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, - WORKSHOP_DIFFICULTIES=WORKSHOP_DIFFICULTIES, - WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, - WORKSHOP_STATUSES=WORKSHOP_STATUSES, - WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, - WORKSHOP_VISIBILITIES=WORKSHOP_VISIBILITIES, - WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS) - - -@app.route("/workshops//delete", methods=["POST"]) -@login_required -def workshop_delete(workshop_id): - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - if not ws: return redirect(url_for("workshops_list")) - perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) - if not perms["can_delete"]: - return "403: Удалять мастер-класс может только ведущий или админ", 403 - db_session.delete(ws) - db_session.commit() - return redirect(url_for("workshops_list")) - - -@app.route("/workshops//sessions/add", methods=["POST"]) -@login_required -def workshop_session_add(workshop_id): - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - if not ws: return "Мастер-класс не найден", 404 - perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) - if not perms["can_manage"]: return "403: Нет прав", 403 - start = parse_datetime(request.form.get("start_time")) - end = parse_datetime(request.form.get("end_time")) - if start and end: - db_session.add(WorkshopSession( - workshop_id=workshop_id, - start_time=start, end_time=end, - location_override=(request.form.get("location_override") or "").strip() or None, - note=(request.form.get("note") or "").strip() or None, - )) - db_session.commit() - return redirect(url_for("workshop_detail", workshop_id=workshop_id)) - - -@app.route("/workshops//sessions//delete", methods=["POST"]) -@login_required -def workshop_session_delete(workshop_id, session_id): - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - if not ws: return redirect(url_for("workshops_list")) - perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) - if not perms["can_manage"]: return "403: Нет прав", 403 - s = db_session.get(WorkshopSession, session_id) - if s and s.workshop_id == workshop_id: - db_session.delete(s) - db_session.commit() - return redirect(url_for("workshop_detail", workshop_id=workshop_id)) - - -@app.route("/workshops//materials/add", methods=["POST"]) -@login_required -def workshop_material_add(workshop_id): - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - if not ws: return "Мастер-класс не найден", 404 - perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) - if not perms["can_manage"]: return "403: Нет прав", 403 - title = (request.form.get("title") or "").strip() - if title: - db_session.add(WorkshopMaterial( - workshop_id=workshop_id, title=title, - description=(request.form.get("description") or "").strip() or None, - external_url=(request.form.get("external_url") or "").strip() or None, - visible_to=request.form.get("visible_to") or "registered", - )) - db_session.commit() - regs = db_session.exec( - select(WorkshopRegistration).where(WorkshopRegistration.workshop_id == workshop_id) - .where(WorkshopRegistration.status.in_(("registered", "attended"))) - ).all() - recipients = [r.user_id for r in regs if r.user_id != session.get("user_id")] - if recipients: - notify_users(recipients, - f"Новый материал в «{ws.title}»", - title, - link_url=url_for("workshop_detail", workshop_id=workshop_id), - kind="info") - return redirect(url_for("workshop_detail", workshop_id=workshop_id)) - - -@app.route("/workshops//materials//delete", methods=["POST"]) -@login_required -def workshop_material_delete(workshop_id, material_id): - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - if not ws: return redirect(url_for("workshops_list")) - perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) - if not perms["can_manage"]: return "403: Нет прав", 403 - m = db_session.get(WorkshopMaterial, material_id) - if m and m.workshop_id == workshop_id: - db_session.delete(m) - db_session.commit() - return redirect(url_for("workshop_detail", workshop_id=workshop_id)) - - -@app.route("/workshops//register", methods=["POST"]) -@login_required -def workshop_register(workshop_id): - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - if not ws: return "Мастер-класс не найден", 404 - if ws.status != "published": - return "Регистрация доступна только для опубликованных", 400 - existing = _ws_registration(db_session, workshop_id, session.get("user_id")) - if existing and existing.status in ("registered", "attended"): - return redirect(url_for("workshop_detail", workshop_id=workshop_id)) - if ws.max_participants is not None: - count = _ws_count_registered(db_session, workshop_id) - if count >= ws.max_participants: - return redirect(url_for("workshop_detail", workshop_id=workshop_id)) - if existing: - existing.status = "registered" - existing.registered_at = datetime.now(timezone.utc) - db_session.add(existing) - else: - db_session.add(WorkshopRegistration( - workshop_id=workshop_id, user_id=session.get("user_id"), - status="registered", - )) - db_session.commit() - if ws.lead_id != session.get("user_id"): - notify_user(ws.lead_id, - f"Новая запись на «{ws.title}»", - f"Пользователь записался на ваш мастер-класс.", - link_url=url_for("workshop_detail", workshop_id=workshop_id), - kind="success") - return redirect(url_for("workshop_detail", workshop_id=workshop_id)) - - -@app.route("/workshops//unregister", methods=["POST"]) -@login_required -def workshop_unregister(workshop_id): - with Session(engine) as db_session: - ws = db_session.get(Workshop, workshop_id) - existing = _ws_registration(db_session, workshop_id, session.get("user_id")) - if existing: - existing.status = "cancelled" - db_session.add(existing) - db_session.commit() - if ws and ws.lead_id != session.get("user_id"): - notify_user(ws.lead_id, - f"Отмена записи на «{ws.title}»", - "Пользователь отменил запись на ваш мастер-класс.", - link_url=url_for("workshop_detail", workshop_id=workshop_id), - kind="warning") - return redirect(url_for("workshop_detail", workshop_id=workshop_id)) - - -# ══════════════════════════════════════════════════════════════════════ -# ФАЙЛЫ -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/files") -@login_required -def files_list(): - with Session(engine) as db_session: - files = db_session.exec( - select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id) - .order_by(PortalFile.uploaded_at.desc()) - ).all() - projects_map = {p.id: p.title for p in db_session.exec(select(Project)).all()} - return render_template("files.html", files=files, projects_map=projects_map) - - -@app.route("/files/upload", methods=["POST"]) -@login_required -def file_upload(): - try: - file = request.files.get('file') - except Exception as e: - print(f"[file_upload] Ошибка парсинга: {e}", flush=True) - return "Ошибка разбора запроса", 400 - if not file or file.filename == '': - return redirect(url_for('files_list')) - original_name = file.filename - ext = os.path.splitext(original_name)[1] - stored_name = f"{uuid.uuid4().hex}{ext}" - filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) - try: - file.save(filepath) - size_bytes = os.path.getsize(filepath) - except Exception as e: - print(f"[file_upload] Ошибка сохранения: {e}", flush=True) - return "Ошибка сохранения файла", 500 - try: - with Session(engine) as db_session: - db_session.add(PortalFile( - original_name=original_name, stored_name=stored_name, - size_bytes=size_bytes, uploaded_by=session.get("user_id"), - )) - db_session.commit() - except Exception as e: - print(f"[file_upload] Ошибка БД: {e}", flush=True) - try: os.remove(filepath) - except Exception: pass - return "Ошибка записи в БД", 500 - return redirect(url_for('files_list')) - - -@app.route("/files/download/") -@login_required -def file_download(file_id): - with Session(engine) as db_session: - db_file = db_session.get(PortalFile, file_id) - if db_file: - return send_from_directory(app.config['UPLOAD_FOLDER'], db_file.stored_name, as_attachment=True, download_name=db_file.original_name) - return "Файл не найден", 404 - - -@app.route("/files//delete", methods=["POST"]) -@login_required -def file_delete(file_id): - next_url = request.form.get("next") or "/files" - if not next_url.startswith("/"): next_url = "/files" - with Session(engine) as db_session: - db_file = db_session.get(PortalFile, file_id) - if not db_file: return redirect(next_url) - allowed = session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id") - if not allowed and db_file.project_id: - proj = db_session.get(Project, db_file.project_id) - if proj: - membership = db_session.exec( - select(ProjectMember).where(ProjectMember.project_id == db_file.project_id).where(ProjectMember.user_id == session.get("user_id")) - ).first() - perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) - if perms["can_manage"]: allowed = True - if allowed: - filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name) - if os.path.exists(filepath): os.remove(filepath) - db_session.delete(db_file) - db_session.commit() - return redirect(next_url) - - -# ══════════════════════════════════════════════════════════════════════ -# АДМИНКА: ПОЛЬЗОВАТЕЛИ -# ══════════════════════════════════════════════════════════════════════ - -@app.route("/admin/users") -@require_role("admin") -def admin_users(): - with Session(engine) as db_session: - users = db_session.exec(select(User).order_by(User.username)).all() - return render_template("admin_users.html", users=users) - - -@app.route("/admin/users//role", methods=["POST"]) -@require_role("admin") -def change_user_role(user_id): - new_role = request.form.get("role") - valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] - if new_role not in valid_roles: return "Ошибка", 400 - with Session(engine) as db_session: - user = db_session.get(User, user_id) - if user: - if user.id == session.get("user_id") and new_role != "admin": - return "Нельзя снять с себя админа", 400 - user.role = new_role - db_session.add(user) - db_session.commit() - return redirect(url_for("admin_users")) - - -@app.route("/admin/users//delete", methods=["POST"]) -@require_role("admin") -def delete_user(user_id): - if user_id == session.get("user_id"): return "Нельзя удалить себя", 400 - with Session(engine) as db_session: - user = db_session.get(User, user_id) - if not user: - return redirect(url_for("admin_users")) - owned_projects = db_session.exec(select(Project).where(Project.owner_id == user_id)).all() - uploaded_files = db_session.exec(select(PortalFile).where(PortalFile.uploaded_by == user_id)).all() - if owned_projects or uploaded_files: - return (f"Нельзя удалить {user.username}: у него {len(owned_projects)} проект(ов) " - f"и {len(uploaded_files)} файл(ов)."), 400 - owned_workshops = db_session.exec(select(Workshop).where(Workshop.lead_id == user_id)).all() - if owned_workshops: - return (f"Нельзя удалить {user.username}: он ведёт {len(owned_workshops)} мастер-класс(ов)."), 400 - for pm in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all(): - db_session.delete(pm) - for s in db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all(): - db_session.delete(s) - for sub in db_session.exec(select(EventSubscriber).where(EventSubscriber.user_id == user_id)).all(): - sub.user_id = None - db_session.add(sub) - for t in db_session.exec(select(ProjectTask).where(ProjectTask.assignee_id == user_id)).all(): - t.assignee_id = None - db_session.add(t) - for n in db_session.exec(select(Notification).where(Notification.user_id == user_id)).all(): - db_session.delete(n) - db_session.delete(user) - db_session.commit() - return redirect(url_for("admin_users")) + return jsonify({"status": gs.status, "delay_minutes": gs.delay_minutes or 0, + "message": gs.message, + "updated_at": gs.updated_at.isoformat() if gs.updated_at else None}) diff --git a/app/main.py.bak b/app/main.py.bak deleted file mode 100644 index 2bd1c67..0000000 --- a/app/main.py.bak +++ /dev/null @@ -1,679 +0,0 @@ -import os -import uuid -import smtplib -import pytz -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from itsdangerous import URLSafeTimedSerializer -from werkzeug.security import generate_password_hash, check_password_hash -from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory, make_response -from icalendar import Calendar, Event as ICalEvent -from .db import init_db, engine -from . import config, auth, assistant -from sqlmodel import Session, select -from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember -from datetime import datetime, timezone -from functools import wraps - -app = Flask(__name__) -app.secret_key = config.WEB_SECRET -serializer = URLSafeTimedSerializer(app.secret_key) - -MSK = pytz.timezone("Europe/Moscow") - -def to_msk(dt): - if dt is None: return None - if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(MSK) - -@app.template_filter("msk_strftime") -def _msk_strftime(dt, fmt): - if dt is None: return "" - return to_msk(dt).strftime(fmt) - -UPLOAD_FOLDER = '/var/lib/gitea/portal-files' -app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER -app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 - -with app.app_context(): - init_db() - with Session(engine) as db_session: - status = db_session.exec(select(GlobalStatus)).first() - if not status: - db_session.add(GlobalStatus(status="free", message="Вне расписания")) - db_session.commit() - -@app.before_request -def validate_session(): - if "session_id" in session: - with Session(engine) as db_session: - db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first() - if not db_sess: - session.clear() - return redirect(url_for("login")) - -def send_email(to_email, subject, body): - if not config.SMTP_SERVER: - print("\n" + "="*50, flush=True) - print(f"📧 EMAIL ДЛЯ: {to_email}", flush=True) - print(f"Тема: {subject}", flush=True) - print(body, flush=True) - print("="*50 + "\n", flush=True) - return True - - try: - msg = MIMEMultipart() - msg['From'] = config.SMTP_FROM - msg['To'] = to_email - msg['Subject'] = subject - msg.attach(MIMEText(body, 'plain')) - - server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT) - server.starttls() - server.login(config.SMTP_USER, config.SMTP_PASSWORD) - server.send_message(msg) - server.quit() - return True - except Exception as e: - print(f"Ошибка отправки email: {e}", flush=True) - return False - -def require_role(allowed_roles): - if isinstance(allowed_roles, str): - allowed_roles = [allowed_roles] - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if session.get("role") not in allowed_roles: - return "403: Доступ запрещен.", 403 - return f(*args, **kwargs) - return decorated_function - return decorator - -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not session.get("user_id"): - return redirect(url_for("login")) - return f(*args, **kwargs) - return decorated_function - -def parse_datetime(dt_str): - """Ввод из формы — в Europe/Moscow, сохраняем как UTC.""" - if not dt_str: return None - clean_str = dt_str.replace('T', ' ').strip() - if len(clean_str) > 16: clean_str = clean_str[:16] - naive = datetime.strptime(clean_str, "%Y-%m-%d %H:%M") - return MSK.localize(naive).astimezone(timezone.utc) - -@app.route("/healthz") -def healthz(): - try: - from sqlalchemy import text - with engine.connect() as conn: - conn.execute(text("SELECT 1")) - return jsonify({"status": "ok", "db": "ok"}) - except Exception as e: - return jsonify({"status": "error", "db": str(e)}), 503 - -@app.route("/") -def index(): - return render_template("index.html", username=session.get("username")) - -# --- АУТЕНТИФИКАЦИЯ --- -@app.route("/register", methods=["GET", "POST"]) -def register(): - if request.method == "POST": - username = request.form.get("username") - email = request.form.get("email") - password = request.form.get("password") - full_name = request.form.get("full_name") - organization = request.form.get("organization") - - with Session(engine) as db_session: - if db_session.exec(select(User).where(User.username == username)).first(): - return render_template("register.html", error="Логин уже занят") - if db_session.exec(select(User).where(User.email == email)).first(): - return render_template("register.html", error="Email уже используется") - - new_user = User(username=username, email=email, password_hash=generate_password_hash(password), full_name=full_name, organization=organization, role="student", is_verified=False) - db_session.add(new_user) - db_session.commit() - - token = serializer.dumps(email, salt='email-confirm') - confirm_url = url_for('verify_email', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") - send_email(email, "Подтверждение почты", f"Привет, {full_name}!\nДля подтверждения перейди по ссылке:\n{confirm_url}\n") - - return render_template("login.html", msg="Регистрация успешна. Проверьте почту (или логи сервера).") - return render_template("register.html") - -@app.route("/verify_email/") -def verify_email(token): - try: email = serializer.loads(token, salt='email-confirm', max_age=86400) - except Exception: return "Ссылка недействительна или устарела.", 400 - - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.email == email)).first() - if user: - user.is_verified = True - db_session.add(user) - db_session.commit() - return redirect(url_for("login", msg="Почта подтверждена! Теперь можно войти.")) - return "Пользователь не найден.", 404 - -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.username == username)).first() - if not user or not user.password_hash or not check_password_hash(user.password_hash, password): - return render_template("login.html", error="Неверный логин или пароль") - if not user.is_verified: - return render_template("login.html", error="Подтвердите почту перед входом") - - session_token = str(uuid.uuid4()) - db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string)) - db_session.commit() - - session["user_id"] = user.id - session["username"] = user.username - session["role"] = user.role - session["session_id"] = session_token - return redirect(url_for("index")) - - msg = request.args.get("msg") - return render_template("login.html", msg=msg) - -@app.route("/login/gitea") -def login_gitea(): - gitea_auth_url = ( - f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" - f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" - f"&redirect_uri={config.GITEA_REDIRECT_URI}" - f"&scope=read:user" - ) - return redirect(gitea_auth_url) - -@app.route("/auth/callback") -def auth_callback(): - code = request.args.get("code") - if not code: return "Ошибка авторизации Gitea", 400 - token = auth.get_gitea_token(code) - if not token: return "Ошибка токена Gitea", 400 - gitea_user = auth.get_gitea_user(token) - if not gitea_user: return "Ошибка профиля Gitea", 400 - - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() - if not user: - user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first() - if user: - user.gitea_id = gitea_user["id"] - else: - existing_admin = db_session.exec(select(User).where(User.role == "admin")).first() - bootstrap_role = "admin" if not existing_admin else "student" - user = User(gitea_id=gitea_user["id"], username=gitea_user["login"], full_name=gitea_user.get("full_name"), email=gitea_user.get("email"), role=bootstrap_role, is_verified=True) - db_session.add(user) - db_session.commit() - - session_token = str(uuid.uuid4()) - db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string)) - db_session.commit() - - session["user_id"] = user.id - session["username"] = user.username - session["role"] = user.role - session["session_id"] = session_token - return redirect(url_for("index")) - -@app.route("/logout") -def logout(): - if "session_id" in session: - with Session(engine) as db_session: - db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first() - if db_sess: - db_session.delete(db_sess) - db_session.commit() - session.clear() - return redirect(url_for("index")) - -@app.route("/forgot_password", methods=["GET", "POST"]) -def forgot_password(): - if request.method == "POST": - email = request.form.get("email") - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.email == email)).first() - if user: - token = serializer.dumps(email, salt='password-reset') - reset_url = url_for('reset_password', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") - send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n") - return render_template("login.html", msg="Если такой email есть, инструкция отправлена.") - return render_template("forgot_password.html") - -@app.route("/reset_password/", methods=["GET", "POST"]) -def reset_password(token): - try: email = serializer.loads(token, salt='password-reset', max_age=3600) - except Exception: return "Ссылка недействительна или устарела.", 400 - - if request.method == "POST": - with Session(engine) as db_session: - user = db_session.exec(select(User).where(User.email == email)).first() - if user: - user.password_hash = generate_password_hash(request.form.get("password")) - db_session.add(user) - db_session.commit() - return redirect(url_for("login", msg="Пароль успешно изменен!")) - return render_template("reset_password.html", token=token) - -@app.route("/sessions") -@login_required -def sessions_list(): - with Session(engine) as db_session: - sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id")).order_by(UserSession.created_at.desc())).all() - return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id")) - -@app.route("/sessions/logout_all", methods=["POST"]) -@login_required -def logout_all(): - with Session(engine) as db_session: - for s in db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all(): - db_session.delete(s) - db_session.commit() - session.clear() - return redirect(url_for("login", msg="Вы вышли со всех устройств.")) - -@app.route("/profile") -@login_required -def profile(): - with Session(engine) as db_session: - user = db_session.get(User, session.get("user_id")) - return render_template("profile.html", user=user) - -# --- РАСПИСАНИЕ (ПУБЛИЧНОЕ И АДМИНКА) --- -@app.route("/schedule") -def public_schedule(): - with Session(engine) as db_session: - current_status = db_session.exec(select(GlobalStatus)).first() - now = datetime.now(timezone.utc) - events = db_session.exec(select(Event).where(Event.is_public == True).where(Event.start_time >= now).order_by(Event.start_time).limit(10)).all() - return render_template("schedule.html", status=current_status, events=events) - -@app.route("/schedule/export.ics") -def export_ics(): - with Session(engine) as db_session: - events = db_session.exec(select(Event).where(Event.is_public == True)).all() - - cal = Calendar() - cal.add('prodid', '-//Morozovka Portal//mxm.dk//') - cal.add('version', '2.0') - - for ev in events: - ievent = ICalEvent() - ievent.add('summary', ev.title) - # Приводим к UTC для совместимости - ievent.add('dtstart', ev.start_time.astimezone(pytz.utc)) - ievent.add('dtend', ev.end_time.astimezone(pytz.utc)) - if ev.location: - ievent.add('location', ev.location) - if ev.description: - ievent.add('description', ev.description) - cal.add_component(ievent) - - response = make_response(cal.to_ical()) - response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics" - response.headers["Content-Type"] = "text/calendar; charset=utf-8" - return response - -@app.route("/api/assistant", methods=["POST"]) -def api_assistant(): - data = request.get_json() - if not data or "message" not in data: return jsonify({"error": "Нет сообщения"}), 400 - return jsonify({"reply": assistant.get_response(data["message"])}) - -@app.route("/admin/schedule") -@require_role(["admin", "professor"]) -def admin_schedule(): - with Session(engine) as db_session: - current_status = db_session.exec(select(GlobalStatus)).first() - events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).all() - return render_template("admin_schedule.html", status=current_status, events=events) - -@app.route("/admin/schedule/status", methods=["POST"]) -@require_role(["admin", "professor"]) -def update_status(): - status_val = request.form.get("status") - delay = int(request.form.get("delay_minutes") or 0) - message = request.form.get("message") - with Session(engine) as db_session: - current_status = db_session.exec(select(GlobalStatus)).first() - if current_status: - current_status.status = status_val - current_status.delay_minutes = delay - current_status.message = message - current_status.updated_at = datetime.now(timezone.utc) - db_session.add(current_status) - db_session.commit() - return redirect(url_for("admin_schedule")) - -@app.route("/admin/schedule/event/add", methods=["POST"]) -@require_role(["admin", "professor"]) -def add_event(): - title = request.form.get("title") - event_type = request.form.get("event_type") - start_time = parse_datetime(request.form.get("start_time")) - end_time = parse_datetime(request.form.get("end_time")) - with Session(engine) as db_session: - db_session.add(Event(title=title, event_type=event_type, start_time=start_time, end_time=end_time, location=request.form.get("location"), description=request.form.get("description"), is_public=request.form.get("is_public") == "on")) - db_session.commit() - return redirect(url_for("admin_schedule")) - -@app.route("/admin/schedule/event//edit", methods=["GET", "POST"]) -@require_role(["admin", "professor"]) -def edit_event(event_id): - with Session(engine) as db_session: - event = db_session.get(Event, event_id) - if not event: return "Событие не найдено", 404 - - if request.method == "POST": - event.title = request.form.get("title") - event.event_type = request.form.get("event_type") - event.start_time = parse_datetime(request.form.get("start_time")) - event.end_time = parse_datetime(request.form.get("end_time")) - event.location = request.form.get("location") - event.description = request.form.get("description") - event.is_public = request.form.get("is_public") == "on" - event.manually_edited = True - db_session.add(event) - db_session.commit() - return redirect(url_for("admin_schedule")) - - return render_template("admin_event_edit.html", event=event) - -@app.route("/admin/schedule/event//delete", methods=["POST"]) -@require_role(["admin", "professor"]) -def delete_event(event_id): - with Session(engine) as db_session: - event = db_session.get(Event, event_id) - if event: - db_session.delete(event) - db_session.commit() - return redirect(url_for("admin_schedule")) - -# --- НОВЫЕ РОУТЫ: ИМПОРТ ICS --- -@app.route("/admin/schedule/import", methods=["GET", "POST"]) -@require_role(["admin", "professor"]) -def import_ics(): - if request.method == "POST": - if 'file' not in request.files: - return "Файл не загружен", 400 - file = request.files['file'] - if file.filename == '': - return "Файл не выбран", 400 - - if file and file.filename.endswith('.ics'): - try: - cal = Calendar.from_ical(file.read()) - count = 0 - with Session(engine) as db_session: - for component in cal.walk(): - if component.name == "VEVENT": - summary = str(component.get('summary', 'Без названия')) - dtstart_prop = component.get('dtstart') - dtend_prop = component.get('dtend') - - if not dtstart_prop or not dtend_prop: - continue - - dtstart = dtstart_prop.dt - dtend = dtend_prop.dt - - # Обработка событий на весь день (datetime.date) - if not isinstance(dtstart, datetime): - dtstart = datetime.combine(dtstart, datetime.min.time()) - if not isinstance(dtend, datetime): - dtend = datetime.combine(dtend, datetime.min.time()) - - # Делаем timezone-aware (если нет зоны, считаем UTC) - if dtstart.tzinfo is None: - dtstart = dtstart.replace(tzinfo=timezone.utc) - if dtend.tzinfo is None: - dtend = dtend.replace(tzinfo=timezone.utc) - - loc = str(component.get('location', '')) - desc = str(component.get('description', '')) - - new_event = Event( - title=summary, - event_type='lecture', # По умолчанию - start_time=dtstart.astimezone(timezone.utc), - end_time=dtend.astimezone(timezone.utc), - location=loc, - description=desc, - is_public=True # Импортированные события делаем публичными - ) - db_session.add(new_event) - count += 1 - db_session.commit() - return redirect(url_for("admin_schedule")) - except Exception as e: - return f"Ошибка при чтении файла .ics: {e}", 400 - return render_template("admin_schedule_import.html") - -# --- ПРОЕКТЫ --- -@app.route("/projects") -def projects_list(): - user_role = session.get("role") - user_id = session.get("user_id") - with Session(engine) as db_session: - projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() - member_project_ids = [] - if user_id: - memberships = db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all() - member_project_ids = [m.project_id for m in memberships] - - filtered_projects = [] - for p in projects: - if p.visibility == "public": - filtered_projects.append(p) - elif p.visibility == "internal" and user_id: - filtered_projects.append(p) - elif p.visibility == "private" and user_id and (p.owner_id == user_id or p.id in member_project_ids or user_role == "admin"): - filtered_projects.append(p) - - return render_template("projects.html", projects=filtered_projects, role=user_role, user_id=user_id, member_ids=member_project_ids) - -@app.route("/projects/new", methods=["GET", "POST"]) -@require_role(["admin", "professor", "lead"]) -def project_new(): - if request.method == "POST": - with Session(engine) as db_session: - new_proj = Project( - title=request.form.get("title"), description=request.form.get("description"), - status=request.form.get("status"), visibility=request.form.get("visibility"), - gitea_url=request.form.get("gitea_url"), owner_id=session.get("user_id") - ) - db_session.add(new_proj) - db_session.commit() - db_session.refresh(new_proj) - db_session.add(ProjectMember(project_id=new_proj.id, user_id=session.get("user_id"), role="lead")) - db_session.commit() - return redirect(url_for("projects_list")) - return render_template("project_form.html", project=None, is_edit=False) - -@app.route("/projects/") -def project_detail(project_id): - user_id = session.get("user_id") - user_role = session.get("role") - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - - members = db_session.exec(select(ProjectMember, User).join(User, ProjectMember.user_id == User.id).where(ProjectMember.project_id == project_id)).all() - is_member = any(m[0].user_id == user_id for m in members) - - if proj.visibility == "private" and not is_member and user_role != "admin": - return "403: Это приватный проект", 403 - if proj.visibility == "internal" and not user_id: - return redirect(url_for("login")) - return render_template("project_detail.html", project=proj, members=members, is_member=is_member, user_id=user_id, role=user_role) - -@app.route("/projects//edit", methods=["GET", "POST"]) -@login_required -def project_edit(project_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - - membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first() - is_lead = membership and membership.role == "lead" - - if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and not is_lead: - return "403: У вас нет прав на редактирование", 403 - - if request.method == "POST": - proj.title = request.form.get("title") - proj.description = request.form.get("description") - proj.status = request.form.get("status") - proj.visibility = request.form.get("visibility") - proj.gitea_url = request.form.get("gitea_url") - db_session.add(proj) - db_session.commit() - return redirect(url_for("project_detail", project_id=proj.id)) - return render_template("project_form.html", project=proj, is_edit=True) - -@app.route("/projects//members/add", methods=["POST"]) -@login_required -def project_add_member(project_id): - username = request.form.get("username") - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - - membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first() - if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and (not membership or membership.role != "lead"): - return "403: Нет прав", 403 - - user_to_add = db_session.exec(select(User).where(User.username == username)).first() - if not user_to_add: return redirect(url_for("project_detail", project_id=project_id)) - - existing = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_to_add.id)).first() - if not existing: - db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role="member")) - db_session.commit() - return redirect(url_for("project_detail", project_id=project_id)) - -@app.route("/projects//members//remove", methods=["POST"]) -@login_required -def project_remove_member(project_id, user_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if not proj: return "Проект не найден", 404 - - membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first() - if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and (not membership or membership.role != "lead"): - return "403: Нет прав", 403 - - if user_id == proj.owner_id: return "Нельзя удалить владельца", 400 - - to_remove = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)).first() - if to_remove: - db_session.delete(to_remove) - db_session.commit() - return redirect(url_for("project_detail", project_id=project_id)) - -@app.route("/projects//delete", methods=["POST"]) -@login_required -def project_delete(project_id): - with Session(engine) as db_session: - proj = db_session.get(Project, project_id) - if proj and (session.get("role") == "admin" or proj.owner_id == session.get("user_id")): - for m in db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all(): - db_session.delete(m) - db_session.delete(proj) - db_session.commit() - return redirect(url_for("projects_list")) - -# --- ФАЙЛЫ --- -@app.route("/files") -@login_required -def files_list(): - with Session(engine) as db_session: - files = db_session.exec(select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id).order_by(PortalFile.uploaded_at.desc())).all() - return render_template("files.html", files=files) - -@app.route("/files/upload", methods=["POST"]) -@login_required -def file_upload(): - if 'file' not in request.files: return redirect(url_for('files_list')) - file = request.files['file'] - if file.filename == '': return redirect(url_for('files_list')) - if file: - original_name = file.filename - ext = os.path.splitext(original_name)[1] - stored_name = f"{uuid.uuid4().hex}{ext}" - filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) - file.save(filepath) - with Session(engine) as db_session: - db_session.add(PortalFile(original_name=original_name, stored_name=stored_name, size_bytes=os.path.getsize(filepath), uploaded_by=session.get("user_id"))) - db_session.commit() - return redirect(url_for('files_list')) - -@app.route("/files/download/") -@login_required -def file_download(file_id): - with Session(engine) as db_session: - db_file = db_session.get(PortalFile, file_id) - if db_file: - return send_from_directory(app.config['UPLOAD_FOLDER'], db_file.stored_name, as_attachment=True, download_name=db_file.original_name) - return "Файл не найден", 404 - -@app.route("/files//delete", methods=["POST"]) -@login_required -def file_delete(file_id): - with Session(engine) as db_session: - db_file = db_session.get(PortalFile, file_id) - if db_file and (session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id")): - filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name) - if os.path.exists(filepath): os.remove(filepath) - db_session.delete(db_file) - db_session.commit() - return redirect(url_for("files_list")) - -# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ --- -@app.route("/admin/users") -@require_role("admin") -def admin_users(): - with Session(engine) as db_session: - users = db_session.exec(select(User).order_by(User.username)).all() - return render_template("admin_users.html", users=users) - -@app.route("/admin/users//role", methods=["POST"]) -@require_role("admin") -def change_user_role(user_id): - new_role = request.form.get("role") - valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] - if new_role not in valid_roles: return "Ошибка", 400 - with Session(engine) as db_session: - user = db_session.get(User, user_id) - if user: - if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400 - user.role = new_role - db_session.add(user) - db_session.commit() - return redirect(url_for("admin_users")) - -@app.route("/admin/users//delete", methods=["POST"]) -@require_role("admin") -def delete_user(user_id): - if user_id == session.get("user_id"): return "Нельзя удалить себя", 400 - with Session(engine) as db_session: - for s in db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all(): - db_session.delete(s) - user = db_session.get(User, user_id) - if user: - db_session.delete(user) - db_session.commit() - return redirect(url_for("admin_users")) diff --git a/app/models.py b/app/models.py index 6fb363b..36fd4dc 100644 --- a/app/models.py +++ b/app/models.py @@ -2,6 +2,7 @@ from sqlmodel import SQLModel, Field from typing import Optional from datetime import datetime, timezone + class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) gitea_id: Optional[int] = Field(default=None, unique=True, index=True) @@ -15,6 +16,7 @@ class User(SQLModel, table=True): is_verified: bool = Field(default=False) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + class UserSession(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) session_token: str = Field(unique=True, index=True) @@ -23,6 +25,7 @@ class UserSession(SQLModel, table=True): user_agent: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + class Event(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str @@ -37,6 +40,7 @@ class Event(SQLModel, table=True): manually_edited: bool = Field(default=False) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + class GlobalStatus(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) status: str = Field(default="free") @@ -44,6 +48,7 @@ class GlobalStatus(SQLModel, table=True): message: Optional[str] = None updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + class Project(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str @@ -59,13 +64,22 @@ class Project(SQLModel, table=True): contact_person: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + class ProjectMember(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) project_id: int = Field(foreign_key="project.id") user_id: int = Field(foreign_key="user.id") role: str = Field(default="viewer") + can_view_files: bool = Field(default=True) + can_edit_files: bool = Field(default=False) + can_view_repo: bool = Field(default=True) + can_post_feed: bool = Field(default=False) + can_edit_own_posts: bool = Field(default=True) + can_edit_any_post: bool = Field(default=False) + can_manage_members: bool = Field(default=False) joined_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + class ProjectTask(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) project_id: int = Field(foreign_key="project.id") @@ -80,14 +94,49 @@ class ProjectTask(SQLModel, table=True): created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class ProjectPost(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + project_id: int = Field(foreign_key="project.id") + author_id: int = Field(foreign_key="user.id") + title: str + body: Optional[str] = None + is_pinned: bool = Field(default=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class ProjectPostAttachment(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + post_id: int = Field(foreign_key="projectpost.id") + file_id: int = Field(foreign_key="portalfile.id") + + +class ProjectSubscription(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + project_id: int = Field(foreign_key="project.id") + user_id: int = Field(foreign_key="user.id") + notify_email: bool = Field(default=True) + notify_inapp: bool = Field(default=True) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class PortalFile(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) original_name: str - stored_name: str + stored_name: str size_bytes: int uploaded_by: int = Field(foreign_key="user.id") project_id: Optional[int] = Field(default=None, foreign_key="project.id") uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + # ── v2.0 ────────────────────────────────────────────────────────── + space_id: Optional[int] = Field(default=None, foreign_key="filespace.id") + folder_id: Optional[int] = Field(default=None, foreign_key="filefolder.id") + description: Optional[str] = None + tags: Optional[str] = None + version: int = Field(default=1) + current_version_id: Optional[int] = Field(default=None) + class EventSubscriber(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) @@ -100,9 +149,6 @@ class EventSubscriber(SQLModel, table=True): notify_events: bool = Field(default=True) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) -# ══════════════════════════════════════════════════════════════════════ -# МАСТЕР-КЛАССЫ -# ══════════════════════════════════════════════════════════════════════ class Workshop(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) @@ -111,19 +157,28 @@ class Workshop(SQLModel, table=True): program: Optional[str] = None lead_id: int = Field(foreign_key="user.id") category: str = Field(default="other") - difficulty: str = Field(default="beginner") # beginner, intermediate, advanced - partner: Optional[str] = None # ННГУ, Неймарк, Ядро, ... - status: str = Field(default="draft") # draft, published, archived - visibility: str = Field(default="public") # public, internal + difficulty: str = Field(default="beginner") + partner: Optional[str] = None + status: str = Field(default="draft") + visibility: str = Field(default="public") location: Optional[str] = None materials_required: Optional[str] = None - outcome: Optional[str] = None # что получит участник + outcome: Optional[str] = None has_certificate: bool = Field(default=False) + recurrence: Optional[str] = None max_participants: Optional[int] = None cover_url: Optional[str] = None tags: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class WorkshopAssistant(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + workshop_id: int = Field(foreign_key="workshop.id") + user_id: int = Field(foreign_key="user.id") + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class WorkshopSession(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) workshop_id: int = Field(foreign_key="workshop.id") @@ -132,6 +187,7 @@ class WorkshopSession(SQLModel, table=True): location_override: Optional[str] = None note: Optional[str] = None + class WorkshopRegistration(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) workshop_id: int = Field(foreign_key="workshop.id") @@ -140,6 +196,7 @@ class WorkshopRegistration(SQLModel, table=True): notes: Optional[str] = None registered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + class WorkshopMaterial(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) workshop_id: int = Field(foreign_key="workshop.id") @@ -150,6 +207,33 @@ class WorkshopMaterial(SQLModel, table=True): visible_to: str = Field(default="registered") created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class WorkshopPost(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + workshop_id: int = Field(foreign_key="workshop.id") + author_id: int = Field(foreign_key="user.id") + title: str + body: Optional[str] = None + is_pinned: bool = Field(default=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class WorkshopPostAttachment(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + post_id: int = Field(foreign_key="workshoppost.id") + file_id: int = Field(foreign_key="portalfile.id") + + +class WorkshopSubscription(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + workshop_id: int = Field(foreign_key="workshop.id") + user_id: int = Field(foreign_key="user.id") + notify_email: bool = Field(default=True) + notify_inapp: bool = Field(default=True) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class WorkshopFeedback(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) workshop_id: int = Field(foreign_key="workshop.id") @@ -158,9 +242,6 @@ class WorkshopFeedback(SQLModel, table=True): comment: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) -# ══════════════════════════════════════════════════════════════════════ -# УВЕДОМЛЕНИЯ -# ══════════════════════════════════════════════════════════════════════ class Notification(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) @@ -168,6 +249,74 @@ class Notification(SQLModel, table=True): title: str body: Optional[str] = None link_url: Optional[str] = None - kind: str = Field(default="info") # info, success, warning, error + kind: str = Field(default="info") is_read: bool = Field(default=False, index=True) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class UserOnboarding(SQLModel, table=True): + """Прогресс прохождения обучающего тура по платформе.""" + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id", unique=True, index=True) + visited_schedule: bool = Field(default=False) + visited_projects: bool = Field(default=False) + visited_workshops: bool = Field(default=False) + visited_files: bool = Field(default=False) + visited_feed: bool = Field(default=False) + visited_profile: bool = Field(default=False) + completed_at: Optional[datetime] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class FileSpace(SQLModel, table=True): + """Пространство файлов: по проекту, по направлению, личное.""" + id: Optional[int] = Field(default=None, primary_key=True) + name: str + slug: str = Field(unique=True, index=True) + description: Optional[str] = None + owner_id: int = Field(foreign_key="user.id") + project_id: Optional[int] = Field(default=None, foreign_key="project.id") + visibility: str = Field(default="internal") # public | internal | private + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class FileFolder(SQLModel, table=True): + """Папка внутри пространства. parent_id=null — корень пространства.""" + id: Optional[int] = Field(default=None, primary_key=True) + space_id: int = Field(foreign_key="filespace.id") + parent_id: Optional[int] = Field(default=None, foreign_key="filefolder.id") + name: str + description: Optional[str] = None + created_by: int = Field(foreign_key="user.id") + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class FileSpaceACL(SQLModel, table=True): + """Права пользователя на пространство.""" + id: Optional[int] = Field(default=None, primary_key=True) + space_id: int = Field(foreign_key="filespace.id") + user_id: int = Field(foreign_key="user.id") + role: str = Field(default="viewer") # viewer | editor | manager + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class FileFolderACL(SQLModel, table=True): + """Права пользователя на папку (перекрывают права пространства).""" + id: Optional[int] = Field(default=None, primary_key=True) + folder_id: int = Field(foreign_key="filefolder.id") + user_id: int = Field(foreign_key="user.id") + can_view: bool = Field(default=True) + can_upload: bool = Field(default=False) + can_delete: bool = Field(default=False) + can_manage: bool = Field(default=False) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class FileVersion(SQLModel, table=True): + """Версия файла — каждая имеет свой stored_name.""" + id: Optional[int] = Field(default=None, primary_key=True) + file_id: int = Field(foreign_key="portalfile.id") + version_num: int = Field(default=1) + stored_name: str + size_bytes: int + uploaded_by: int = Field(foreign_key="user.id") + uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + comment: Optional[str] = None diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..8b44411 --- /dev/null +++ b/app/modules/__init__.py @@ -0,0 +1 @@ +"""Модули роутов Morozovka Portal.""" diff --git a/app/modules/admin_routes.py b/app/modules/admin_routes.py new file mode 100644 index 0000000..f80ae6e --- /dev/null +++ b/app/modules/admin_routes.py @@ -0,0 +1,104 @@ +"""Роуты админки: пользователи, роли, удаление.""" +from flask import render_template, request, redirect, url_for, session +from sqlmodel import Session, select +from ..db import engine +from ..models import ( + User, Project, PortalFile, ProjectMember, Workshop, WorkshopAssistant, + UserSession, EventSubscriber, ProjectTask, Notification, + ProjectPost, WorkshopPost, +) +from ..helpers import require_role + + +def register(app): + + @app.route("/admin/users") + @require_role("admin") + def admin_users(): + q = (request.args.get("q") or "").strip().lower() + with Session(engine) as db_session: + users = db_session.exec(select(User).order_by(User.username)).all() + if q: + users = [ + u for u in users + if q in (u.username or "").lower() + or q in (u.full_name or "").lower() + or q in (u.email or "").lower() + ] + return render_template("admin_users.html", users=users, q=q) + + @app.route("/admin/users//role", methods=["POST"]) + @require_role("admin") + def change_user_role(user_id): + new_role = request.form.get("role") + valid = ["admin", "professor", "lead", "member", "student", "guest"] + if new_role not in valid: + return "Ошибка", 400 + with Session(engine) as db_session: + user = db_session.get(User, user_id) + if user: + if user.id == session.get("user_id") and new_role != "admin": + return "Нельзя снять с себя админа", 400 + user.role = new_role + db_session.add(user) + db_session.commit() + return redirect(url_for("admin_users")) + + @app.route("/admin/users//toggle_active", methods=["POST"]) + @require_role("admin") + def toggle_user_active(user_id): + """Блокировка/разблокировка вместо удаления — безопаснее.""" + if user_id == session.get("user_id"): + return "Нельзя заблокировать себя", 400 + with Session(engine) as db_session: + user = db_session.get(User, user_id) + if user: + user.is_active = not user.is_active + db_session.add(user) + db_session.commit() + return redirect(url_for("admin_users")) + + @app.route("/admin/users//delete", methods=["POST"]) + @require_role("admin") + def delete_user(user_id): + if user_id == session.get("user_id"): + return "Нельзя удалить себя", 400 + with Session(engine) as db_session: + user = db_session.get(User, user_id) + if not user: + return redirect(url_for("admin_users")) + + # ── Проверки: нельзя удалить, если есть hard-FK зависимости ── + if db_session.exec(select(Project).where(Project.owner_id == user_id)).all(): + return "У пользователя есть проекты (передайте владение сначала)", 400 + if db_session.exec(select(PortalFile).where(PortalFile.uploaded_by == user_id)).all(): + return "У пользователя есть загруженные файлы", 400 + if db_session.exec(select(Workshop).where(Workshop.lead_id == user_id)).all(): + return "Пользователь ведёт мастер-классы", 400 + # Ниже — FK с NOT NULL, без этих проверок DELETE упадёт + if db_session.exec(select(ProjectPost).where(ProjectPost.author_id == user_id)).all(): + return "У пользователя есть посты в проектах (удалите/переатрибутируйте сначала)", 400 + if db_session.exec(select(WorkshopPost).where(WorkshopPost.author_id == user_id)).all(): + return "У пользователя есть посты в мастер-классах", 400 + if db_session.exec(select(ProjectTask).where(ProjectTask.created_by == user_id)).all(): + return "Пользователь создавал задачи (сначала переатрибутируйте)", 400 + + # ── Развязываем то, что можно обнулить / удалить каскадно ── + for pm in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all(): + db_session.delete(pm) + for wa in db_session.exec(select(WorkshopAssistant).where(WorkshopAssistant.user_id == user_id)).all(): + db_session.delete(wa) + for ss in db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all(): + db_session.delete(ss) + for sub in db_session.exec(select(EventSubscriber).where(EventSubscriber.user_id == user_id)).all(): + sub.user_id = None + db_session.add(sub) + for t in db_session.exec(select(ProjectTask).where(ProjectTask.assignee_id == user_id)).all(): + t.assignee_id = None + db_session.add(t) + for n in db_session.exec(select(Notification).where(Notification.user_id == user_id)).all(): + db_session.delete(n) + + db_session.delete(user) + db_session.commit() + return redirect(url_for("admin_users")) diff --git a/app/modules/auth_routes.py b/app/modules/auth_routes.py new file mode 100644 index 0000000..ca6d581 --- /dev/null +++ b/app/modules/auth_routes.py @@ -0,0 +1,287 @@ +"""Роуты аутентификации: регистрация, логин, OAuth, пароль, сессии, профиль, подписки на расписание.""" +import uuid +from datetime import datetime, timezone +from flask import render_template, request, redirect, url_for, session +from werkzeug.security import generate_password_hash, check_password_hash +from sqlmodel import Session, select +from ..db import engine +from .. import config, auth +from ..models import User, UserSession, EventSubscriber, UserOnboarding, Notification +from ..helpers import ( + serializer, send_email, login_required, get_user_subscription, +) + + +def _init_user_onboarding(db_session, user_id): + """Создать запись онбординга + приветственное уведомление при регистрации.""" + db_session.add(UserOnboarding(user_id=user_id)) + db_session.add(Notification( + user_id=user_id, + title="👋 Добро пожаловать на платформу!", + body="Пройди короткий тур по разделам — это займёт 2 минуты.", + link_url="/onboarding", + kind="info", + )) + db_session.commit() + + +def register(app): + + @app.route("/register", methods=["GET", "POST"]) + def register_view(): + if request.method == "POST": + username = request.form.get("username") + email = request.form.get("email") + password = request.form.get("password") + full_name = request.form.get("full_name") + organization = request.form.get("organization") + with Session(engine) as db_session: + if db_session.exec(select(User).where(User.username == username)).first(): + return render_template("register.html", error="Логин уже занят") + if db_session.exec(select(User).where(User.email == email)).first(): + return render_template("register.html", error="Email уже используется") + new_user = User(username=username, email=email, + password_hash=generate_password_hash(password), + full_name=full_name, organization=organization, + role="student", is_verified=True) + db_session.add(new_user) + db_session.commit() + db_session.refresh(new_user) + _init_user_onboarding(db_session, new_user.id) + return render_template("login.html", msg="Регистрация успешна.") + return render_template("register.html") + + app.view_functions["register"] = register_view + + @app.route("/verify_email/") + def verify_email(token): + try: + email = serializer.loads(token, salt='email-confirm', max_age=86400) + except Exception: + return "Ссылка недействительна.", 400 + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + user.is_verified = True + db_session.add(user) + db_session.commit() + return redirect(url_for("login", msg="Почта подтверждена!")) + return "Пользователь не найден.", 404 + + @app.route("/login", methods=["GET", "POST"]) + def login(): + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.username == username)).first() + if not user or not user.password_hash or not check_password_hash(user.password_hash, password): + return render_template("login.html", error="Неверный логин или пароль") + if not user.is_verified: + return render_template("login.html", error="Подтвердите почту") + if not user.is_active: + return render_template("login.html", error="Аккаунт заблокирован") + token = str(uuid.uuid4()) + db_session.add(UserSession(session_token=token, user_id=user.id, + ip_address=request.remote_addr, + user_agent=request.user_agent.string)) + db_session.commit() + session["user_id"] = user.id + session["username"] = user.username + session["role"] = user.role + session["session_id"] = token + return redirect(url_for("dashboard")) + return render_template("login.html", msg=request.args.get("msg")) + + @app.route("/login/gitea") + def login_gitea(): + return redirect(f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" + f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" + f"&redirect_uri={config.GITEA_REDIRECT_URI}&scope=read:user") + + @app.route("/auth/callback") + def auth_callback(): + code = request.args.get("code") + if not code: + return "Ошибка авторизации Gitea", 400 + token = auth.get_gitea_token(code) + if not token: + return "Ошибка токена Gitea", 400 + gitea_user = auth.get_gitea_user(token) + if not gitea_user: + return "Ошибка профиля Gitea", 400 + created = False + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() + if not user: + user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first() + if user: + user.gitea_id = gitea_user["id"] + else: + existing_admin = db_session.exec(select(User).where(User.role == "admin")).first() + user = User(gitea_id=gitea_user["id"], username=gitea_user["login"], + full_name=gitea_user.get("full_name"), email=gitea_user.get("email"), + role=("admin" if not existing_admin else "student"), is_verified=True) + db_session.add(user) + created = True + db_session.commit() + if created: + db_session.refresh(user) + _init_user_onboarding(db_session, user.id) + token2 = str(uuid.uuid4()) + db_session.add(UserSession(session_token=token2, user_id=user.id, + ip_address=request.remote_addr, + user_agent=request.user_agent.string)) + db_session.commit() + session["user_id"] = user.id + session["username"] = user.username + session["role"] = user.role + session["session_id"] = token2 + return redirect(url_for("onboarding_page") if created else url_for("dashboard")) + + @app.route("/logout") + def logout(): + if "session_id" in session: + with Session(engine) as db_session: + db_sess = db_session.exec(select(UserSession) + .where(UserSession.session_token == session["session_id"])).first() + if db_sess: + db_session.delete(db_sess) + db_session.commit() + session.clear() + return redirect(url_for("index")) + + @app.route("/forgot_password", methods=["GET", "POST"]) + def forgot_password(): + if request.method == "POST": + email = request.form.get("email") + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + token = serializer.dumps(email, salt='password-reset') + reset_url = (url_for('reset_password', token=token, _external=True) + .replace("127.0.0.1:8000", "portal.morozovka.space") + .replace("http://", "https://")) + send_email(email, "Сброс пароля", f"Ссылка:\n{reset_url}") + return render_template("login.html", msg="Если email есть, инструкция отправлена.") + return render_template("forgot_password.html") + + @app.route("/reset_password/", methods=["GET", "POST"]) + def reset_password(token): + try: + email = serializer.loads(token, salt='password-reset', max_age=3600) + except Exception: + return "Ссылка недействительна.", 400 + if request.method == "POST": + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + user.password_hash = generate_password_hash(request.form.get("password")) + db_session.add(user) + db_session.commit() + return redirect(url_for("login", msg="Пароль изменён!")) + return render_template("reset_password.html", token=token) + + @app.route("/sessions") + @login_required + def sessions_list(): + with Session(engine) as db_session: + sessions = db_session.exec( + select(UserSession).where(UserSession.user_id == session.get("user_id")) + .order_by(UserSession.created_at.desc()) + ).all() + return render_template("sessions.html", sessions=sessions, + current_token=session.get("session_id")) + + @app.route("/sessions/logout_all", methods=["POST"]) + @login_required + def logout_all(): + with Session(engine) as db_session: + for s in db_session.exec(select(UserSession) + .where(UserSession.user_id == session.get("user_id"))).all(): + db_session.delete(s) + db_session.commit() + session.clear() + return redirect(url_for("login", msg="Вы вышли со всех устройств.")) + + @app.route("/profile") + @login_required + def profile(): + with Session(engine) as db_session: + user = db_session.get(User, session.get("user_id")) + subscription = get_user_subscription(db_session, user) + onboarding = db_session.exec( + select(UserOnboarding).where(UserOnboarding.user_id == user.id) + ).first() + return render_template("profile.html", user=user, subscription=subscription, + onboarding=onboarding) + + @app.route("/subscribe/toggle", methods=["POST"]) + @login_required + def subscribe_toggle(): + action = request.form.get("action") + notify_status = request.form.get("notify_status") == "on" + notify_events = request.form.get("notify_events") == "on" + next_url = request.form.get("next") or url_for("profile") + if not next_url.startswith("/"): + next_url = url_for("profile") + with Session(engine) as db_session: + user = db_session.get(User, session.get("user_id")) + if not user or not user.email: + return redirect(next_url) + existing = get_user_subscription(db_session, user) + if action == "unsubscribe": + if existing: + db_session.delete(existing) + db_session.commit() + return redirect(next_url) + if existing: + existing.email = user.email + existing.user_id = user.id + existing.notify_status = notify_status + existing.notify_events = notify_events + existing.is_confirmed = True + existing.confirm_token = None + db_session.add(existing) + else: + db_session.add(EventSubscriber( + email=user.email, user_id=user.id, + unsubscribe_token=str(uuid.uuid4()), + is_confirmed=True, confirm_token=None, + notify_status=notify_status, notify_events=notify_events)) + db_session.commit() + return redirect(next_url) + + @app.route("/subscribe/confirm/") + def subscribe_confirm(token): + with Session(engine) as db_session: + sub = db_session.exec(select(EventSubscriber) + .where(EventSubscriber.confirm_token == token)).first() + if not sub: + return render_template("subscribe_status.html", title="Ссылка недействительна", + message="Возможно, устарела.", ok=False) + sub.is_confirmed = True + sub.confirm_token = None + db_session.add(sub) + db_session.commit() + return render_template("subscribe_status.html", title="Подписка подтверждена!", + message=f"Уведомления на {sub.email}.", ok=True, + unsubscribe_token=sub.unsubscribe_token) + + @app.route("/unsubscribe/", methods=["GET", "POST"]) + def unsubscribe(token): + with Session(engine) as db_session: + sub = db_session.exec(select(EventSubscriber) + .where(EventSubscriber.unsubscribe_token == token)).first() + if not sub: + return render_template("subscribe_status.html", title="Ссылка недействительна", + message="Такой подписки нет.", ok=False) + if request.method == "POST": + db_session.delete(sub) + db_session.commit() + return render_template("subscribe_status.html", title="Вы отписаны", + message="Больше не будет присылать письма.", ok=True) + return render_template("subscribe_status.html", title="Отписаться?", + message=f"Вы перестанете получать письма на {sub.email}.", + ok=True, show_confirm_form=True, + confirm_action=url_for("unsubscribe", token=token)) diff --git a/app/modules/dashboard_routes.py b/app/modules/dashboard_routes.py new file mode 100644 index 0000000..f14c72d --- /dev/null +++ b/app/modules/dashboard_routes.py @@ -0,0 +1,121 @@ +"""Дашборд участника — персональный экран после входа (§3.6 ТЗ).""" +from datetime import datetime, timezone + +from flask import render_template, session +from sqlmodel import Session, select + +from ..db import engine +from ..models import ( + Event, Project, ProjectMember, ProjectTask, + Workshop, WorkshopMaterial, WorkshopRegistration, + Notification, +) +from ..helpers import login_required +from .. import gitea_api + + +# Приоритеты в порядке важности (0 = самый важный) +_PRIORITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3} + + +def register(app): + + @app.route("/dashboard") + @login_required + def dashboard(): + user_id = session.get("user_id") + now = datetime.now(timezone.utc) + + with Session(engine) as db_session: + + # ── 1. Ближайшие события (5) ───────────────────────────── + upcoming_events = db_session.exec( + select(Event) + .where(Event.is_public == True) + .where(Event.start_time >= now) + .order_by(Event.start_time) + .limit(5) + ).all() + + # ── 2. Мои проекты ──────────────────────────────────────── + my_projects = db_session.exec( + select(Project, ProjectMember) + .join(ProjectMember, ProjectMember.project_id == Project.id) + .where(ProjectMember.user_id == user_id) + .order_by(Project.created_at.desc()) + ).all() + + # ── 3. Мои задачи (не done, топ-10) ─────────────────────── + my_tasks_raw = list(db_session.exec( + select(ProjectTask, Project) + .join(Project, ProjectTask.project_id == Project.id) + .where(ProjectTask.assignee_id == user_id) + .where(ProjectTask.status != "done") + ).all()) + # Сортируем в Python: по приоритету (critical → low), потом по due_date + my_tasks_raw.sort(key=lambda row: ( + _PRIORITY_ORDER.get(row[0].priority, 4), + row[0].due_date or "9999-12-31", + )) + my_tasks = my_tasks_raw[:10] + + # ── 4. Непрочитанные уведомления (10) ──────────────────── + unread = db_session.exec( + select(Notification) + .where(Notification.user_id == user_id) + .where(Notification.is_read == False) + .order_by(Notification.created_at.desc()) + .limit(10) + ).all() + unread_count = len(unread) + + # ── 5. Новые материалы МК (где записан, 5) ─────────────── + my_workshop_ids = [ + r.workshop_id for r in db_session.exec( + select(WorkshopRegistration) + .where(WorkshopRegistration.user_id == user_id) + .where(WorkshopRegistration.status.in_(("registered", "attended"))) + ).all() + ] + new_materials = [] + if my_workshop_ids: + new_materials = db_session.exec( + select(WorkshopMaterial, Workshop) + .join(Workshop, WorkshopMaterial.workshop_id == Workshop.id) + .where(WorkshopMaterial.workshop_id.in_(my_workshop_ids)) + .order_by(WorkshopMaterial.created_at.desc()) + .limit(5) + ).all() + + # ── 6. Gitea-коммиты (топ-3 проекта с repo, по 3 коммита) ─ + gitea_widget = [] + for proj, _member in my_projects[:10]: + repo = proj.gitea_repo or ( + gitea_api.extract_repo_from_url(proj.gitea_url) if proj.gitea_url else None + ) + if not repo or "/" not in repo: + continue + try: + owner, name = repo.split("/", 1) + commits = gitea_api.get_latest_commits(owner, name, limit=3) + if commits: + gitea_widget.append({ + "project_id": proj.id, + "project_title": proj.title, + "commits": commits, + }) + except Exception as e: + print(f"[dashboard] gitea {repo}: {e}", flush=True) + if len(gitea_widget) >= 3: + break + + return render_template( + "dashboard.html", + upcoming_events=upcoming_events, + my_projects=my_projects, + my_tasks=my_tasks, + unread=unread, + unread_count=unread_count, + new_materials=new_materials, + gitea_widget=gitea_widget, + ) diff --git a/app/modules/feed_routes.py b/app/modules/feed_routes.py new file mode 100644 index 0000000..8d22656 --- /dev/null +++ b/app/modules/feed_routes.py @@ -0,0 +1,157 @@ +"""Общая лента: все посты из мастер-классов и проектов в одном потоке.""" +from datetime import datetime +from flask import render_template, request, session +from sqlmodel import Session, select +from ..db import engine +from ..models import ( + Workshop, WorkshopPost, WorkshopPostAttachment, + Project, ProjectPost, ProjectPostAttachment, ProjectMember, + PortalFile, User, +) +from .. import gitea_api + + +def register(app): + + @app.route("/feed") + def feed(): + user_id = session.get("user_id") + user_role = session.get("role") + source = (request.args.get("source") or "all").strip() # all | workshops | projects + q = (request.args.get("q") or "").strip().lower() + + items = [] + all_file_ids = set() + + with Session(engine) as db_session: + # ─── Посты МК ─────────────────────────────────────────────── + if source in ("all", "workshops"): + rows = db_session.exec( + select(WorkshopPost, Workshop, User) + .join(Workshop, WorkshopPost.workshop_id == Workshop.id) + .join(User, WorkshopPost.author_id == User.id) + .order_by(WorkshopPost.created_at.desc()) + .limit(200) + ).all() + + # Фильтр видимости + visible_ws_rows = [] + for post, ws, au in rows: + if ws.status == "draft" and not (user_role == "admin" or ws.lead_id == user_id): + continue + if ws.visibility == "internal" and not user_id: + continue + visible_ws_rows.append((post, ws, au)) + + # Аттачи + ws_post_ids = [p.id for p, _, _ in visible_ws_rows] + atts_by_post = {} + if ws_post_ids: + for a in db_session.exec( + select(WorkshopPostAttachment) + .where(WorkshopPostAttachment.post_id.in_(ws_post_ids)) + ).all(): + atts_by_post.setdefault(a.post_id, []).append(a.file_id) + all_file_ids.add(a.file_id) + + for post, ws, au in visible_ws_rows: + items.append({ + "kind": "workshop", + "kind_label": "Мастер-класс", + "kind_icon": "🎓", + "post_id": post.id, + "title": post.title, + "body": post.body, + "author_username": au.username, + "author_name": au.full_name or au.username, + "created_at": post.created_at, + "is_pinned": post.is_pinned, + "source_title": ws.title, + "source_url": f"/workshops/{ws.id}", + "attachments": atts_by_post.get(post.id, []), + }) + + # ─── Посты проектов ───────────────────────────────────────── + if source in ("all", "projects"): + rows = db_session.exec( + select(ProjectPost, Project, User) + .join(Project, ProjectPost.project_id == Project.id) + .join(User, ProjectPost.author_id == User.id) + .order_by(ProjectPost.created_at.desc()) + .limit(200) + ).all() + + # Предзагрузка membership (чтобы не делать запрос в цикле) + membership_map = {} + if user_id: + for m in db_session.exec( + select(ProjectMember).where(ProjectMember.user_id == user_id) + ).all(): + membership_map[m.project_id] = m + + visible_proj_rows = [] + for post, proj, au in rows: + if proj.visibility == "internal" and not user_id: + continue + if proj.visibility == "private": + if not user_id: continue + if proj.owner_id != user_id and user_role != "admin" and proj.id not in membership_map: + continue + visible_proj_rows.append((post, proj, au)) + + proj_post_ids = [p.id for p, _, _ in visible_proj_rows] + atts_by_post = {} + if proj_post_ids: + for a in db_session.exec( + select(ProjectPostAttachment) + .where(ProjectPostAttachment.post_id.in_(proj_post_ids)) + ).all(): + atts_by_post.setdefault(a.post_id, []).append(a.file_id) + all_file_ids.add(a.file_id) + + for post, proj, au in visible_proj_rows: + items.append({ + "kind": "project", + "kind_label": "Проект", + "kind_icon": "🗂", + "post_id": post.id, + "title": post.title, + "body": post.body, + "author_username": au.username, + "author_name": au.full_name or au.username, + "created_at": post.created_at, + "is_pinned": post.is_pinned, + "source_title": proj.title, + "source_url": f"/projects/{proj.id}", + "attachments": atts_by_post.get(post.id, []), + }) + + # Файлы для превью + files_map = {} + if all_file_ids: + for f in db_session.exec( + select(PortalFile).where(PortalFile.id.in_(list(all_file_ids))) + ).all(): + files_map[f.id] = f + + # Сортировка: закреплённые сверху, потом по дате + items.sort(key=lambda x: (not x["is_pinned"], -(x["created_at"].timestamp() if x["created_at"] else 0))) + + # Поиск + if q: + items = [i for i in items + if q in i["title"].lower() + or (i["body"] and q in i["body"].lower()) + or q in i["source_title"].lower() + or q in i["author_username"].lower()] + + # Ограничение для рендера + total_count = len(items) + items = items[:100] + + return render_template("feed.html", + items=items, + files_map=files_map, + total_count=total_count, + current_source=source, + q=request.args.get("q", "")) diff --git a/app/modules/files_routes.py b/app/modules/files_routes.py new file mode 100644 index 0000000..88a4f3e --- /dev/null +++ b/app/modules/files_routes.py @@ -0,0 +1,1063 @@ +"""Роуты файлов v2.0: пространства, папки, версии, ACL, поиск, перемещение, удаление.""" +import os +import uuid +import shutil + +from flask import ( + render_template, request, redirect, url_for, session, + send_from_directory, jsonify, +) +from sqlmodel import Session, select +from sqlalchemy import or_ + +from ..db import engine +from ..models import ( + PortalFile, Project, ProjectMember, User, + FileSpace, FileFolder, FileSpaceACL, FileFolderACL, FileVersion, + ProjectPostAttachment, WorkshopPostAttachment, +) +from ..helpers import ( + login_required, require_role, project_permissions, + _save_uploaded_files, notify_user, + _slugify, _unique_space_slug, _get_space_acl, _get_folder_acl, + get_space_permissions, get_folder_permissions, + check_file_access, build_breadcrumbs, +) + + +# ══════════════════════════════════════════════════════════════════════ +# УТИЛИТЫ УДАЛЕНИЯ (на уровне модуля, не роутов) +# ══════════════════════════════════════════════════════════════════════ +def _gather_and_delete_file(db_session, db_file): + """Удаляет запись файла + все версии + вложения из БД. + НЕ коммитит, НЕ трогает блобы на диске. + Возвращает set(stored_names) для последующей очистки с диска.""" + stored = {db_file.stored_name} + for v in db_session.exec(select(FileVersion).where(FileVersion.file_id == db_file.id)).all(): + stored.add(v.stored_name) + db_session.delete(v) + for att in db_session.exec(select(ProjectPostAttachment).where(ProjectPostAttachment.file_id == db_file.id)).all(): + db_session.delete(att) + for att in db_session.exec(select(WorkshopPostAttachment).where(WorkshopPostAttachment.file_id == db_file.id)).all(): + db_session.delete(att) + db_session.delete(db_file) + return stored + + +def _delete_folder_tree(db_session, folder_id): + """Рекурсивно удаляет папку + все подпапки + их файлы + ACL. + НЕ коммитит. Возвращает set(stored_names) для очистки с диска.""" + stored = set() + # 1. Файлы в этой папке + for f in db_session.exec(select(PortalFile).where(PortalFile.folder_id == folder_id)).all(): + stored |= _gather_and_delete_file(db_session, f) + # 2. Подпапки (рекурсивно) + for child in db_session.exec(select(FileFolder).where(FileFolder.parent_id == folder_id)).all(): + stored |= _delete_folder_tree(db_session, child.id) + # 3. ACL этой папки + for acl in db_session.exec(select(FileFolderACL).where(FileFolderACL.folder_id == folder_id)).all(): + db_session.delete(acl) + # 4. Сама папка + folder = db_session.get(FileFolder, folder_id) + if folder: + db_session.delete(folder) + db_session.flush() + return stored + + +def _delete_blobs_from_disk(upload_folder, stored_names): + """Удаляет файлы с диска. Вызывать ТОЛЬКО после успешного commit.""" + for name in (stored_names or ()): + try: + path = os.path.join(upload_folder, name) + if os.path.exists(path): + os.remove(path) + except Exception as e: + print(f"[delete_blob] {name}: {e}", flush=True) + + +def _delete_file_and_blobs(db_session, db_file, upload_folder): + """Совместимость: удалить один файл + закоммитить + очистить диск.""" + stored = _gather_and_delete_file(db_session, db_file) + db_session.commit() + _delete_blobs_from_disk(upload_folder, stored) + + +# ══════════════════════════════════════════════════════════════════════ +def register(app): + + # ══════════════════════════════════════════════════════════════════ + # ГЛАВНАЯ: список пространств + «Без пространства» + # ══════════════════════════════════════════════════════════════════ + @app.route("/files") + @login_required + def files_list(): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + all_spaces = db_session.exec( + select(FileSpace).order_by(FileSpace.created_at.desc()) + ).all() + + visible_spaces = [] + for sp in all_spaces: + acl = _get_space_acl(db_session, sp.id, user_id) + perms = get_space_permissions(sp, user_id, user_role, acl) + if perms["can_view"]: + fcount = len(db_session.exec( + select(PortalFile).where(PortalFile.space_id == sp.id) + ).all()) + visible_spaces.append({ + "space": sp, "perms": perms, "file_count": fcount, + }) + + loose_files_raw = db_session.exec( + select(PortalFile, User.username) + .join(User, PortalFile.uploaded_by == User.id) + .where(PortalFile.space_id == None) # noqa: E711 + .order_by(PortalFile.uploaded_at.desc()) + ).all() + + projects_map = {p.id: p.title for p in db_session.exec(select(Project)).all()} + + return render_template( + "files.html", + spaces=visible_spaces, + loose_files=loose_files_raw, + projects_map=projects_map, + can_create_space=user_role in ("admin", "professor", "lead"), + view="root", + ) + + # ══════════════════════════════════════════════════════════════════ + # ПРОСТРАНСТВО + # ══════════════════════════════════════════════════════════════════ + @app.route("/files/space/new", methods=["GET", "POST"]) + @require_role(["admin", "professor", "lead"]) + def filespace_new(): + user_id = session.get("user_id") + if request.method == "POST": + name = (request.form.get("name") or "").strip() + if not name: + return render_template("files_edit.html", mode="space_new", + error="Название обязательно", form=request.form) + description = (request.form.get("description") or "").strip() or None + visibility = request.form.get("visibility") or "internal" + if visibility not in ("public", "internal", "private"): + visibility = "internal" + project_id = request.form.get("project_id") + project_id = int(project_id) if (project_id or "").isdigit() else None + + with Session(engine) as db_session: + slug = _unique_space_slug(db_session, _slugify(name)) + sp = FileSpace( + name=name, + slug=slug, + description=description, + owner_id=user_id, + project_id=project_id, + visibility=visibility, + ) + db_session.add(sp) + db_session.flush() + space_id = sp.id + db_session.commit() + return redirect(url_for("filespace_view", space_id=space_id)) + + with Session(engine) as db_session: + my_projects = db_session.exec( + select(Project, ProjectMember) + .join(ProjectMember, ProjectMember.project_id == Project.id) + .where(ProjectMember.user_id == user_id) + .order_by(Project.title) + ).all() + return render_template("files_edit.html", mode="space_new", + form={}, projects=my_projects) + + @app.route("/files/space/") + @login_required + def filespace_view(space_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + space = db_session.get(FileSpace, space_id) + if not space: + return "Пространство не найдено", 404 + acl = _get_space_acl(db_session, space_id, user_id) + perms = get_space_permissions(space, user_id, user_role, acl) + if not perms["can_view"]: + return "403: Нет доступа", 403 + + folders = db_session.exec( + select(FileFolder) + .where(FileFolder.space_id == space_id) + .where(FileFolder.parent_id == None) # noqa: E711 + .order_by(FileFolder.name) + ).all() + + files_raw = db_session.exec( + select(PortalFile, User.username) + .join(User, PortalFile.uploaded_by == User.id) + .where(PortalFile.space_id == space_id) + .where(PortalFile.folder_id == None) # noqa: E711 + .order_by(PortalFile.uploaded_at.desc()) + ).all() + + breadcrumbs = build_breadcrumbs(db_session, space=space) + + return render_template( + "files.html", + view="space", + space=space, + perms=perms, + folders=folders, + files=files_raw, + breadcrumbs=breadcrumbs, + loose_files=None, + spaces=None, + ) + + @app.route("/files/space//edit", methods=["GET", "POST"]) + @login_required + def filespace_edit(space_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + space = db_session.get(FileSpace, space_id) + if not space: + return "Пространство не найдено", 404 + acl = _get_space_acl(db_session, space_id, user_id) + perms = get_space_permissions(space, user_id, user_role, acl) + if not perms["can_manage"]: + return "403: Нет прав", 403 + + if request.method == "POST": + space.name = (request.form.get("name") or space.name).strip() + space.description = (request.form.get("description") or "").strip() or None + visibility = request.form.get("visibility") or space.visibility + if visibility in ("public", "internal", "private"): + space.visibility = visibility + db_session.add(space) + db_session.commit() + return redirect(url_for("filespace_view", space_id=space_id)) + + return render_template("files_edit.html", mode="space_edit", + space=space, perms=perms, form={}) + + @app.route("/files/space//delete", methods=["POST"]) + @login_required + def filespace_delete(space_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + space = db_session.get(FileSpace, space_id) + if not space: + return redirect(url_for("files_list")) + acl = _get_space_acl(db_session, space_id, user_id) + perms = get_space_permissions(space, user_id, user_role, acl) + if not (perms["is_owner"] or user_role == "admin"): + return "403: Удалить может только владелец или admin", 403 + + stored_names = set() + + # 1. Все файлы пространства (в корне и в папках) + all_files = db_session.exec( + select(PortalFile).where(PortalFile.space_id == space_id) + ).all() + for f in all_files: + stored_names |= _gather_and_delete_file(db_session, f) + + # 2. Все корневые папки — рекурсивно (вложенные обработаются внутри) + root_folders = db_session.exec( + select(FileFolder) + .where(FileFolder.space_id == space_id) + .where(FileFolder.parent_id == None) # noqa: E711 + ).all() + for fd in root_folders: + stored_names |= _delete_folder_tree(db_session, fd.id) + + # 3. ACL пространства + for acl_e in db_session.exec( + select(FileSpaceACL).where(FileSpaceACL.space_id == space_id) + ).all(): + db_session.delete(acl_e) + + # 4. Само пространство + db_session.delete(space) + db_session.commit() + + # 5. Блобы — после успешного коммита + _delete_blobs_from_disk(app.config['UPLOAD_FOLDER'], stored_names) + return redirect(url_for("files_list")) + + # ══════════════════════════════════════════════════════════════════ + # ПАПКА + # ══════════════════════════════════════════════════════════════════ + @app.route("/files/folder/") + @login_required + def folder_view(folder_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + folder = db_session.get(FileFolder, folder_id) + if not folder: + return "Папка не найдена", 404 + space = db_session.get(FileSpace, folder.space_id) + if not space: + return "Пространство не найдено", 404 + space_acl = _get_space_acl(db_session, space.id, user_id) + space_perms = get_space_permissions(space, user_id, user_role, space_acl) + folder_acl = _get_folder_acl(db_session, folder_id, user_id) + perms = get_folder_permissions(folder, user_id, user_role, space_perms, folder_acl) + if not perms["can_view"]: + return "403: Нет доступа", 403 + + subfolders = db_session.exec( + select(FileFolder) + .where(FileFolder.parent_id == folder_id) + .order_by(FileFolder.name) + ).all() + + files_raw = db_session.exec( + select(PortalFile, User.username) + .join(User, PortalFile.uploaded_by == User.id) + .where(PortalFile.folder_id == folder_id) + .order_by(PortalFile.uploaded_at.desc()) + ).all() + + breadcrumbs = build_breadcrumbs(db_session, space=space, folder=folder) + + return render_template( + "files.html", + view="folder", + space=space, + folder=folder, + perms=perms, + folders=subfolders, + files=files_raw, + breadcrumbs=breadcrumbs, + loose_files=None, + spaces=None, + ) + + @app.route("/files/folder/new", methods=["POST"]) + @login_required + def folder_new(): + user_id = session.get("user_id") + user_role = session.get("role") + space_id = request.form.get("space_id") + parent_id = request.form.get("parent_id") + name = (request.form.get("name") or "").strip() + if not space_id or not space_id.isdigit() or not name: + return "Ошибка параметров", 400 + + space_id = int(space_id) + parent_id = int(parent_id) if (parent_id or "").isdigit() else None + + with Session(engine) as db_session: + space = db_session.get(FileSpace, space_id) + if not space: + return "Пространство не найдено", 404 + space_acl = _get_space_acl(db_session, space_id, user_id) + space_perms = get_space_permissions(space, user_id, user_role, space_acl) + if not space_perms["can_upload"]: + return "403: Нет прав на создание папки", 403 + + fd = FileFolder( + space_id=space_id, + parent_id=parent_id, + name=name, + description=(request.form.get("description") or "").strip() or None, + created_by=user_id, + ) + db_session.add(fd) + db_session.commit() + + if parent_id: + return redirect(url_for("folder_view", folder_id=parent_id)) + return redirect(url_for("filespace_view", space_id=space_id)) + + @app.route("/files/folder//delete", methods=["POST"]) + @login_required + def folder_delete(folder_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + folder = db_session.get(FileFolder, folder_id) + if not folder: + return redirect(url_for("files_list")) + space = db_session.get(FileSpace, folder.space_id) + if not space: + return redirect(url_for("files_list")) + space_acl = _get_space_acl(db_session, space.id, user_id) + space_perms = get_space_permissions(space, user_id, user_role, space_acl) + folder_acl = _get_folder_acl(db_session, folder_id, user_id) + perms = get_folder_permissions(folder, user_id, user_role, space_perms, folder_acl) + if not perms["can_delete"]: + return "403: Нет прав", 403 + + parent_id = folder.parent_id + space_id = folder.space_id + + # Рекурсивное удаление папки + содержимого (коммит внутри хелпера не делаем) + stored_names = _delete_folder_tree(db_session, folder_id) + db_session.commit() + + _delete_blobs_from_disk(app.config['UPLOAD_FOLDER'], stored_names) + + if parent_id: + return redirect(url_for("folder_view", folder_id=parent_id)) + return redirect(url_for("filespace_view", space_id=space_id)) + + # ══════════════════════════════════════════════════════════════════ + # ЗАГРУЗКА ФАЙЛА + # ══════════════════════════════════════════════════════════════════ + @app.route("/files/upload", methods=["POST"]) + @login_required + def file_upload(): + user_id = session.get("user_id") + user_role = session.get("role") + space_id = request.form.get("space_id") + folder_id = request.form.get("folder_id") + + if not space_id: + file = request.files.get("file") + if not file or not file.filename: + return redirect(url_for("files_list")) + _save_uploaded_files([file], user_id, upload_folder=app.config['UPLOAD_FOLDER']) + return redirect(url_for("files_list")) + + if not space_id.isdigit(): + return "Ошибка параметров", 400 + space_id = int(space_id) + folder_id = int(folder_id) if (folder_id or "").isdigit() else None + + with Session(engine) as db_session: + space = db_session.get(FileSpace, space_id) + if not space: + return "Пространство не найдено", 404 + space_acl = _get_space_acl(db_session, space_id, user_id) + space_perms = get_space_permissions(space, user_id, user_role, space_acl) + + if folder_id: + folder = db_session.get(FileFolder, folder_id) + if not folder or folder.space_id != space_id: + return "Папка не найдена", 404 + folder_acl = _get_folder_acl(db_session, folder_id, user_id) + perms = get_folder_permissions(folder, user_id, user_role, space_perms, folder_acl) + else: + perms = space_perms + + if not perms.get("can_upload"): + return "403: Нет прав на загрузку", 403 + + file = request.files.get("file") + if not file or not file.filename: + target = url_for("folder_view", folder_id=folder_id) if folder_id else url_for("filespace_view", space_id=space_id) + return redirect(target) + + _save_uploaded_files( + [file], user_id, + upload_folder=app.config['UPLOAD_FOLDER'], + space_id=space_id, + folder_id=folder_id, + ) + + if folder_id: + return redirect(url_for("folder_view", folder_id=folder_id)) + return redirect(url_for("filespace_view", space_id=space_id)) + + # ══════════════════════════════════════════════════════════════════ + # СКАЧИВАНИЕ / ПРЕВЬЮ + # ══════════════════════════════════════════════════════════════════ + @app.route("/files/download/") + @login_required + def file_download(file_id): + return _serve_file(file_id, as_attachment=True) + + @app.route("/files/preview/") + @login_required + def file_preview(file_id): + return _serve_file(file_id, as_attachment=False) + + def _serve_file(file_id, as_attachment): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if not db_file: + return "Файл не найден", 404 + perms = check_file_access(db_session, db_file, user_id, user_role) + if not perms["can_download"]: + return "403: Нет доступа", 403 + + if db_file.current_version_id: + fv = db_session.get(FileVersion, db_file.current_version_id) + stored_name = fv.stored_name if fv else db_file.stored_name + else: + stored_name = db_file.stored_name + + path = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) + if not os.path.exists(path): + return "Файл на диске отсутствует", 404 + return send_from_directory( + app.config['UPLOAD_FOLDER'], stored_name, + as_attachment=as_attachment, + download_name=db_file.original_name, + ) + + # ══════════════════════════════════════════════════════════════════ + # РЕДАКТИРОВАНИЕ / УДАЛЕНИЕ ФАЙЛА + # ══════════════════════════════════════════════════════════════════ + @app.route("/files//edit", methods=["GET", "POST"]) + @login_required + def file_edit(file_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if not db_file: + return "Файл не найден", 404 + perms = check_file_access(db_session, db_file, user_id, user_role) + if not perms["can_edit"]: + return "403: Нет прав", 403 + + if request.method == "POST": + db_file.description = (request.form.get("description") or "").strip() or None + db_file.tags = (request.form.get("tags") or "").strip() or None + if db_file.space_id: + new_folder = request.form.get("folder_id") + db_file.folder_id = int(new_folder) if (new_folder or "").isdigit() else None + db_session.add(db_file) + db_session.commit() + target = url_for("folder_view", folder_id=db_file.folder_id) if db_file.folder_id else url_for("filespace_view", space_id=db_file.space_id) + return redirect(target) + + folders_available = [] + if db_file.space_id: + folders_available = db_session.exec( + select(FileFolder).where(FileFolder.space_id == db_file.space_id).order_by(FileFolder.name) + ).all() + return render_template("files_edit.html", mode="file_edit", + db_file=db_file, perms=perms, + folders=folders_available, form={}) + + @app.route("/files//delete", methods=["POST"]) + @login_required + def file_delete(file_id): + user_id = session.get("user_id") + user_role = session.get("role") + next_url = request.form.get("next") or "/files" + if not next_url.startswith("/"): + next_url = "/files" + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if not db_file: + return redirect(next_url) + perms = check_file_access(db_session, db_file, user_id, user_role) + if not perms["can_delete"]: + return "403: Нет прав", 403 + stored = _gather_and_delete_file(db_session, db_file) + db_session.commit() + _delete_blobs_from_disk(app.config['UPLOAD_FOLDER'], stored) + return redirect(next_url) + + # ══════════════════════════════════════════════════════════════════ + # ПОИСК + # ══════════════════════════════════════════════════════════════════ + @app.route("/files/search") + @login_required + def file_search(): + user_id = session.get("user_id") + user_role = session.get("role") + q = (request.args.get("q") or "").strip().lower() + scope_space = request.args.get("space_id") + + with Session(engine) as db_session: + query = select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id) + if scope_space and scope_space.isdigit(): + query = query.where(PortalFile.space_id == int(scope_space)) + if q: + query = query.where(or_( + PortalFile.original_name.ilike(f"%{q}%"), + PortalFile.description.ilike(f"%{q}%"), + PortalFile.tags.ilike(f"%{q}%"), + )) + query = query.order_by(PortalFile.uploaded_at.desc()).limit(200) + results = db_session.exec(query).all() + + visible = [] + for f, uploader in results: + perms = check_file_access(db_session, f, user_id, user_role) + if perms["can_view"]: + visible.append((f, uploader)) + + space_scope = None + if scope_space and scope_space.isdigit(): + space_scope = db_session.get(FileSpace, int(scope_space)) + + return render_template( + "files.html", + view="search", + q=q, + results=visible, + space_scope=space_scope, + spaces=None, + files=None, + folders=None, + loose_files=None, + breadcrumbs=None, + perms={"can_view": True}, + ) + + # ══════════════════════════════════════════════════════════════════ + # ACL ПРОСТРАНСТВА + # ══════════════════════════════════════════════════════════════════ + @app.route("/files/space//acl") + @login_required + def filespace_acl(space_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + space = db_session.get(FileSpace, space_id) + if not space: + return "Пространство не найдено", 404 + space_acl = _get_space_acl(db_session, space_id, user_id) + perms = get_space_permissions(space, user_id, user_role, space_acl) + if not perms["can_manage"]: + return "403: Нет прав на управление ACL", 403 + acl_entries = db_session.exec( + select(FileSpaceACL, User) + .join(User, FileSpaceACL.user_id == User.id) + .where(FileSpaceACL.space_id == space_id) + .order_by(User.username) + ).all() + owner = db_session.get(User, space.owner_id) + return render_template("files_acl.html", mode="space", + space=space, owner=owner, acl_entries=acl_entries) + + @app.route("/files/space//acl/add", methods=["POST"]) + @login_required + def filespace_acl_add(space_id): + user_id = session.get("user_id") + user_role = session.get("role") + username = (request.form.get("username") or "").strip() + role = request.form.get("role") or "viewer" + if role not in ("viewer", "editor", "manager"): + role = "viewer" + with Session(engine) as db_session: + space = db_session.get(FileSpace, space_id) + if not space: + return "Пространство не найдено", 404 + space_acl = _get_space_acl(db_session, space_id, user_id) + perms = get_space_permissions(space, user_id, user_role, space_acl) + if not perms["can_manage"]: + return "403", 403 + target_user = db_session.exec(select(User).where(User.username == username)).first() + if not target_user: + return redirect(url_for("filespace_acl", space_id=space_id)) + if target_user.id == space.owner_id: + return redirect(url_for("filespace_acl", space_id=space_id)) + existing = db_session.exec( + select(FileSpaceACL) + .where(FileSpaceACL.space_id == space_id) + .where(FileSpaceACL.user_id == target_user.id) + ).first() + if existing: + existing.role = role + db_session.add(existing) + else: + db_session.add(FileSpaceACL( + space_id=space_id, user_id=target_user.id, role=role, + )) + db_session.commit() + return redirect(url_for("filespace_acl", space_id=space_id)) + + @app.route("/files/space//acl//remove", methods=["POST"]) + @login_required + def filespace_acl_remove(space_id, acl_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + space = db_session.get(FileSpace, space_id) + if not space: + return redirect(url_for("files_list")) + space_acl = _get_space_acl(db_session, space_id, user_id) + perms = get_space_permissions(space, user_id, user_role, space_acl) + if not perms["can_manage"]: + return "403", 403 + entry = db_session.get(FileSpaceACL, acl_id) + if entry and entry.space_id == space_id: + db_session.delete(entry) + db_session.commit() + return redirect(url_for("filespace_acl", space_id=space_id)) + + # ══════════════════════════════════════════════════════════════════ + # ACL ПАПКИ + # ══════════════════════════════════════════════════════════════════ + @app.route("/files/folder//acl") + @login_required + def folder_acl(folder_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + folder = db_session.get(FileFolder, folder_id) + if not folder: + return "Папка не найдена", 404 + space = db_session.get(FileSpace, folder.space_id) + if not space: + return "Пространство не найдено", 404 + space_acl = _get_space_acl(db_session, space.id, user_id) + space_perms = get_space_permissions(space, user_id, user_role, space_acl) + folder_acl_entry = _get_folder_acl(db_session, folder_id, user_id) + if not (space_perms["can_manage"] or (folder_acl_entry and folder_acl_entry.can_manage)): + return "403: Нет прав на управление ACL папки", 403 + acl_entries = db_session.exec( + select(FileFolderACL, User) + .join(User, FileFolderACL.user_id == User.id) + .where(FileFolderACL.folder_id == folder_id) + .order_by(User.username) + ).all() + return render_template("files_acl.html", mode="folder", + folder=folder, space=space, acl_entries=acl_entries) + + @app.route("/files/folder//acl/add", methods=["POST"]) + @login_required + def folder_acl_add(folder_id): + user_id = session.get("user_id") + user_role = session.get("role") + username = (request.form.get("username") or "").strip() + can_view = request.form.get("can_view") == "on" + can_upload = request.form.get("can_upload") == "on" + can_delete = request.form.get("can_delete") == "on" + can_manage = request.form.get("can_manage") == "on" + with Session(engine) as db_session: + folder = db_session.get(FileFolder, folder_id) + if not folder: + return "Папка не найдена", 404 + space = db_session.get(FileSpace, folder.space_id) + space_acl = _get_space_acl(db_session, space.id, user_id) + space_perms = get_space_permissions(space, user_id, user_role, space_acl) + folder_acl_entry = _get_folder_acl(db_session, folder_id, user_id) + if not (space_perms["can_manage"] or (folder_acl_entry and folder_acl_entry.can_manage)): + return "403", 403 + target_user = db_session.exec(select(User).where(User.username == username)).first() + if not target_user: + return redirect(url_for("folder_acl", folder_id=folder_id)) + existing = db_session.exec( + select(FileFolderACL) + .where(FileFolderACL.folder_id == folder_id) + .where(FileFolderACL.user_id == target_user.id) + ).first() + if existing: + existing.can_view = can_view + existing.can_upload = can_upload + existing.can_delete = can_delete + existing.can_manage = can_manage + db_session.add(existing) + else: + db_session.add(FileFolderACL( + folder_id=folder_id, user_id=target_user.id, + can_view=can_view, can_upload=can_upload, + can_delete=can_delete, can_manage=can_manage, + )) + db_session.commit() + return redirect(url_for("folder_acl", folder_id=folder_id)) + + @app.route("/files/folder//acl//save", methods=["POST"]) + @login_required + def folder_acl_save(folder_id, acl_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + folder = db_session.get(FileFolder, folder_id) + if not folder: + return "Папка не найдена", 404 + space = db_session.get(FileSpace, folder.space_id) + space_acl = _get_space_acl(db_session, space.id, user_id) + space_perms = get_space_permissions(space, user_id, user_role, space_acl) + folder_acl_entry = _get_folder_acl(db_session, folder_id, user_id) + if not (space_perms["can_manage"] or (folder_acl_entry and folder_acl_entry.can_manage)): + return "403", 403 + entry = db_session.get(FileFolderACL, acl_id) + if entry and entry.folder_id == folder_id: + entry.can_view = request.form.get("can_view") == "on" + entry.can_upload = request.form.get("can_upload") == "on" + entry.can_delete = request.form.get("can_delete") == "on" + entry.can_manage = request.form.get("can_manage") == "on" + db_session.add(entry) + db_session.commit() + return redirect(url_for("folder_acl", folder_id=folder_id)) + + @app.route("/files/folder//acl//remove", methods=["POST"]) + @login_required + def folder_acl_remove(folder_id, acl_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + folder = db_session.get(FileFolder, folder_id) + if not folder: + return redirect(url_for("files_list")) + space = db_session.get(FileSpace, folder.space_id) + space_acl = _get_space_acl(db_session, space.id, user_id) + space_perms = get_space_permissions(space, user_id, user_role, space_acl) + folder_acl_entry = _get_folder_acl(db_session, folder_id, user_id) + if not (space_perms["can_manage"] or (folder_acl_entry and folder_acl_entry.can_manage)): + return "403", 403 + entry = db_session.get(FileFolderACL, acl_id) + if entry and entry.folder_id == folder_id: + db_session.delete(entry) + db_session.commit() + return redirect(url_for("folder_acl", folder_id=folder_id)) + + # ══════════════════════════════════════════════════════════════════ + # ВЕРСИИ ФАЙЛА + # ══════════════════════════════════════════════════════════════════ + @app.route("/files//history") + @login_required + def file_history(file_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if not db_file: + return "Файл не найден", 404 + perms = check_file_access(db_session, db_file, user_id, user_role) + if not perms["can_view"]: + return "403: Нет доступа", 403 + versions = db_session.exec( + select(FileVersion, User) + .join(User, FileVersion.uploaded_by == User.id) + .where(FileVersion.file_id == file_id) + .order_by(FileVersion.version_num.desc()) + ).all() + owner_username = db_session.exec(select(User).where(User.id == db_file.uploaded_by)).first() + return render_template("files_history.html", + db_file=db_file, versions=versions, + perms=perms, owner=owner_username) + + @app.route("/files//new_version", methods=["POST"]) + @login_required + def file_new_version(file_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if not db_file: + return "Файл не найден", 404 + perms = check_file_access(db_session, db_file, user_id, user_role) + if not perms["can_edit"]: + return "403", 403 + if db_file.space_id is None: + return "Версии доступны только для файлов в пространстве", 400 + file = request.files.get("file") + if not file or not file.filename: + return redirect(url_for("file_history", file_id=file_id)) + + ext = os.path.splitext(file.filename)[1] + stored_name = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) + try: + file.save(filepath) + size_bytes = os.path.getsize(filepath) + except Exception as e: + return f"Ошибка сохранения: {e}", 500 + + new_version_num = (db_file.version or 1) + 1 + fv = FileVersion( + file_id=db_file.id, + version_num=new_version_num, + stored_name=stored_name, + size_bytes=size_bytes, + uploaded_by=user_id, + comment=(request.form.get("comment") or "").strip() or None, + ) + db_session.add(fv) + db_session.flush() + db_file.version = new_version_num + db_file.current_version_id = fv.id + db_file.size_bytes = size_bytes + db_session.add(db_file) + db_session.commit() + return redirect(url_for("file_history", file_id=file_id)) + + @app.route("/files//version//download") + @login_required + def file_version_download(file_id, version_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if not db_file: + return "Файл не найден", 404 + perms = check_file_access(db_session, db_file, user_id, user_role) + if not perms["can_download"]: + return "403", 403 + fv = db_session.get(FileVersion, version_id) + if not fv or fv.file_id != file_id: + return "Версия не найдена", 404 + path = os.path.join(app.config['UPLOAD_FOLDER'], fv.stored_name) + if not os.path.exists(path): + return "Файл версии отсутствует на диске", 404 + base, ext = os.path.splitext(db_file.original_name) + dl_name = f"{base}_v{fv.version_num}{ext}" + return send_from_directory( + app.config['UPLOAD_FOLDER'], fv.stored_name, + as_attachment=True, download_name=dl_name, + ) + + @app.route("/files//revert/", methods=["POST"]) + @login_required + def file_revert(file_id, version_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if not db_file: + return "Файл не найден", 404 + perms = check_file_access(db_session, db_file, user_id, user_role) + if not perms["can_edit"]: + return "403", 403 + old_fv = db_session.get(FileVersion, version_id) + if not old_fv or old_fv.file_id != file_id: + return "Версия не найдена", 404 + old_path = os.path.join(app.config['UPLOAD_FOLDER'], old_fv.stored_name) + if not os.path.exists(old_path): + return "Файл старой версии отсутствует на диске", 404 + + ext = os.path.splitext(old_fv.stored_name)[1] + new_stored = f"{uuid.uuid4().hex}{ext}" + new_path = os.path.join(app.config['UPLOAD_FOLDER'], new_stored) + try: + shutil.copy2(old_path, new_path) + except Exception as e: + return f"Ошибка копирования: {e}", 500 + + new_version_num = (db_file.version or 1) + 1 + fv = FileVersion( + file_id=db_file.id, + version_num=new_version_num, + stored_name=new_stored, + size_bytes=old_fv.size_bytes, + uploaded_by=user_id, + comment=f"Откат к v{old_fv.version_num}", + ) + db_session.add(fv) + db_session.flush() + db_file.version = new_version_num + db_file.current_version_id = fv.id + db_file.size_bytes = old_fv.size_bytes + db_session.add(db_file) + db_session.commit() + return redirect(url_for("file_history", file_id=file_id)) + + # ══════════════════════════════════════════════════════════════════ + # ПЕРЕМЕЩЕНИЕ ФАЙЛА + # ══════════════════════════════════════════════════════════════════ + @app.route("/files//move", methods=["GET", "POST"]) + @login_required + def file_move(file_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if not db_file: + return "Файл не найден", 404 + perms = check_file_access(db_session, db_file, user_id, user_role) + if not perms["can_edit"]: + return "403: Нет прав на перемещение", 403 + + if request.method == "POST": + new_space_raw = request.form.get("space_id") + new_folder_raw = request.form.get("folder_id") + new_space_id = int(new_space_raw) if (new_space_raw or "").isdigit() else None + new_folder_id = int(new_folder_raw) if (new_folder_raw or "").isdigit() else None + + target_folder = None + if new_folder_id: + target_folder = db_session.get(FileFolder, new_folder_id) + if not target_folder: + return "Папка не найдена", 404 + new_space_id = target_folder.space_id + + if new_space_id: + target_space = db_session.get(FileSpace, new_space_id) + if not target_space: + return "Пространство не найдено", 404 + target_space_acl = _get_space_acl(db_session, new_space_id, user_id) + target_perms = get_space_permissions(target_space, user_id, user_role, target_space_acl) + if target_folder: + target_folder_acl = _get_folder_acl(db_session, new_folder_id, user_id) + folder_perms = get_folder_permissions( + target_folder, user_id, user_role, target_perms, target_folder_acl + ) + if not folder_perms.get("can_upload"): + return "403: Нет прав на запись в целевую папку", 403 + else: + if not target_perms.get("can_upload"): + return "403: Нет прав на запись в целевое пространство", 403 + + if new_space_id and not db_file.current_version_id: + fv = FileVersion( + file_id=db_file.id, + version_num=1, + stored_name=db_file.stored_name, + size_bytes=db_file.size_bytes, + uploaded_by=db_file.uploaded_by, + comment="Мигрировано при перемещении в пространство", + ) + db_session.add(fv) + db_session.flush() + db_file.current_version_id = fv.id + db_file.version = 1 + + if not new_space_id and db_file.current_version_id: + versions = db_session.exec( + select(FileVersion).where(FileVersion.file_id == db_file.id) + ).all() + for v in versions: + if v.stored_name != db_file.stored_name: + try: + p = os.path.join(app.config['UPLOAD_FOLDER'], v.stored_name) + if os.path.exists(p): + os.remove(p) + except Exception as e: + print(f"[file_move] cleanup {v.stored_name}: {e}", flush=True) + db_session.delete(v) + db_file.current_version_id = None + db_file.version = 1 + + db_file.space_id = new_space_id + db_file.folder_id = new_folder_id + db_session.add(db_file) + db_session.commit() + + if new_folder_id: + return redirect(url_for("folder_view", folder_id=new_folder_id)) + if new_space_id: + return redirect(url_for("filespace_view", space_id=new_space_id)) + return redirect(url_for("files_list")) + + all_spaces = db_session.exec(select(FileSpace).order_by(FileSpace.name)).all() + available_spaces = [] + for sp in all_spaces: + space_acl = _get_space_acl(db_session, sp.id, user_id) + sp_perms = get_space_permissions(sp, user_id, user_role, space_acl) + if sp_perms.get("can_upload"): + available_spaces.append(sp) + + folders_by_space = {} + for sp in available_spaces: + flds = db_session.exec( + select(FileFolder).where(FileFolder.space_id == sp.id).order_by(FileFolder.name) + ).all() + if flds: + folders_by_space[sp.id] = [ + {"id": f.id, "name": f.name, "parent_id": f.parent_id} + for f in flds + ] + + return render_template("files_edit.html", mode="file_move", + db_file=db_file, perms=perms, + available_spaces=available_spaces, + folders_by_space=folders_by_space) diff --git a/app/modules/gitea_routes.py b/app/modules/gitea_routes.py new file mode 100644 index 0000000..b432bfc --- /dev/null +++ b/app/modules/gitea_routes.py @@ -0,0 +1,83 @@ +"""Gitea webhook: принимает push-события, обновляет кэш, уведомляет подписчиков.""" +import hmac +import hashlib +import json +from datetime import datetime +from flask import request, jsonify +from sqlmodel import Session, select +from ..db import engine +from ..models import Project +from ..helpers import ( + _project_subscriber_ids, notify_users, +) +from .. import config, gitea_api + + +def _verify_signature(raw_body, sig_header): + if not config.GITEA_WEBHOOK_SECRET: + return True + if not sig_header: + return False + expected = hmac.new( + config.GITEA_WEBHOOK_SECRET.encode("utf-8"), + raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, sig_header) + + +def register(app): + + @app.route("/webhooks/gitea", methods=["POST"]) + def gitea_webhook(): + raw = request.get_data() or b"" + sig = request.headers.get("X-Gitea-Signature", "") + if not _verify_signature(raw, sig): + return jsonify({"ok": False, "error": "invalid_signature"}), 401 + + event_type = request.headers.get("X-Gitea-Event", "") + try: + payload = json.loads(raw.decode("utf-8")) + except Exception: + return jsonify({"ok": False, "error": "bad_json"}), 400 + + repo_full = ((payload.get("repository") or {}).get("full_name") or "").strip() + if not repo_full: + return jsonify({"ok": False, "error": "no_repo"}), 400 + + # Инвалидируем кэш gitea_api + dropped = gitea_api.invalidate_cache(repo_full) + + # Найдём проект(ы), привязанные к этому репо + linked_projects = [] + with Session(engine) as db_session: + for p in db_session.exec(select(Project)).all(): + repo = p.gitea_repo or (gitea_api.extract_repo_from_url(p.gitea_url) if p.gitea_url else None) + if repo == repo_full: + linked_projects.append({"id": p.id, "title": p.title}) + + # Уведомления о push + if event_type == "push": + commits = payload.get("commits") or [] + pusher = (payload.get("pusher") or {}).get("username") or "" + branch = (payload.get("ref") or "").replace("refs/heads/", "") or "?" + + if commits: + n = len(commits) + first_msg = (commits[0].get("message") or "").split("\n")[0][:100] + title = f"Новый push в {repo_full} ({n} коммит{'ов' if n > 1 else ''})" + body = f"Ветка: {branch}\nАвтор: {pusher}\nПервый коммит: {first_msg}" + with Session(engine) as s: + for lp in linked_projects: + sub_ids = _project_subscriber_ids(s, lp["id"]) + if sub_ids: + notify_users(sub_ids, title, body, + link_url=f"/projects/{lp['id']}", + kind="info") + + # Gitea ждёт 200-204 + return jsonify({ + "ok": True, + "event": event_type, + "repo": repo_full, + "cache_invalidated_keys": dropped, + "linked_projects": linked_projects, + }) diff --git a/app/modules/notifications_routes.py b/app/modules/notifications_routes.py new file mode 100644 index 0000000..cb47038 --- /dev/null +++ b/app/modules/notifications_routes.py @@ -0,0 +1,63 @@ +"""Роуты для уведомлений (in-app колокольчик).""" +from flask import render_template, jsonify, session +from sqlmodel import Session, select +from ..db import engine +from ..models import Notification +from ..helpers import login_required + + +def register(app): + + @app.route("/api/notifications") + @login_required + def api_notifications_list(): + uid = session.get("user_id") + with Session(engine) as s: + items = s.exec(select(Notification).where(Notification.user_id == uid) + .order_by(Notification.created_at.desc()).limit(20)).all() + unread = s.exec(select(Notification).where(Notification.user_id == uid) + .where(Notification.is_read == False)).all() + return jsonify({ + "items": [{"id": n.id, "title": n.title, "body": n.body, + "link_url": n.link_url, "kind": n.kind, "is_read": n.is_read, + "created_at": n.created_at.isoformat() + "Z"} for n in items], + "unread": len(unread)}) + + @app.route("/api/notifications/count") + @login_required + def api_notifications_count(): + uid = session.get("user_id") + with Session(engine) as s: + unread = s.exec(select(Notification).where(Notification.user_id == uid) + .where(Notification.is_read == False)).all() + return jsonify({"unread": len(unread)}) + + @app.route("/api/notifications//read", methods=["POST"]) + @login_required + def api_notifications_read(nid): + uid = session.get("user_id") + with Session(engine) as s: + n = s.get(Notification, nid) + if not n or n.user_id != uid: return jsonify({"ok": False}), 403 + n.is_read = True; s.add(n); s.commit() + return jsonify({"ok": True}) + + @app.route("/api/notifications/read-all", methods=["POST"]) + @login_required + def api_notifications_read_all(): + uid = session.get("user_id") + with Session(engine) as s: + for n in s.exec(select(Notification).where(Notification.user_id == uid) + .where(Notification.is_read == False)).all(): + n.is_read = True; s.add(n) + s.commit() + return jsonify({"ok": True}) + + @app.route("/notifications") + @login_required + def notifications_page(): + uid = session.get("user_id") + with Session(engine) as s: + items = s.exec(select(Notification).where(Notification.user_id == uid) + .order_by(Notification.created_at.desc()).limit(100)).all() + return render_template("notifications_page.html", notifications=items) diff --git a/app/modules/onboarding_routes.py b/app/modules/onboarding_routes.py new file mode 100644 index 0000000..edf5279 --- /dev/null +++ b/app/modules/onboarding_routes.py @@ -0,0 +1,107 @@ +"""Онбординг: пошаговый тур по разделам для новых пользователей.""" +from datetime import datetime, timezone +from flask import render_template, request, redirect, url_for, session, jsonify +from sqlmodel import Session, select +from ..db import engine +from ..models import UserOnboarding +from ..helpers import login_required + + +STEPS = [ + {"key": "visited_schedule", "num": 1, "icon": "📅", "title": "Расписание", + "url": "/schedule", + "desc": "Календарь занятий, статус Никиты Сергеевича, ассистент. Спроси «Где сейчас?» — ответит."}, + {"key": "visited_projects", "num": 2, "icon": "🗂", "title": "Проекты", + "url": "/projects", + "desc": "Каталог проектов команды: карточки, участники, канбан-доски, лента новостей."}, + {"key": "visited_workshops", "num": 3, "icon": "🎓", "title": "Мастер-классы", + "url": "/workshops", + "desc": "Пайка, embedded, Linux, творчество. Регистрация на занятия, материалы, посещаемость."}, + {"key": "visited_files", "num": 4, "icon": "📁", "title": "Файлы", + "url": "/files", + "desc": "Единое хранилище документов. Загружай PDF, картинки, схемы — коллеги скачают."}, + {"key": "visited_feed", "num": 5, "icon": "📰", "title": "Лента", + "url": "/feed", + "desc": "Все новости из проектов и мастер-классов в одном потоке. Поиск и фильтры."}, + {"key": "visited_profile", "num": 6, "icon": "👤", "title": "Профиль", + "url": "/profile", + "desc": "Подписки на уведомления, активные сессии, безопасность."}, +] + +_TOTAL = len(STEPS) + + +def ensure_onboarding(db_session, user_id): + """Создать запись прогресса, если её нет. Вернуть объект.""" + if not user_id: + return None + ob = db_session.exec(select(UserOnboarding).where(UserOnboarding.user_id == user_id)).first() + if not ob: + ob = UserOnboarding(user_id=user_id) + db_session.add(ob) + db_session.commit() + db_session.refresh(ob) + return ob + + +def _progress(ob): + completed = sum(1 for s in STEPS if getattr(ob, s["key"])) + return completed, _TOTAL + + +def register(app): + + @app.route("/onboarding") + @login_required + def onboarding_page(): + user_id = session.get("user_id") + with Session(engine) as db_session: + ob = ensure_onboarding(db_session, user_id) + completed, total = _progress(ob) + return render_template("onboarding.html", steps=STEPS, + ob=ob, completed=completed, total=total, + just_done=request.args.get("done") == "1") + + @app.route("/onboarding/mark", methods=["POST"]) + @login_required + def onboarding_mark(): + data = request.get_json(silent=True) or {} + key = data.get("key") + value = bool(data.get("value", True)) + if key not in {s["key"] for s in STEPS}: + return jsonify({"ok": False, "error": "bad_key"}), 400 + user_id = session.get("user_id") + with Session(engine) as db_session: + ob = ensure_onboarding(db_session, user_id) + setattr(ob, key, value) + all_done = all(getattr(ob, s["key"]) for s in STEPS) + if all_done and ob.completed_at is None: + ob.completed_at = datetime.now(timezone.utc) + elif not all_done: + ob.completed_at = None + db_session.add(ob) + db_session.commit() + completed, total = _progress(ob) + is_complete = ob.completed_at is not None + return jsonify({"ok": True, "completed": completed, "total": total, + "is_complete": is_complete}) + + @app.route("/onboarding/skip", methods=["POST"]) + @login_required + def onboarding_skip(): + """Пропустить тур (не отмечать ничего).""" + return redirect(url_for("index", skip_tour="1")) + + @app.route("/onboarding/complete", methods=["POST"]) + @login_required + def onboarding_complete(): + """Отметить всё сразу — для тех, кто уже разобрался.""" + user_id = session.get("user_id") + with Session(engine) as db_session: + ob = ensure_onboarding(db_session, user_id) + for s in STEPS: + setattr(ob, s["key"], True) + ob.completed_at = datetime.now(timezone.utc) + db_session.add(ob) + db_session.commit() + return redirect(url_for("onboarding_page", done="1")) diff --git a/app/modules/projects_routes.py b/app/modules/projects_routes.py new file mode 100644 index 0000000..864ba09 --- /dev/null +++ b/app/modules/projects_routes.py @@ -0,0 +1,586 @@ +"""Роуты проектов: каталог, карточка, участники, лента, файлы, канбан.""" +from datetime import datetime, timezone +from flask import render_template, request, redirect, url_for, session, jsonify +from sqlmodel import Session, select +from ..db import engine +from ..models import ( + Project, ProjectMember, ProjectPost, ProjectPostAttachment, + ProjectSubscription, ProjectTask, PortalFile, User, +) +from ..helpers import ( + login_required, require_role, project_permissions, + normalize_tags, get_all_tags, resolve_gitea_repo, + _project_subscription, _project_subscriber_ids, + _save_uploaded_files, notify_user, notify_users, + PROJECT_ROLE_RANK, TASK_STATUSES, TASK_STATUS_LABELS, TASK_PRIORITIES, +) +from .. import gitea_api + + +def register(app): + + @app.route("/projects") + def projects_list(): + user_role = session.get("role") + user_id = session.get("user_id") + current_tag = (request.args.get("tag") or "").strip().lower() + current_status = (request.args.get("status") or "").strip() + with Session(engine) as db_session: + all_projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() + member_map = {} + if user_id: + for m in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all(): + member_map[m.project_id] = m.role + all_tags = get_all_tags(db_session) + filtered = [] + for p in all_projects: + if p.visibility == "public": filtered.append(p) + elif p.visibility == "internal" and user_id: filtered.append(p) + elif p.visibility == "private" and user_id and ( + p.owner_id == user_id or p.id in member_map or user_role == "admin" + ): filtered.append(p) + if current_status: + filtered = [p for p in filtered if p.status == current_status] + if current_tag: + filtered = [p for p in filtered + if p.tags and current_tag in [t.strip().lower() for t in p.tags.split(",")]] + return render_template("projects.html", projects=filtered, role=user_role, user_id=user_id, + member_map=member_map, all_tags=all_tags, + current_tag=current_tag, current_status=current_status) + + @app.route("/projects/new", methods=["GET", "POST"]) + @require_role(["admin", "professor", "lead"]) + def project_new(): + if request.method == "POST": + with Session(engine) as db_session: + new_proj = Project( + title=request.form.get("title"), + description=request.form.get("description"), + status=request.form.get("status"), + visibility=request.form.get("visibility"), + gitea_url=(request.form.get("gitea_url") or "").strip() or None, + gitea_repo=resolve_gitea_repo(request.form), + tags=normalize_tags(request.form.get("tags")), + start_date=(request.form.get("start_date") or "").strip() or None, + end_date=(request.form.get("end_date") or "").strip() or None, + contact_person=(request.form.get("contact_person") or "").strip() or None, + owner_id=session.get("user_id")) + db_session.add(new_proj); db_session.flush() + proj_id = new_proj.id + db_session.add(ProjectMember( + project_id=proj_id, user_id=session.get("user_id"), + role="manager", can_edit_files=True, can_post_feed=True, + can_edit_any_post=True, can_manage_members=True)) + db_session.commit() + return redirect(url_for("project_detail", project_id=proj_id)) + return render_template("project_form.html", project=None, is_edit=False) + + @app.route("/projects/") + def project_detail(project_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = None + if user_id: + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == user_id)).first() + perms = project_permissions(proj, user_id, user_role, membership) + if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: + return "403: Это приватный проект", 403 + if proj.visibility == "internal" and not user_id: + return redirect(url_for("login")) + members = db_session.exec( + select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) + .where(ProjectMember.project_id == project_id).order_by(User.username)).all() + files = db_session.exec( + select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id) + .where(PortalFile.project_id == project_id) + .order_by(PortalFile.uploaded_at.desc())).all() + task_counts = {s: 0 for s in TASK_STATUSES} + for t in db_session.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all(): + if t.status in task_counts: task_counts[t.status] += 1 + posts = db_session.exec( + select(ProjectPost).where(ProjectPost.project_id == project_id) + .order_by(ProjectPost.is_pinned.desc(), ProjectPost.created_at.desc())).all() + post_ids = [p.id for p in posts] + attachments_by_post = {} + if post_ids: + for att in db_session.exec(select(ProjectPostAttachment) + .where(ProjectPostAttachment.post_id.in_(post_ids))).all(): + attachments_by_post.setdefault(att.post_id, []).append(att) + file_ids = [a.file_id for arr in attachments_by_post.values() for a in arr] + files_map = {} + if file_ids: + for f in db_session.exec(select(PortalFile).where(PortalFile.id.in_(file_ids))).all(): + files_map[f.id] = f + author_ids = {p.author_id for p in posts} + authors_map = {} + if author_ids: + for u in db_session.exec(select(User).where(User.id.in_(author_ids))).all(): + authors_map[u.id] = u + my_sub = _project_subscription(db_session, project_id, user_id) + return render_template("project_detail.html", project=proj, members=members, files=files, + perms=perms, user_id=user_id, role=user_role, + active_tab="overview", task_counts=task_counts, + posts=posts, attachments_by_post=attachments_by_post, + files_map=files_map, authors_map=authors_map, my_sub=my_sub) + + @app.route("/projects//gitea-stats") + def project_gitea_stats(project_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return jsonify({"ok": False, "error": "not_found"}), 404 + membership = None + if session.get("user_id"): + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: + return jsonify({"ok": False, "error": "forbidden"}), 403 + repo = proj.gitea_repo or (gitea_api.extract_repo_from_url(proj.gitea_url) if proj.gitea_url else None) + if not repo: + return jsonify({"ok": False, "error": "no_repo", + "message": "У проекта не указан репозиторий."}) + return jsonify(gitea_api.get_project_stats(repo)) + + @app.route("/projects//edit", methods=["GET", "POST"]) + @login_required + def project_edit(project_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if not perms["can_manage_members"] and not perms["is_admin"] and not perms["is_owner"]: + return "403: Нет прав", 403 + if request.method == "POST": + proj.title = request.form.get("title") + proj.description = request.form.get("description") + proj.status = request.form.get("status") + proj.visibility = request.form.get("visibility") + proj.gitea_url = (request.form.get("gitea_url") or "").strip() or None + proj.gitea_repo = resolve_gitea_repo(request.form, proj.gitea_url, proj.gitea_repo) + proj.tags = normalize_tags(request.form.get("tags")) + proj.start_date = (request.form.get("start_date") or "").strip() or None + proj.end_date = (request.form.get("end_date") or "").strip() or None + proj.contact_person = (request.form.get("contact_person") or "").strip() or None + db_session.add(proj); db_session.commit() + return redirect(url_for("project_detail", project_id=proj.id)) + return render_template("project_form.html", project=proj, is_edit=True) + + @app.route("/projects//members/add", methods=["POST"]) + @login_required + def project_add_member(project_id): + username = (request.form.get("username") or "").strip() + role = request.form.get("role") or "viewer" + if role not in PROJECT_ROLE_RANK: role = "viewer" + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if not perms["can_manage_members"]: return "403: Нет прав", 403 + user_to_add = db_session.exec(select(User).where(User.username == username)).first() + if not user_to_add: return redirect(url_for("project_detail", project_id=project_id)) + existing = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == user_to_add.id)).first() + if not existing: + is_mgr = (role == "manager") + is_ed = (role in ("editor", "manager")) + db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role=role, + can_edit_files=is_ed, can_post_feed=is_ed, + can_edit_any_post=is_mgr, can_manage_members=is_mgr)) + db_session.commit() + notify_user(user_to_add.id, f"Вас добавили в «{proj.title}»", + f"Роль: {role}", + link_url=url_for("project_detail", project_id=project_id), + kind="info") + return redirect(url_for("project_detail", project_id=project_id)) + + @app.route("/projects//members//permissions", methods=["POST"]) + @login_required + def project_member_permissions(project_id, user_id): + with Session(engine) as s: + proj = s.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = s.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if not perms["can_manage_members"]: return "403: Нет прав", 403 + if user_id == proj.owner_id: return "Нельзя менять права владельца", 400 + target = s.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == user_id)).first() + if target: + target.can_view_files = request.form.get("can_view_files") == "on" + target.can_edit_files = request.form.get("can_edit_files") == "on" + target.can_view_repo = request.form.get("can_view_repo") == "on" + target.can_post_feed = request.form.get("can_post_feed") == "on" + target.can_edit_own_posts = request.form.get("can_edit_own_posts") == "on" + target.can_edit_any_post = request.form.get("can_edit_any_post") == "on" + target.can_manage_members = request.form.get("can_manage_members") == "on" + if target.can_manage_members: target.role = "manager" + elif target.can_edit_files or target.can_post_feed: target.role = "editor" + else: target.role = "viewer" + s.add(target); s.commit() + return redirect(url_for("project_detail", project_id=project_id)) + + @app.route("/projects//members//remove", methods=["POST"]) + @login_required + def project_remove_member(project_id, user_id): + with Session(engine) as s: + proj = s.get(Project, project_id) + if not proj: return "Проект не найден", 404 + my_membership = s.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), my_membership) + if not perms["can_manage_members"]: return "403: Нет прав", 403 + if user_id == proj.owner_id: return "Нельзя удалить владельца", 400 + to_remove = s.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == user_id)).first() + if to_remove: s.delete(to_remove); s.commit() + return redirect(url_for("project_detail", project_id=project_id)) + + @app.route("/projects//subscribe", methods=["POST"]) + @login_required + def project_subscribe_toggle(project_id): + user_id = session.get("user_id") + with Session(engine) as s: + existing = _project_subscription(s, project_id, user_id) + if existing: s.delete(existing); s.commit() + else: + s.add(ProjectSubscription(project_id=project_id, user_id=user_id)) + s.commit() + return redirect(url_for("project_detail", project_id=project_id)) + + @app.route("/projects//posts/new", methods=["POST"]) + @login_required + def project_post_new(project_id): + user_id = session.get("user_id") + post_id = None + with Session(engine) as s: + proj = s.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = s.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == user_id)).first() + perms = project_permissions(proj, user_id, session.get("role"), membership) + if not perms["can_post_feed"]: return "403: Нет прав", 403 + title = (request.form.get("title") or "").strip() + if not title: return redirect(url_for("project_detail", project_id=project_id)) + post = ProjectPost(project_id=project_id, author_id=user_id, title=title, + body=(request.form.get("body") or "").strip() or None, + is_pinned=bool(request.form.get("is_pinned"))) + s.add(post); s.flush() + post_id = post.id; s.commit() + proj_title = proj.title + files = request.files.getlist("files") or [] + saved_ids = _save_uploaded_files(files, user_id, project_id=project_id, + upload_folder=app.config['UPLOAD_FOLDER']) + if saved_ids: + with Session(engine) as s2: + for fid in saved_ids: + s2.add(ProjectPostAttachment(post_id=post_id, file_id=fid)) + s2.commit() + with Session(engine) as s3: + sub_ids = _project_subscriber_ids(s3, project_id) + recipients = [uid for uid in sub_ids if uid != user_id] + if recipients: + notify_users(recipients, f"Новый пост в «{proj_title}»", + request.form.get("title", ""), + link_url=url_for("project_detail", project_id=project_id), kind="info") + return redirect(url_for("project_detail", project_id=project_id)) + + @app.route("/projects//posts//edit", methods=["POST"]) + @login_required + def project_post_edit(project_id, post_id): + user_id = session.get("user_id") + with Session(engine) as s: + proj = s.get(Project, project_id) + post = s.get(ProjectPost, post_id) + if not proj or not post or post.project_id != project_id: return "Не найдено", 404 + membership = s.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == user_id)).first() + perms = project_permissions(proj, user_id, session.get("role"), membership) + can_edit = perms["can_edit_any_post"] or (perms["can_edit_own_posts"] and post.author_id == user_id) + if not can_edit: return "403: Нет прав", 403 + post.title = (request.form.get("title") or post.title).strip() + post.body = (request.form.get("body") or "").strip() or None + post.is_pinned = bool(request.form.get("is_pinned")) + post.updated_at = datetime.now(timezone.utc) + s.add(post); s.commit() + return redirect(url_for("project_detail", project_id=project_id)) + + @app.route("/projects//posts//delete", methods=["POST"]) + @login_required + def project_post_delete(project_id, post_id): + user_id = session.get("user_id") + with Session(engine) as s: + proj = s.get(Project, project_id) + post = s.get(ProjectPost, post_id) + if not proj or not post or post.project_id != project_id: return "Не найдено", 404 + membership = s.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == user_id)).first() + perms = project_permissions(proj, user_id, session.get("role"), membership) + can_delete = (perms["is_admin"] or perms["is_owner"] or perms["can_edit_any_post"] + or (perms["can_edit_own_posts"] and post.author_id == user_id)) + if not can_delete: return "403: Нет прав", 403 + for att in s.exec(select(ProjectPostAttachment) + .where(ProjectPostAttachment.post_id == post_id)).all(): + s.delete(att) + s.delete(post); s.commit() + return redirect(url_for("project_detail", project_id=project_id)) + + @app.route("/projects//delete", methods=["POST"]) + @login_required + def project_delete(project_id): + with Session(engine) as s: + proj = s.get(Project, project_id) + if not proj: return redirect(url_for("projects_list")) + if not (session.get("role") == "admin" or proj.owner_id == session.get("user_id")): + return "403: Только владелец или админ", 403 + try: + for f in s.exec(select(PortalFile).where(PortalFile.project_id == project_id)).all(): + f.project_id = None; s.add(f) + s.flush() + for t in s.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all(): + s.delete(t) + s.flush() + for p in s.exec(select(ProjectPost).where(ProjectPost.project_id == project_id)).all(): + for a in s.exec(select(ProjectPostAttachment) + .where(ProjectPostAttachment.post_id == p.id)).all(): + s.delete(a) + s.delete(p) + for sub in s.exec(select(ProjectSubscription) + .where(ProjectSubscription.project_id == project_id)).all(): + s.delete(sub) + for m in s.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id)).all(): + s.delete(m) + s.flush() + s.delete(proj); s.commit() + except Exception as e: + s.rollback() + print(f"[project_delete] {e}", flush=True) + return f"Ошибка: {e}", 500 + return redirect(url_for("projects_list")) + + @app.route("/projects//files/upload", methods=["POST"]) + @login_required + def project_file_upload(project_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if not perms["can_upload"]: return "403: Нет прав", 403 + try: + file = request.files.get('file') + except Exception: + return "Ошибка разбора", 400 + if not file or file.filename == '': + return redirect(url_for("project_detail", project_id=project_id)) + saved = _save_uploaded_files([file], session.get("user_id"), + project_id=project_id, + upload_folder=app.config['UPLOAD_FOLDER']) + if not saved: return "Ошибка сохранения", 500 + return redirect(url_for("project_detail", project_id=project_id)) + + # ─── КАНБАН ─────────────────────────────────────────────────────── + + @app.route("/projects//board") + def project_board(project_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = None + if user_id: + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == user_id)).first() + perms = project_permissions(proj, user_id, user_role, membership) + if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: + return "403", 403 + if proj.visibility == "internal" and not user_id: return redirect(url_for("login")) + members = db_session.exec( + select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) + .where(ProjectMember.project_id == project_id).order_by(User.username)).all() + tasks = db_session.exec( + select(ProjectTask).where(ProjectTask.project_id == project_id) + .order_by(ProjectTask.position, ProjectTask.id)).all() + user_ids = set() + for t in tasks: + if t.assignee_id: user_ids.add(t.assignee_id) + if t.created_by: user_ids.add(t.created_by) + users_map = {} + if user_ids: + for u in db_session.exec(select(User).where(User.id.in_(user_ids))).all(): + users_map[u.id] = u + columns = {s: [] for s in TASK_STATUSES} + for t in tasks: + if t.status in columns: + columns[t.status].append({ + "task": t, + "assignee": users_map.get(t.assignee_id) if t.assignee_id else None, + "author": users_map.get(t.created_by)}) + task_counts = {s: len(columns[s]) for s in TASK_STATUSES} + return render_template("project_board.html", project=proj, members=members, + columns=columns, perms=perms, user_id=user_id, role=user_role, + active_tab="board", task_counts=task_counts, + TASK_STATUSES=TASK_STATUSES, + TASK_STATUS_LABELS=TASK_STATUS_LABELS, + TASK_PRIORITIES=TASK_PRIORITIES) + + @app.route("/projects//tasks/new", methods=["POST"]) + @login_required + def task_new(project_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if not perms["can_edit"]: return "403", 403 + title = (request.form.get("title") or "").strip() + if not title: return redirect(url_for("project_board", project_id=project_id)) + status = request.form.get("status") or "backlog" + if status not in TASK_STATUSES: status = "backlog" + priority = request.form.get("priority") or "medium" + if priority not in TASK_PRIORITIES: priority = "medium" + aid = request.form.get("assignee_id") + assignee_id = int(aid) if aid and aid.isdigit() else None + max_pos = db_session.exec(select(ProjectTask) + .where(ProjectTask.project_id == project_id) + .where(ProjectTask.status == status)).all() + position = max([t.position for t in max_pos], default=-1) + 1 + task = ProjectTask(project_id=project_id, title=title, + description=(request.form.get("description") or "").strip() or None, + status=status, priority=priority, assignee_id=assignee_id, + created_by=session.get("user_id"), + due_date=(request.form.get("due_date") or "").strip() or None, + position=position) + db_session.add(task); db_session.commit() + if assignee_id and assignee_id != session.get("user_id"): + notify_user(assignee_id, f"Задача: {title}", f"Проект: {proj.title}", + link_url=url_for("project_board", project_id=project_id), + kind="info") + return redirect(url_for("project_board", project_id=project_id)) + + @app.route("/projects//tasks//edit", methods=["GET", "POST"]) + @login_required + def task_edit(project_id, task_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if not perms["can_edit"]: return "403", 403 + task = db_session.get(ProjectTask, task_id) + if not task or task.project_id != project_id: return "Не найдено", 404 + if request.method == "POST": + old_aid = task.assignee_id + task.title = (request.form.get("title") or task.title).strip() + task.description = (request.form.get("description") or "").strip() or None + new_status = request.form.get("status") or task.status + if new_status in TASK_STATUSES: task.status = new_status + new_priority = request.form.get("priority") or task.priority + if new_priority in TASK_PRIORITIES: task.priority = new_priority + aid = request.form.get("assignee_id") + new_aid = int(aid) if aid and aid.isdigit() else None + task.assignee_id = new_aid + task.due_date = (request.form.get("due_date") or "").strip() or None + task.updated_at = datetime.now(timezone.utc) + db_session.add(task); db_session.commit() + if new_aid and new_aid != old_aid and new_aid != session.get("user_id"): + notify_user(new_aid, f"Задача: {task.title}", f"Проект: {proj.title}", + link_url=url_for("project_board", project_id=project_id), + kind="info") + return redirect(url_for("project_board", project_id=project_id)) + members = db_session.exec( + select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) + .where(ProjectMember.project_id == project_id).order_by(User.username)).all() + return render_template("task_edit.html", project=proj, task=task, members=members, + TASK_STATUSES=TASK_STATUSES, + TASK_STATUS_LABELS=TASK_STATUS_LABELS, + TASK_PRIORITIES=TASK_PRIORITIES) + + @app.route("/projects//tasks//delete", methods=["POST"]) + @login_required + def task_delete(project_id, task_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if not perms["can_manage_members"] and not perms["is_admin"] and not perms["is_owner"]: + return "403", 403 + task = db_session.get(ProjectTask, task_id) + if task and task.project_id == project_id: + db_session.delete(task); db_session.commit() + return redirect(url_for("project_board", project_id=project_id)) + + @app.route("/projects//tasks//move", methods=["POST"]) + @login_required + def task_move(project_id, task_id): + data = request.get_json(silent=True) or {} + new_status = data.get("status") + new_position = data.get("position", 0) + if new_status not in TASK_STATUSES: + return jsonify({"error": "invalid status"}), 400 + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return jsonify({"error": "not found"}), 404 + membership = db_session.exec(select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .where(ProjectMember.user_id == session.get("user_id"))).first() + perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) + if not perms["can_edit"]: return jsonify({"error": "no permission"}), 403 + task = db_session.get(ProjectTask, task_id) + if not task or task.project_id != project_id: + return jsonify({"error": "not found"}), 404 + old_status = task.status + siblings = db_session.exec( + select(ProjectTask).where(ProjectTask.project_id == project_id) + .where(ProjectTask.status == new_status) + .where(ProjectTask.id != task_id) + .order_by(ProjectTask.position, ProjectTask.id)).all() + siblings.insert(max(0, min(int(new_position), len(siblings))), task) + for i, t in enumerate(siblings): + t.position = i + if t.id == task_id: + t.status = new_status + t.updated_at = datetime.now(timezone.utc) + db_session.add(t) + db_session.flush() + if old_status != new_status: + olds = db_session.exec( + select(ProjectTask).where(ProjectTask.project_id == project_id) + .where(ProjectTask.status == old_status) + .order_by(ProjectTask.position, ProjectTask.id)).all() + for i, t in enumerate(olds): + t.position = i; db_session.add(t) + db_session.commit() + return jsonify({"ok": True}) diff --git a/app/modules/schedule_routes.py b/app/modules/schedule_routes.py new file mode 100644 index 0000000..fe38d78 --- /dev/null +++ b/app/modules/schedule_routes.py @@ -0,0 +1,208 @@ +"""Роуты расписания: публичная страница, экспорт ICS, ассистент, админка.""" +import pytz +from datetime import datetime, timezone, timedelta +from flask import render_template, request, redirect, url_for, session, jsonify, make_response +from icalendar import Calendar, Event as ICalEvent +from sqlmodel import Session, select +from ..db import engine +from ..models import Event, GlobalStatus, User +from ..helpers import ( + to_msk, login_required, require_role, + parse_datetime, status_label, event_summary_text, + STATUS_LABELS, send_email, notify_subscribers, +) +from .. import assistant, llm_assistant + + +def register(app): + + @app.route("/schedule") + def public_schedule(): + with Session(engine) as db_session: + current_status = db_session.exec(select(GlobalStatus)).first() + now = datetime.now(timezone.utc) + events = db_session.exec( + select(Event).where(Event.is_public == True) + .where(Event.start_time >= now - timedelta(days=180)) + .where(Event.start_time <= now + timedelta(days=365)) + .order_by(Event.start_time) + ).all() + from ..helpers import get_user_subscription + my_subscription = None + current_user = None + if session.get("user_id"): + current_user = db_session.get(User, session["user_id"]) + if current_user: + my_subscription = get_user_subscription(db_session, current_user) + events_json = [{"id": e.id, "title": e.title, "type": e.event_type, + "start": to_msk(e.start_time).isoformat(), + "end": to_msk(e.end_time).isoformat(), + "location": e.location, "description": e.description} + for e in events] + return render_template("schedule.html", status=current_status, + events_json=events_json, + my_subscription=my_subscription, + current_user=current_user, + msg=request.args.get("msg"), + error=request.args.get("error")) + + @app.route("/schedule/export.ics") + def export_ics(): + with Session(engine) as db_session: + events = db_session.exec(select(Event).where(Event.is_public == True)).all() + cal = Calendar() + cal.add('prodid', '-//Morozovka Portal//mxm.dk//') + cal.add('version', '2.0') + for ev in events: + ievent = ICalEvent() + ievent.add('summary', ev.title) + ievent.add('dtstart', ev.start_time.astimezone(pytz.utc)) + ievent.add('dtend', ev.end_time.astimezone(pytz.utc)) + if ev.location: ievent.add('location', ev.location) + if ev.description: ievent.add('description', ev.description) + cal.add_component(ievent) + response = make_response(cal.to_ical()) + response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics" + response.headers["Content-Type"] = "text/calendar; charset=utf-8" + return response + + @app.route("/api/assistant", methods=["POST"]) + def api_assistant(): + data = request.get_json() or {} + message = (data.get("message") or "").strip() + if not message: return jsonify({"error": "Нет сообщения"}), 400 + history = data.get("history") or [] + if llm_assistant.is_enabled(): + try: + reply = llm_assistant.chat(message, history) + if reply: return jsonify({"reply": reply, "source": "llm"}) + except Exception as e: + print(f"[llm] fallback: {e}", flush=True) + return jsonify({"reply": assistant.get_response(message), "source": "regex"}) + + @app.route("/admin/schedule") + @require_role(["admin", "professor"]) + def admin_schedule(): + with Session(engine) as db_session: + current_status = db_session.exec(select(GlobalStatus)).first() + events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).all() + return render_template("admin_schedule.html", status=current_status, events=events) + + @app.route("/admin/schedule/status", methods=["POST"]) + @require_role(["admin", "professor"]) + def update_status(): + status_val = request.form.get("status") + delay = int(request.form.get("delay_minutes") or 0) + message = request.form.get("message") + with Session(engine) as db_session: + cs = db_session.exec(select(GlobalStatus)).first() + if cs: + old_label = status_label(cs.status, cs.delay_minutes) + cs.status = status_val + cs.delay_minutes = delay + cs.message = message + cs.updated_at = datetime.now(timezone.utc) + db_session.add(cs); db_session.commit() + new_label = status_label(status_val, delay) + body = f"Новый статус: {new_label}\nБыло: {old_label}\n" + if message: body += f"Сообщение: {message}\n" + body += f"Обновлено: {to_msk(cs.updated_at).strftime('%d.%m.%Y %H:%M')}" + notify_subscribers(f"[Расписание] {new_label}", body, kind="status") + return redirect(url_for("admin_schedule")) + + @app.route("/admin/schedule/event/add", methods=["POST"]) + @require_role(["admin", "professor"]) + def add_event(): + with Session(engine) as db_session: + ev = Event(title=request.form.get("title"), + event_type=request.form.get("event_type"), + start_time=parse_datetime(request.form.get("start_time")), + end_time=parse_datetime(request.form.get("end_time")), + location=request.form.get("location"), + description=request.form.get("description"), + is_public=request.form.get("is_public") == "on") + db_session.add(ev); db_session.commit(); db_session.refresh(ev) + if ev.is_public: + notify_subscribers(f"[Расписание] Новое: {ev.title}", + "Добавлено событие.\n\n" + event_summary_text(ev), + kind="event") + return redirect(url_for("admin_schedule")) + + @app.route("/admin/schedule/event//edit", methods=["GET", "POST"]) + @require_role(["admin", "professor"]) + def edit_event(event_id): + with Session(engine) as db_session: + event = db_session.get(Event, event_id) + if not event: return "Не найдено", 404 + if request.method == "POST": + event.title = request.form.get("title") + event.event_type = request.form.get("event_type") + event.start_time = parse_datetime(request.form.get("start_time")) + event.end_time = parse_datetime(request.form.get("end_time")) + event.location = request.form.get("location") + event.description = request.form.get("description") + event.is_public = request.form.get("is_public") == "on" + event.manually_edited = True + db_session.add(event); db_session.commit() + if event.is_public: + notify_subscribers(f"[Расписание] Изменено: {event.title}", + "Событие изменено.\n\n" + event_summary_text(event), + kind="event") + return redirect(url_for("admin_schedule")) + return render_template("admin_event_edit.html", event=event) + + @app.route("/admin/schedule/event//delete", methods=["POST"]) + @require_role(["admin", "professor"]) + def delete_event(event_id): + with Session(engine) as db_session: + event = db_session.get(Event, event_id) + if event: + t_snap = event.title + s_snap = event.start_time + was_public = event.is_public + db_session.delete(event); db_session.commit() + if was_public: + body = f"Событие удалено.\n\nНазвание: {t_snap}\nБыло: {to_msk(s_snap).strftime('%d.%m.%Y %H:%M')}" + notify_subscribers(f"[Расписание] Удалено: {t_snap}", body, kind="event") + return redirect(url_for("admin_schedule")) + + @app.route("/admin/schedule/import", methods=["GET", "POST"]) + @require_role(["admin", "professor"]) + def import_ics(): + if request.method == "POST": + if 'file' not in request.files: return "Файл не загружен", 400 + file = request.files['file'] + if not file or not file.filename: return "Файл не выбран", 400 + if file.filename.endswith('.ics'): + try: + cal = Calendar.from_ical(file.read()) + count = 0 + with Session(engine) as db_session: + for comp in cal.walk(): + if comp.name == "VEVENT": + summary = str(comp.get('summary', 'Без названия')) + ds = comp.get('dtstart'); de = comp.get('dtend') + if not ds or not de: continue + dtstart = ds.dt; dtend = de.dt + if not isinstance(dtstart, datetime): + dtstart = datetime.combine(dtstart, datetime.min.time()) + if not isinstance(dtend, datetime): + dtend = datetime.combine(dtend, datetime.min.time()) + if dtstart.tzinfo is None: dtstart = dtstart.replace(tzinfo=timezone.utc) + if dtend.tzinfo is None: dtend = dtend.replace(tzinfo=timezone.utc) + db_session.add(Event( + title=summary, event_type='lecture', + start_time=dtstart.astimezone(timezone.utc), + end_time=dtend.astimezone(timezone.utc), + location=str(comp.get('location', '')), + description=str(comp.get('description', '')), + is_public=True)) + count += 1 + db_session.commit() + if count > 0: + notify_subscribers(f"[Расписание] Импорт: {count}", + f"Добавлено: {count}", kind="event") + return redirect(url_for("admin_schedule")) + except Exception as e: + return f"Ошибка: {e}", 400 + return render_template("admin_schedule_import.html") diff --git a/app/modules/workshops_routes.py b/app/modules/workshops_routes.py new file mode 100644 index 0000000..a5b1eb7 --- /dev/null +++ b/app/modules/workshops_routes.py @@ -0,0 +1,486 @@ +"""Роуты мастер-классов: каталог, CRUD, сессии, материалы, лента, подписка, посещаемость, помощники.""" +from datetime import datetime, timezone +from flask import render_template, request, redirect, url_for, session +from sqlmodel import Session, select +from ..db import engine +from ..models import ( + Workshop, WorkshopAssistant, WorkshopSession, WorkshopRegistration, + WorkshopMaterial, WorkshopPost, WorkshopPostAttachment, + WorkshopSubscription, PortalFile, User, +) +from ..helpers import ( + login_required, require_role, + normalize_tags, parse_datetime, + workshop_permissions, _ws_perms_for, _ws_assistant_ids, _ws_assistants_full, + _ws_registration, _ws_session_helpers, _ws_count_registered, + _ws_subscription, _ws_subscriber_ids, _ws_assistants_full, + _save_uploaded_files, notify_user, notify_users, + WORKSHOP_CATEGORIES, WORKSHOP_CATEGORY_LABELS, + WORKSHOP_DIFFICULTIES, WORKSHOP_DIFFICULTY_LABELS, + WORKSHOP_STATUSES, WORKSHOP_STATUS_LABELS, + WORKSHOP_VISIBILITIES, WORKSHOP_VISIBILITY_LABELS, + RECURRENCE_PRESETS, RECURRENCE_LABELS, +) + + +def register(app): + + @app.route("/workshops") + def workshops_list(): + user_id = session.get("user_id") + user_role = session.get("role") + current_cat = (request.args.get("category") or "").strip() + current_status = (request.args.get("status") or "").strip() + with Session(engine) as db_session: + all_ws = db_session.exec(select(Workshop).order_by(Workshop.created_at.desc())).all() + visible = [] + for w in all_ws: + if w.status == "draft" and not (user_role == "admin" or w.lead_id == user_id): continue + if w.visibility == "internal" and not user_id: continue + visible.append(w) + reg_counts = {w.id: _ws_count_registered(db_session, w.id) for w in visible} + next_sessions = {} + now = datetime.now(timezone.utc) + for w in visible: + ss = _ws_session_helpers(db_session, w.id) + future = [s for s in ss if s.start_time >= now] + next_sessions[w.id] = future[0] if future else (ss[-1] if ss else None) + lead_ids = {w.lead_id for w in visible} + leads_map = {} + if lead_ids: + for u in db_session.exec(select(User).where(User.id.in_(lead_ids))).all(): + leads_map[u.id] = u + my_subs = {} + if user_id: + for sub in db_session.exec(select(WorkshopSubscription) + .where(WorkshopSubscription.user_id == user_id)).all(): + my_subs[sub.workshop_id] = True + filtered = visible + if current_cat: filtered = [w for w in filtered if w.category == current_cat] + if current_status: filtered = [w for w in filtered if w.status == current_status] + can_create = user_role == "admin" + return render_template("workshops.html", workshops=filtered, reg_counts=reg_counts, + next_sessions=next_sessions, leads_map=leads_map, + my_subs=my_subs, current_cat=current_cat, current_status=current_status, + can_create=can_create, + WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, + WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, + WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, + WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, + WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS, + RECURRENCE_LABELS=RECURRENCE_LABELS) + + @app.route("/workshops/new", methods=["GET", "POST"]) + @require_role("admin") + def workshop_new(): + if request.method == "POST": + with Session(engine) as db_session: + lead_id = session.get("user_id") + other_lead = request.form.get("lead_id") + if other_lead and other_lead.isdigit(): + lead_id = int(other_lead) + ws = Workshop( + title=request.form.get("title"), + description=request.form.get("description"), + program=request.form.get("program"), + lead_id=lead_id, + category=request.form.get("category") or "other", + difficulty=request.form.get("difficulty") or "beginner", + partner=(request.form.get("partner") or "").strip() or None, + status=request.form.get("status") or "draft", + visibility=request.form.get("visibility") or "public", + location=(request.form.get("location") or "").strip() or None, + materials_required=request.form.get("materials_required"), + outcome=request.form.get("outcome"), + has_certificate=request.form.get("has_certificate") == "on", + recurrence=request.form.get("recurrence") or None, + max_participants=int(request.form.get("max_participants")) if (request.form.get("max_participants") or "").strip().isdigit() else None, + cover_url=(request.form.get("cover_url") or "").strip() or None, + tags=normalize_tags(request.form.get("tags"))) + db_session.add(ws); db_session.flush() + ws_id = ws.id; db_session.commit() + return redirect(url_for("workshop_detail", workshop_id=ws_id)) + with Session(engine) as s: + users = s.exec(select(User).where(User.is_active == True).order_by(User.username)).all() + return render_template("workshop_form.html", workshop=None, is_edit=False, users=users, + WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, + WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, + WORKSHOP_DIFFICULTIES=WORKSHOP_DIFFICULTIES, + WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, + WORKSHOP_STATUSES=WORKSHOP_STATUSES, + WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, + WORKSHOP_VISIBILITIES=WORKSHOP_VISIBILITIES, + WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS, + RECURRENCE_PRESETS=RECURRENCE_PRESETS) + + @app.route("/workshops/") + def workshop_detail(workshop_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + ws = db_session.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + assistant_ids = _ws_assistant_ids(db_session, workshop_id) + is_assistant = user_id in assistant_ids if user_id else False + perms = workshop_permissions(ws, user_id, user_role, is_assistant) + if ws.status == "draft" and not perms["can_manage"] and user_role != "admin": + return "403: Черновик", 403 + if ws.visibility == "internal" and not user_id: + return redirect(url_for("login")) + lead = db_session.get(User, ws.lead_id) + assistants = _ws_assistants_full(db_session, workshop_id) + sessions = _ws_session_helpers(db_session, workshop_id) + registration = _ws_registration(db_session, workshop_id, user_id) + is_registered = registration is not None and registration.status in ("registered", "attended") + registered_count = _ws_count_registered(db_session, workshop_id) + is_full = ws.max_participants is not None and registered_count >= ws.max_participants + materials = db_session.exec(select(WorkshopMaterial) + .where(WorkshopMaterial.workshop_id == workshop_id) + .order_by(WorkshopMaterial.created_at.desc())).all() + visible_materials = [m for m in materials + if m.visible_to != "registered" or is_registered or perms["can_manage"]] + participants = [] + if perms["can_manage"]: + participants = db_session.exec( + select(WorkshopRegistration, User) + .join(User, WorkshopRegistration.user_id == User.id) + .where(WorkshopRegistration.workshop_id == workshop_id) + .order_by(WorkshopRegistration.registered_at)).all() + material_files_map = {} + fids = [m.file_id for m in visible_materials if m.file_id] + if fids: + for f in db_session.exec(select(PortalFile).where(PortalFile.id.in_(fids))).all(): + material_files_map[f.id] = f + my_sub = _ws_subscription(db_session, workshop_id, user_id) + posts = db_session.exec(select(WorkshopPost) + .where(WorkshopPost.workshop_id == workshop_id) + .order_by(WorkshopPost.is_pinned.desc(), + WorkshopPost.created_at.desc())).all() + post_ids = [p.id for p in posts] + atts = {} + if post_ids: + for a in db_session.exec(select(WorkshopPostAttachment) + .where(WorkshopPostAttachment.post_id.in_(post_ids))).all(): + atts.setdefault(a.post_id, []).append(a) + file_ids = [a.file_id for arr in atts.values() for a in arr] + posts_files = {} + if file_ids: + for f in db_session.exec(select(PortalFile).where(PortalFile.id.in_(file_ids))).all(): + posts_files[f.id] = f + author_ids = {p.author_id for p in posts} + authors = {} + if author_ids: + for u in db_session.exec(select(User).where(User.id.in_(author_ids))).all(): + authors[u.id] = u + return render_template("workshop_detail.html", workshop=ws, lead=lead, assistants=assistants, + sessions=sessions, perms=perms, user_id=user_id, role=user_role, + registration=registration, is_registered=is_registered, + registered_count=registered_count, is_full=is_full, + materials=visible_materials, material_files_map=material_files_map, + participants=participants, my_sub=my_sub, + posts=posts, atts=atts, posts_files=posts_files, authors=authors, + WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, + WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, + WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, + WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS, + RECURRENCE_LABELS=RECURRENCE_LABELS) + + @app.route("/workshops//edit", methods=["GET", "POST"]) + @login_required + def workshop_edit(workshop_id): + with Session(engine) as db_session: + ws = db_session.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + perms = _ws_perms_for(db_session, ws, session.get("user_id"), session.get("role")) + if not perms["can_manage"]: return "403", 403 + if request.method == "POST": + ws.title = request.form.get("title") + ws.description = request.form.get("description") + ws.program = request.form.get("program") + ws.category = request.form.get("category") or "other" + ws.difficulty = request.form.get("difficulty") or "beginner" + ws.partner = (request.form.get("partner") or "").strip() or None + ws.status = request.form.get("status") or "draft" + ws.visibility = request.form.get("visibility") or "public" + ws.location = (request.form.get("location") or "").strip() or None + ws.materials_required = request.form.get("materials_required") + ws.outcome = request.form.get("outcome") + ws.has_certificate = request.form.get("has_certificate") == "on" + ws.recurrence = request.form.get("recurrence") or None + mp = request.form.get("max_participants") + ws.max_participants = int(mp) if (mp or "").strip().isdigit() else None + ws.cover_url = (request.form.get("cover_url") or "").strip() or None + ws.tags = normalize_tags(request.form.get("tags")) + db_session.add(ws); db_session.commit() + return redirect(url_for("workshop_detail", workshop_id=ws.id)) + users = db_session.exec(select(User).where(User.is_active == True) + .order_by(User.username)).all() + return render_template("workshop_form.html", workshop=ws, is_edit=True, users=users, + WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, + WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, + WORKSHOP_DIFFICULTIES=WORKSHOP_DIFFICULTIES, + WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, + WORKSHOP_STATUSES=WORKSHOP_STATUSES, + WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, + WORKSHOP_VISIBILITIES=WORKSHOP_VISIBILITIES, + WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS, + RECURRENCE_PRESETS=RECURRENCE_PRESETS) + + @app.route("/workshops//delete", methods=["POST"]) + @login_required + def workshop_delete(workshop_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return redirect(url_for("workshops_list")) + perms = _ws_perms_for(s, ws, session.get("user_id"), session.get("role")) + if not perms["can_delete_workshop"]: + return "403: Удалить может только владелец или admin", 403 + for a in s.exec(select(WorkshopAssistant) + .where(WorkshopAssistant.workshop_id == workshop_id)).all(): + s.delete(a) + s.delete(ws); s.commit() + return redirect(url_for("workshops_list")) + + @app.route("/workshops//assistants/add", methods=["POST"]) + @login_required + def workshop_assistant_add(workshop_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + if not (session.get("role") == "admin" or session.get("user_id") == ws.lead_id): + return "403: Только ведущий или admin", 403 + username = (request.form.get("username") or "").strip() + user_to_add = s.exec(select(User).where(User.username == username)).first() + if not user_to_add: return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + if user_to_add.id == ws.lead_id: return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + existing = s.exec(select(WorkshopAssistant) + .where(WorkshopAssistant.workshop_id == workshop_id) + .where(WorkshopAssistant.user_id == user_to_add.id)).first() + if not existing: + s.add(WorkshopAssistant(workshop_id=workshop_id, user_id=user_to_add.id)) + s.commit() + notify_user(user_to_add.id, f"Вы помощник на «{ws.title}»", + "Теперь вы можете вести ленту, управлять сессиями и материалами.", + link_url=url_for("workshop_detail", workshop_id=workshop_id), + kind="success") + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//assistants//remove", methods=["POST"]) + @login_required + def workshop_assistant_remove(workshop_id, uid): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + if not (session.get("role") == "admin" or session.get("user_id") == ws.lead_id): + return "403", 403 + a = s.exec(select(WorkshopAssistant) + .where(WorkshopAssistant.workshop_id == workshop_id) + .where(WorkshopAssistant.user_id == uid)).first() + if a: s.delete(a); s.commit() + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//sessions/add", methods=["POST"]) + @login_required + def workshop_session_add(workshop_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + perms = _ws_perms_for(s, ws, session.get("user_id"), session.get("role")) + if not perms["can_manage"]: return "403", 403 + start = parse_datetime(request.form.get("start_time")) + end = parse_datetime(request.form.get("end_time")) + if start and end: + s.add(WorkshopSession(workshop_id=workshop_id, start_time=start, end_time=end, + location_override=(request.form.get("location_override") or "").strip() or None, + note=(request.form.get("note") or "").strip() or None)) + s.commit() + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//sessions//delete", methods=["POST"]) + @login_required + def workshop_session_delete(workshop_id, session_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return redirect(url_for("workshops_list")) + perms = _ws_perms_for(s, ws, session.get("user_id"), session.get("role")) + if not perms["can_manage"]: return "403", 403 + ses = s.get(WorkshopSession, session_id) + if ses and ses.workshop_id == workshop_id: + s.delete(ses); s.commit() + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//materials/add", methods=["POST"]) + @login_required + def workshop_material_add(workshop_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + perms = _ws_perms_for(s, ws, session.get("user_id"), session.get("role")) + if not perms["can_manage"]: return "403", 403 + title = (request.form.get("title") or "").strip() + if title: + s.add(WorkshopMaterial(workshop_id=workshop_id, title=title, + description=(request.form.get("description") or "").strip() or None, + external_url=(request.form.get("external_url") or "").strip() or None, + visible_to=request.form.get("visible_to") or "registered")) + s.commit() + sub_ids = _ws_subscriber_ids(s, workshop_id) + recipients = [uid for uid in sub_ids if uid != session.get("user_id")] + if recipients: + notify_users(recipients, f"Материал в «{ws.title}»", title, + link_url=url_for("workshop_detail", workshop_id=workshop_id), + kind="info") + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//materials//delete", methods=["POST"]) + @login_required + def workshop_material_delete(workshop_id, material_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return redirect(url_for("workshops_list")) + perms = _ws_perms_for(s, ws, session.get("user_id"), session.get("role")) + if not perms["can_manage"]: return "403", 403 + m = s.get(WorkshopMaterial, material_id) + if m and m.workshop_id == workshop_id: + s.delete(m); s.commit() + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//subscribe", methods=["POST"]) + @login_required + def workshop_subscribe_toggle(workshop_id): + user_id = session.get("user_id") + with Session(engine) as s: + existing = _ws_subscription(s, workshop_id, user_id) + if existing: s.delete(existing); s.commit() + else: + s.add(WorkshopSubscription(workshop_id=workshop_id, user_id=user_id)) + s.commit() + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//posts/new", methods=["POST"]) + @login_required + def workshop_post_new(workshop_id): + user_id = session.get("user_id") + post_id = None + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + perms = _ws_perms_for(s, ws, user_id, session.get("role")) + if not perms["can_post"]: return "403", 403 + title = (request.form.get("title") or "").strip() + if not title: return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + post = WorkshopPost(workshop_id=workshop_id, author_id=user_id, title=title, + body=(request.form.get("body") or "").strip() or None, + is_pinned=bool(request.form.get("is_pinned"))) + s.add(post); s.flush() + post_id = post.id; s.commit() + ws_title = ws.title + sub_ids = _ws_subscriber_ids(s, workshop_id) + files = request.files.getlist("files") or [] + saved_ids = _save_uploaded_files(files, user_id, project_id=None, + upload_folder=app.config['UPLOAD_FOLDER']) + if saved_ids: + with Session(engine) as s2: + for fid in saved_ids: + s2.add(WorkshopPostAttachment(post_id=post_id, file_id=fid)) + s2.commit() + recipients = [uid for uid in sub_ids if uid != user_id] + if recipients: + notify_users(recipients, f"Новость в «{ws_title}»", title, + link_url=url_for("workshop_detail", workshop_id=workshop_id), + kind="info") + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//posts//edit", methods=["POST"]) + @login_required + def workshop_post_edit(workshop_id, post_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + post = s.get(WorkshopPost, post_id) + if not ws or not post or post.workshop_id != workshop_id: return "Не найдено", 404 + perms = _ws_perms_for(s, ws, session.get("user_id"), session.get("role")) + if not perms["can_manage"]: return "403", 403 + post.title = (request.form.get("title") or post.title).strip() + post.body = (request.form.get("body") or "").strip() or None + post.is_pinned = bool(request.form.get("is_pinned")) + post.updated_at = datetime.now(timezone.utc) + s.add(post); s.commit() + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//posts//delete", methods=["POST"]) + @login_required + def workshop_post_delete(workshop_id, post_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + post = s.get(WorkshopPost, post_id) + if not ws or not post or post.workshop_id != workshop_id: return "Не найдено", 404 + perms = _ws_perms_for(s, ws, session.get("user_id"), session.get("role")) + if not perms["can_manage"]: return "403", 403 + for att in s.exec(select(WorkshopPostAttachment) + .where(WorkshopPostAttachment.post_id == post_id)).all(): + s.delete(att) + s.delete(post); s.commit() + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//register", methods=["POST"]) + @login_required + def workshop_register(workshop_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + if ws.status != "published": return "Регистрация только для опубликованных", 400 + existing = _ws_registration(s, workshop_id, session.get("user_id")) + if existing and existing.status in ("registered", "attended"): + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + if ws.max_participants is not None: + if _ws_count_registered(s, workshop_id) >= ws.max_participants: + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + if existing: + existing.status = "registered" + existing.registered_at = datetime.now(timezone.utc) + s.add(existing) + else: + s.add(WorkshopRegistration(workshop_id=workshop_id, + user_id=session.get("user_id"), + status="registered")) + s.commit() + if ws.lead_id != session.get("user_id"): + notify_user(ws.lead_id, f"Запись на «{ws.title}»", "Новый участник", + link_url=url_for("workshop_detail", workshop_id=workshop_id), + kind="success") + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + @app.route("/workshops//unregister", methods=["POST"]) + @login_required + def workshop_unregister(workshop_id): + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + existing = _ws_registration(s, workshop_id, session.get("user_id")) + if existing: + existing.status = "cancelled" + s.add(existing); s.commit() + if ws and ws.lead_id != session.get("user_id"): + notify_user(ws.lead_id, f"Отмена записи на «{ws.title}»", + "Пользователь отменил запись", + link_url=url_for("workshop_detail", workshop_id=workshop_id), + kind="warning") + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) + + # ─── ПОСЕЩАЕМОСТЬ ───────────────────────────────────────────────── + + @app.route("/workshops//attendance/", methods=["POST"]) + @login_required + def workshop_attendance(workshop_id, reg_id): + new_status = request.form.get("status") + if new_status not in ("attended", "no_show", "registered"): + return "Неверный статус", 400 + with Session(engine) as s: + ws = s.get(Workshop, workshop_id) + if not ws: return "Не найден", 404 + perms = _ws_perms_for(s, ws, session.get("user_id"), session.get("role")) + if not perms["can_manage"]: return "403", 403 + reg = s.get(WorkshopRegistration, reg_id) + if not reg or reg.workshop_id != workshop_id: return "Не найдено", 404 + reg.status = new_status + s.add(reg); s.commit() + return redirect(url_for("workshop_detail", workshop_id=workshop_id)) diff --git a/app/static/main.css b/app/static/main.css new file mode 100644 index 0000000..7410f03 --- /dev/null +++ b/app/static/main.css @@ -0,0 +1,1160 @@ +/* ══════════════════════════════════════════════════════════════════════ + Morozovka Platform — общие стили + Все шаблоны подключают + ══════════════════════════════════════════════════════════════════════ */ + +* { box-sizing: border-box; } + +:root { + --bg: #0d1117; + --bg-elev: #161b22; + --bg-elev-2: #21262d; + --bg-elev-3: #1c2128; + --border: #30363d; + --border-subtle: #21262d; + --text: #e6edf3; + --text-muted: #8b949e; + --text-dim: #6e7681; + --accent: #58a6ff; + --accent-hover: #388bfd; + --green: #3fb950; + --green-bg: #238636; + --green-bg-hover: #2ea043; + --yellow: #d29922; + --red: #f85149; + --red-bg: #da3633; + --red-bg-hover: #f85149; + --purple: #a371f7; + --blue: #1f6feb; + --radius: 6px; + --radius-lg: 12px; +} + +html, body { margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +body.no-flex { display: block; } + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ══ TOP NAV ═══════════════════════════════════════════════════════════ */ +.topnav { + background: var(--bg-elev); + border-bottom: 1px solid var(--border); + padding: 12px 32px; + display: flex; + align-items: center; + gap: 24px; + position: sticky; + top: 0; + z-index: 100; +} +.topnav-logo { + display: flex; + align-items: center; + gap: 8px; + color: var(--text); + font-weight: 600; + font-size: 15px; +} +.topnav-logo:hover { color: var(--text); text-decoration: none; } +.topnav-logo svg { color: var(--accent); } +.topnav-links { display: flex; gap: 20px; flex: 1; } +.topnav-links a { + color: #c9d1d9; + font-weight: 500; + font-size: 14px; +} +.topnav-links a:hover { color: var(--accent); text-decoration: none; } +.topnav-links a.active { color: var(--accent); } +.topnav-actions { + display: flex; + gap: 12px; + align-items: center; + padding-right: 80px; /* место под колокольчик */ +} + +/* ══ CONTAINER / LAYOUT ════════════════════════════════════════════════ */ +.container { + max-width: 1100px; + margin: 0 auto; + padding: 24px 32px; + flex: 1; + width: 100%; +} +.container-narrow { max-width: 800px; } +.container-wide { max-width: 1400px; } +.layout { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; +} +.breadcrumbs { + color: var(--text-muted); + font-size: 13px; + margin-bottom: 16px; +} +.breadcrumbs a { color: var(--text-muted); } +.breadcrumbs a:hover { color: var(--accent); } + +/* ══ BUTTONS ═══════════════════════════════════════════════════════════ */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 16px; + border-radius: var(--radius); + font-size: 14px; + font-weight: 500; + line-height: 20px; + border: 1px solid rgba(240,246,252,0.1); + cursor: pointer; + text-decoration: none; + white-space: nowrap; + transition: all .15s; + font-family: inherit; + color: var(--text); + background: var(--bg-elev-2); +} +.btn:hover { text-decoration: none; } +.btn-primary { background: var(--green-bg); color: #fff; } +.btn-primary:hover { background: var(--green-bg-hover); color: #fff; } +.btn-danger { background: var(--red-bg); color: #fff; } +.btn-danger:hover { background: var(--red-bg-hover); } +.btn-default { background: var(--bg-elev-2); color: #c9d1d9; } +.btn-default:hover { background: var(--border); } +.btn-outline { background: transparent; color: #c9d1d9; border-color: var(--border); } +.btn-outline:hover { background: var(--bg-elev-2); color: var(--text); border-color: var(--text-muted); } +.btn-sm { padding: 2px 8px; font-size: 12px; line-height: 18px; } +.btn-lg { padding: 10px 24px; font-size: 15px; } +.btn-icon { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--text); + width: 36px; + height: 36px; + border-radius: var(--radius); + cursor: pointer; + font-size: 1.1rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} +.btn-icon:hover { background: var(--border); } + +/* ══ FORMS ═════════════════════════════════════════════════════════════ */ +.form-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} +.form-group { margin-bottom: 16px; display: flex; flex-direction: column; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +label { color: var(--text); font-size: 14px; font-weight: 600; margin-bottom: 6px; } +label .req { color: var(--red); } +input, select, textarea { + padding: 5px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 14px; + font-family: inherit; + line-height: 20px; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(88,166,255,0.15); +} +textarea { min-height: 100px; resize: vertical; } +.hint { color: var(--text-muted); font-size: 12px; margin-top: 4px; } +.checkbox-row, .checkbox-line { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + color: #c9d1d9; + font-size: 13px; + cursor: pointer; +} +.checkbox-row input, .checkbox-line input { width: 15px; height: 15px; } +.checkbox-row label, .checkbox-line label { margin: 0; font-weight: 500; cursor: pointer; } +.form-actions { + display: flex; + gap: 8px; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +/* ══ CARDS / SECTIONS ══════════════════════════════════════════════════ */ +.section { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; +} +.section h2 { + font-size: 16px; + font-weight: 600; + margin: 0 0 12px; + color: var(--text); + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} +.section h2 .counter { color: var(--text-muted); font-size: 13px; font-weight: normal; } +.section-content { color: #c9d1d9; white-space: pre-wrap; line-height: 1.6; } +.card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +/* ══ PAGE HEADER ═══════════════════════════════════════════════════════ */ +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + gap: 16px; + flex-wrap: wrap; +} +.page-title { font-size: 24px; font-weight: 600; margin: 0 0 4px; color: var(--text); } +.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 0; } + +/* ══ BADGES ════════════════════════════════════════════════════════════ */ +.badge { + display: inline-block; + padding: 0 7px; + border-radius: 2em; + font-size: 12px; + font-weight: 500; + line-height: 18px; + border: 1px solid; + white-space: nowrap; +} +.badge-cat-soldering { background: rgba(88,166,255,0.15); color: var(--accent); border-color: rgba(88,166,255,0.4); } +.badge-cat-embroidery { background: rgba(163,113,247,0.15); color: var(--purple); border-color: rgba(163,113,247,0.4); } +.badge-cat-embedded { background: rgba(63,185,80,0.15); color: var(--green); border-color: rgba(63,185,80,0.4); } +.badge-cat-linux { background: rgba(210,153,34,0.15); color: var(--yellow); border-color: rgba(210,153,34,0.4); } +.badge-cat-other { background: rgba(139,148,158,0.15); color: var(--text-muted); border-color: rgba(139,148,158,0.4); } +.badge-status-draft { background: rgba(139,148,158,0.15); color: var(--text-muted); border-color: rgba(139,148,158,0.4); } +.badge-status-published { background: rgba(63,185,80,0.15); color: var(--green); border-color: rgba(63,185,80,0.4); } +.badge-status-archived { background: rgba(210,153,34,0.15); color: var(--yellow); border-color: rgba(210,153,34,0.4); } +.badge-vis-public { background: rgba(63,185,80,0.15); color: var(--green); border-color: rgba(63,185,80,0.4); } +.badge-vis-internal { background: rgba(210,153,34,0.15); color: var(--yellow); border-color: rgba(210,153,34,0.4); } +.badge-vis-private { background: rgba(248,81,73,0.15); color: var(--red); border-color: rgba(248,81,73,0.4); } +.badge-difficulty-beginner { background: rgba(63,185,80,0.15); color: var(--green); border-color: rgba(63,185,80,0.4); } +.badge-difficulty-intermediate { background: rgba(210,153,34,0.15); color: var(--yellow); border-color: rgba(210,153,34,0.4); } +.badge-difficulty-advanced { background: rgba(248,81,73,0.15); color: var(--red); border-color: rgba(248,81,73,0.4); } +.badge-recurrence { background: rgba(163,113,247,0.15); color: var(--purple); border-color: rgba(163,113,247,0.4); } +.badge-status-in_progress { background: rgba(31,111,235,0.15); color: var(--blue); border-color: rgba(31,111,235,0.4); } +.badge-status-idea { background: rgba(139,148,158,0.15); color: var(--text-muted); border-color: rgba(139,148,158,0.4); } +.badge-status-completed { background: rgba(35,134,54,0.15); color: var(--green); border-color: rgba(35,134,54,0.4); } +.badge-status-frozen { background: rgba(218,54,51,0.15); color: var(--red); border-color: rgba(218,54,51,0.4); } + +/* ══ TAGS / FILTERS ════════════════════════════════════════════════════ */ +.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; } +.tag { + font-size: 12px; + background: rgba(88,166,255,0.1); + color: var(--accent); + padding: 2px 10px; + border-radius: 12px; + border: 1px solid rgba(88,166,255,0.3); +} +.filters { + display: flex; + gap: 8px; + flex-wrap: wrap; + padding: 16px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 20px; + align-items: center; +} +.filter-label { + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + margin-right: 6px; + align-self: center; +} +.filter-chip { + display: inline-flex; + align-items: center; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + border-radius: 2em; + border: 1px solid var(--border); + background: var(--bg-elev-2); + color: #c9d1d9; + cursor: pointer; + transition: all .15s; +} +.filter-chip:hover { background: var(--border); text-decoration: none; color: var(--text); } +.filter-chip.active { background: var(--blue); border-color: var(--blue); color: #fff; } +.filter-chip.active:hover { background: var(--accent-hover); color: #fff; } +.filter-chip-clear { + border-color: var(--red); + color: var(--red); + background: transparent; +} +.filter-chip-clear:hover { background: rgba(248,81,73,0.1); color: var(--red); } + +/* ══ SEARCH ════════════════════════════════════════════════════════════ */ +.search-form { display: flex; gap: 8px; align-items: center; } +.search-input { + padding: 6px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + min-width: 200px; +} +.search-input:focus { outline: none; border-color: var(--accent); } +.search-btn { + padding: 6px 12px; + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-size: 13px; +} +.search-btn:hover { background: var(--border); } + +/* ══ TABS ══════════════════════════════════════════════════════════════ */ +.tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; + flex-wrap: wrap; +} +.tab { + padding: 8px 16px; + color: var(--text-muted); + text-decoration: none; + border-bottom: 2px solid transparent; + font-weight: 600; + font-size: 14px; +} +.tab:hover { color: var(--text); text-decoration: none; } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab .badge-count { + background: var(--border); + color: var(--text); + padding: 0 6px; + border-radius: 10px; + font-size: 11px; + margin-left: 4px; + font-weight: 500; +} + +/* ══ POST CARD (общая лента) ═══════════════════════════════════════════ */ +.post-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + margin-bottom: 12px; + position: relative; +} +.post-card:last-child { margin-bottom: 0; } +.post-card.pinned { border-color: var(--yellow); background: rgba(210,153,34,0.05); } +.post-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + margin-bottom: 6px; +} +.post-title { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin: 0 0 4px; + line-height: 1.4; +} +.post-title .pin-mark { color: var(--yellow); margin-right: 4px; font-size: 12px; } +.post-title a { color: inherit; } +.post-title a:hover { color: var(--accent); text-decoration: none; } +.post-meta { color: var(--text-muted); font-size: 12px; } +.post-meta a { color: var(--accent); } +.post-body { + color: #c9d1d9; + line-height: 1.6; + white-space: pre-wrap; + margin: 8px 0; +} +.post-attachments { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-subtle); +} +.post-attachment { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + background: var(--bg-elev); + border-radius: 4px; + font-size: 12px; + color: #c9d1d9; +} +.post-attachment a { color: var(--accent); } +.post-image-wrap { margin: 6px 0; } +.post-image-wrap img { + max-width: 100%; + max-height: 400px; + border-radius: var(--radius); + border: 1px solid var(--border); + display: block; + cursor: zoom-in; +} +.post-image-name { color: var(--text-dim); font-size: 11px; margin-top: 3px; } +.post-actions { + display: flex; + gap: 4px; + margin-left: auto; + opacity: 0; + transition: opacity .15s; +} +.post-card:hover .post-actions { opacity: 1; } +.post-actions form { display: inline; } +.post-actions button { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 2px 6px; + font-size: 12px; + border-radius: 3px; +} +.post-actions button:hover { background: var(--border); color: var(--text); } +.post-actions .del:hover { color: var(--red); } + +/* ══ SOURCE BADGE (для общей ленты) ═══════════════════════════════════ */ +.source-pill { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + padding: 1px 8px; + border-radius: 10px; + text-transform: uppercase; + font-weight: 600; + margin-right: 6px; +} +.source-pill.workshop { background: rgba(163,113,247,0.15); color: var(--purple); } +.source-pill.project { background: rgba(31,111,235,0.15); color: var(--blue); } + +/* ══ EMPTY STATE ═══════════════════════════════════════════════════════ */ +.empty-state { + padding: 64px 24px; + text-align: center; + color: var(--text-muted); + background: var(--bg-elev); + border: 1px dashed var(--border); + border-radius: var(--radius); +} +.empty-state h3 { color: var(--text); margin-bottom: 8px; font-size: 16px; } +.empty-state p { margin-bottom: 16px; } +.empty-small { + color: var(--text-muted); + font-size: 13px; + padding: 12px; + text-align: center; + background: var(--bg); + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +/* ══ GRID ══════════════════════════════════════════════════════════════ */ +.grid { display: grid; gap: 16px; } +.grid-2 { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } +.grid-3 { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } + +/* ══ MISC ══════════════════════════════════════════════════════════════ */ +.muted { color: var(--text-muted); } +.dim { color: var(--text-dim); } +.mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; } +hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; } +code, pre { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 6px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; +} +pre { padding: 12px; overflow: auto; } +pre code { background: transparent; border: none; padding: 0; } + +/* ══ FOOTER ════════════════════════════════════════════════════════════ */ +.footer { + border-top: 1px solid var(--border); + padding: 24px 32px; + text-align: center; + color: var(--text-muted); + font-size: 13px; + margin-top: auto; +} +.footer a { color: var(--text-muted); } +.footer a:hover { color: var(--accent); } + +/* ══ RESPONSIVE ════════════════════════════════════════════════════════ */ +@media (max-width: 900px) { + .layout { grid-template-columns: 1fr; } + .form-row { grid-template-columns: 1fr; } +} +@media (max-width: 768px) { + .topnav { padding: 12px 16px; gap: 12px; } + .topnav-links { display: none; } + .topnav-actions { padding-right: 60px; } + .container { padding: 16px; } + .page-title { font-size: 20px; } + .search-input { min-width: 120px; } + .page-header { flex-direction: column; align-items: flex-start; } +} + +/* ══════════════════════════════════════════════════════════════════════ + 1.5: Онбординг, welcome-баннер, hero-grid для главной + ══════════════════════════════════════════════════════════════════════ */ + +/* welcome-баннер для новичков */ +.welcome-banner { + background: linear-gradient(135deg, rgba(88,166,255,0.12) 0%, rgba(163,113,247,0.12) 100%); + border: 1px solid rgba(88,166,255,0.4); + border-radius: var(--radius); + padding: 20px 24px; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} +.welcome-banner-icon { font-size: 40px; line-height: 1; } +.welcome-banner-body { flex: 1; min-width: 200px; } +.welcome-banner-title { font-size: 16px; font-weight: 600; color: var(--text); margin: 0 0 4px; } +.welcome-banner-text { color: var(--text-muted); font-size: 13px; margin: 0; } +.welcome-banner .btn { flex-shrink: 0; } + +/* hero-grid для главной */ +.hero-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + margin-bottom: 32px; +} +@media (max-width: 900px) { + .hero-grid { grid-template-columns: 1fr; } +} +.hero-main { + padding: 32px; + background: linear-gradient(135deg, #161b22 0%, #1c2128 100%); + border: 1px solid var(--border); + border-radius: var(--radius); + position: relative; + overflow: hidden; +} +.hero-main::before { + content: ''; + position: absolute; + top: -50%; right: -20%; + width: 400px; height: 400px; + background: radial-gradient(circle, rgba(88,166,255,0.08) 0%, transparent 70%); + pointer-events: none; +} +.hero-title { + font-size: 32px; font-weight: 700; margin: 0 0 12px; + background: linear-gradient(90deg, #58a6ff, #a371f7); + -webkit-background-clip: text; background-clip: text; color: transparent; + line-height: 1.2; +} +.hero-subtitle { + color: var(--text-muted); font-size: 16px; margin: 0 0 24px; + line-height: 1.6; max-width: 500px; +} +.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; } +.hero-side { display: flex; flex-direction: column; gap: 16px; } +.status-card { + padding: 20px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg-elev); +} +.status-label { + color: var(--text-muted); font-size: 12px; text-transform: uppercase; + font-weight: 600; margin-bottom: 8px; +} +.status-value { font-size: 18px; font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; } +.status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } +.status-dot-green { background: #3fb950; box-shadow: 0 0 0 3px rgba(63,185,80,0.2); } +.status-dot-yellow { background: #d29922; box-shadow: 0 0 0 3px rgba(210,153,34,0.2); } +.status-dot-red { background: #f85149; box-shadow: 0 0 0 3px rgba(248,81,73,0.2); } +.status-dot-grey { background: #8b949e; box-shadow: 0 0 0 3px rgba(139,148,158,0.2); } +.status-meta { color: var(--text-muted); font-size: 13px; } +.status-link { + display: inline-flex; align-items: center; gap: 4px; + margin-top: 12px; font-size: 13px; font-weight: 500; +} + +/* feature cards на главной */ +.section-header-row { + display: flex; justify-content: space-between; align-items: center; + margin: 32px 0 16px; +} +.section-header-row h2 { + font-size: 18px; font-weight: 600; color: var(--text); margin: 0; +} +.cards-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} +.feature-card { + padding: 20px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg-elev); transition: all .15s; + display: flex; flex-direction: column; text-decoration: none; color: inherit; +} +.feature-card:hover { border-color: var(--accent); transform: translateY(-2px); text-decoration: none; } +.feature-icon { font-size: 32px; margin-bottom: 12px; } +.feature-title { font-size: 16px; font-weight: 600; color: var(--text); margin: 0 0 6px; } +.feature-desc { color: var(--text-muted); font-size: 13px; line-height: 1.5; margin: 0; flex: 1; } +.feature-badge { + display: inline-block; margin-top: 12px; padding: 0 8px; border-radius: 2em; + font-size: 11px; font-weight: 500; line-height: 18px; border: 1px solid; +} +.feature-badge-new { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); } + +/* ══ Онбординг ══════════════════════════════════════════════════════════ */ +.onboarding-progress { + background: var(--bg-elev); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; margin-bottom: 24px; +} +.onboarding-progress-label { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 12px; font-size: 14px; color: var(--text-muted); +} +.onboarding-progress-label strong { color: var(--text); font-size: 16px; } +.onboarding-progress-bar { + height: 10px; background: var(--bg); border-radius: 5px; + overflow: hidden; border: 1px solid var(--border); +} +.onboarding-progress-fill { + height: 100%; background: linear-gradient(90deg, #58a6ff, #a371f7); + transition: width .3s ease; border-radius: 5px; +} +.onboarding-step { + background: var(--bg-elev); border: 1px solid var(--border); + border-radius: var(--radius); padding: 20px; margin-bottom: 12px; + display: flex; gap: 20px; align-items: flex-start; + transition: all .15s; +} +.onboarding-step.done { border-color: rgba(63,185,80,0.5); background: rgba(63,185,80,0.03); } +.onboarding-step:hover { border-color: var(--accent); } +.onboarding-step-num { + width: 40px; height: 40px; border-radius: 50%; + background: var(--bg-elev-2); border: 2px solid var(--border); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 16px; color: var(--text-muted); flex-shrink: 0; +} +.onboarding-step.done .onboarding-step-num { + background: rgba(63,185,80,0.15); border-color: #3fb950; color: #3fb950; +} +.onboarding-step-body { flex: 1; min-width: 0; } +.onboarding-step-title { + font-size: 16px; font-weight: 600; color: var(--text); + margin: 0 0 4px; display: flex; align-items: center; gap: 8px; +} +.onboarding-step-desc { color: var(--text-muted); font-size: 13px; margin: 0 0 12px; line-height: 1.5; } +.onboarding-step-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.onboarding-step-check { display: flex; align-items: center; gap: 8px; color: var(--text-muted); font-size: 13px; cursor: pointer; } +.onboarding-step-check input { width: 16px; height: 16px; cursor: pointer; } + +@media (max-width: 768px) { + .hero-main { padding: 20px; } + .hero-title { font-size: 24px; } + .section-header-row { flex-direction: column; align-items: flex-start; gap: 8px; } + .onboarding-step { flex-direction: column; gap: 12px; } +} + +/* ══════════════════════════════════════════════════════════════════════ + 1.6: Auth-страницы (login / register) — центрированная карточка + ══════════════════════════════════════════════════════════════════════ */ +main.auth-page { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + min-height: calc(100vh - 64px - 100px); +} +.auth-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 8px; + padding: 30px; + width: 100%; + max-width: 380px; +} +.auth-card h2 { color: var(--accent); margin-top: 0; text-align: center; } +.auth-card .form-group { margin-bottom: 15px; display: flex; flex-direction: column; } +.auth-card label { margin-bottom: 5px; color: var(--text-muted); font-size: 0.85rem; } +.auth-card input, .auth-card select { + padding: 10px; background: var(--bg); color: var(--text); + border: 1px solid var(--border); border-radius: 5px; font-family: inherit; +} +.auth-card input:focus, .auth-card select:focus { outline: none; border-color: var(--accent); } +.auth-card button[type="submit"] { + width: 100%; padding: 10px; background: var(--green-bg); color: #fff; + border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; +} +.auth-card button[type="submit"]:hover { background: var(--green-bg-hover); } +.auth-card .msg { color: var(--green); text-align: center; margin-bottom: 15px; font-size: 0.9rem; } +.auth-card .error { color: var(--red); text-align: center; margin-bottom: 15px; font-size: 0.9rem; } +.auth-card .gitea-btn { + background: var(--border); margin-top: 15px; display: block; text-align: center; + text-decoration: none; padding: 10px; border-radius: 5px; color: var(--text); + font-weight: bold; font-size: 0.9rem; +} +.auth-card .gitea-btn:hover { background: var(--bg-elev-2); text-decoration: none; } +.auth-card .links { margin-top: 15px; text-align: center; font-size: 0.85rem; } +.auth-card .links a { color: var(--accent); text-decoration: none; display: block; margin-bottom: 5px; } +@media (max-width: 768px) { + main.auth-page { padding: 20px 12px; min-height: calc(100vh - 60px - 80px); } + .auth-card { padding: 20px; } +} + + +/* ══════════════════════════════════════════════════════════════════════ + F: Files v2.0 — красивый редизайн + ══════════════════════════════════════════════════════════════════════ */ + +.files-hero { + background: linear-gradient(135deg, #161b22 0%, #1c2128 100%); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px 28px; + margin-bottom: 24px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} +.files-hero-left { flex: 1; min-width: 200px; } +.files-hero h1 { margin: 0 0 4px; font-size: 24px; color: var(--text); } +.files-hero .subtitle { color: var(--text-muted); font-size: 13px; margin: 0; } +.files-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.files-section { margin-bottom: 32px; } +.files-section-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} +.files-section-head h2 { + font-size: 16px; + font-weight: 600; + color: var(--text); + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} +.files-section-head h2 .count { + background: var(--bg-elev-2); + color: var(--text-muted); + padding: 1px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 500; +} + +.space-grid-v2 { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; +} +.space-card-v2 { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + gap: 10px; + transition: all .15s; + position: relative; + overflow: hidden; +} +.space-card-v2::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: linear-gradient(90deg, #58a6ff, #a371f7); +} +.space-card-v2:hover { + border-color: var(--accent); + transform: translateY(-3px); + box-shadow: 0 8px 24px rgba(0,0,0,.3); + text-decoration: none; +} +.space-card-v2-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; +} +.space-card-v2-icon { font-size: 32px; line-height: 1; } +.space-card-v2-vis { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .3px; +} +.space-card-v2-vis.public { background: rgba(63,185,80,0.15); color: #3fb950; } +.space-card-v2-vis.internal { background: rgba(210,153,34,0.15); color: #d29922; } +.space-card-v2-vis.private { background: rgba(248,81,73,0.15); color: #f85149; } +.space-card-v2-title { font-size: 15px; font-weight: 600; color: var(--text); line-height: 1.3; } +.space-card-v2-desc { + color: var(--text-muted); + font-size: 13px; + line-height: 1.4; + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.space-card-v2-meta { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-dim); + padding-top: 8px; + border-top: 1px solid var(--border); + flex-wrap: wrap; +} +.space-card-v2-meta span { display: inline-flex; align-items: center; gap: 4px; } + +.folder-grid-v2 { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; +} +.folder-tile-v2 { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + text-decoration: none; + color: inherit; + display: flex; + align-items: center; + gap: 10px; + transition: all .15s; +} +.folder-tile-v2:hover { + border-color: var(--accent); + background: var(--bg-elev-2); + text-decoration: none; + transform: translateX(2px); +} +.folder-tile-v2-icon { font-size: 24px; flex-shrink: 0; } +.folder-tile-v2-info { flex: 1; min-width: 0; } +.folder-tile-v2-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.folder-tile-v2-meta { font-size: 11px; color: var(--text-dim); } + +.file-grid-v2 { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 14px; +} +.file-tile-v2 { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; + transition: all .15s; +} +.file-tile-v2:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 6px 18px rgba(0,0,0,.3); +} +.file-tile-v2-preview { + height: 140px; + background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} +.file-tile-v2-preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.file-tile-v2-icon-big { font-size: 56px; opacity: .5; } +.file-tile-v2-badge { + position: absolute; + top: 8px; + right: 8px; + background: rgba(88,166,255,0.9); + color: #fff; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 700; + letter-spacing: .3px; + z-index: 2; +} +.file-tile-v2-body { + padding: 10px 14px; + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.file-tile-v2-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.3; +} +.file-tile-v2-desc { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-tile-v2-meta { + font-size: 11px; + color: var(--text-dim); + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.file-tile-v2-actions { + display: flex; + gap: 4px; + padding: 8px 14px; + border-top: 1px solid var(--border); + background: var(--bg); + justify-content: flex-end; + opacity: 0; + transition: opacity .15s; +} +.file-tile-v2:hover .file-tile-v2-actions { opacity: 1; } +.file-tile-v2-actions a, +.file-tile-v2-actions button { + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; + padding: 3px 8px; + border-radius: 4px; + font-size: 12px; + text-decoration: none; + font-family: inherit; + display: inline-flex; + align-items: center; + justify-content: center; +} +.file-tile-v2-actions a:hover { background: var(--bg-elev-2); color: var(--accent); text-decoration: none; } +.file-tile-v2-actions button:hover { background: var(--bg-elev-2); color: var(--text); } +.file-tile-v2-actions .danger:hover { color: var(--red); } + +/* Upload zone — flex-центрирование (фикс "сломался дизайн") */ +.upload-zone { + border: 2px dashed var(--border); + border-radius: 10px; + padding: 32px 24px; + background: var(--bg-elev); + transition: all .2s; + margin-bottom: 20px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + user-select: none; +} +.upload-zone:hover { border-color: var(--accent); background: var(--bg-elev-2); } +.upload-zone:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-color: var(--accent); +} +.upload-zone.dragover { + border-color: var(--accent); + background: rgba(88,166,255,0.08); + transform: scale(1.005); +} +.upload-zone.uploading { + border-color: var(--accent); + background: rgba(88,166,255,0.05); + cursor: wait; +} +.upload-zone input[type="file"] { display: none; } +.upload-zone-icon { + font-size: 42px; + margin-bottom: 10px; + opacity: .7; + line-height: 1; +} +.upload-zone-title { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} +.upload-zone-hint { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +.files-empty { + padding: 48px 24px; + text-align: center; + color: var(--text-muted); + background: var(--bg-elev); + border: 1px dashed var(--border); + border-radius: 10px; +} +.files-empty .icon { font-size: 48px; opacity: .4; margin-bottom: 12px; } +.files-empty h3 { color: var(--text); font-size: 16px; margin: 0 0 6px; } +.files-empty p { font-size: 13px; margin: 0 0 16px; } + +.breadcrumbs-bar { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 16px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 13px; + margin-bottom: 20px; +} + +.meta-table { + width: 100%; + border-collapse: collapse; + background: var(--bg-elev); + border-radius: 8px; + overflow: hidden; +} +.meta-table th, +.meta-table td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid var(--border); +} +.meta-table th { + background: var(--bg-elev-2); + color: var(--text-muted); + font-size: 12px; + text-transform: uppercase; + font-weight: 600; +} +.meta-table tr:hover { background: var(--bg-elev-2); } +.meta-table tr:last-child td { border-bottom: none; } + +@media (max-width: 768px) { + .space-grid-v2 { grid-template-columns: 1fr; } + .folder-grid-v2 { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } + .file-grid-v2 { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } + .file-tile-v2-preview { height: 100px; } + .file-tile-v2-icon-big { font-size: 40px; } + .files-hero { padding: 16px 20px; } + .files-hero h1 { font-size: 20px; } +} + +/* F2: Кнопки удаления папок/пространств */ +.folder-tile-v2-wrap { + position: relative; +} +.folder-tile-v2-wrap .folder-tile-v2 { + padding-right: 36px; +} +.folder-delete-form { + position: absolute; + top: 8px; + right: 8px; + opacity: 0; + transition: opacity .15s; + z-index: 2; +} +.folder-tile-v2-wrap:hover .folder-delete-form { opacity: 1; } +.folder-delete-btn { + background: rgba(248,81,73,0.85); + color: #fff; + border: none; + width: 22px; + height: 22px; + border-radius: 50%; + cursor: pointer; + font-size: 11px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + font-family: inherit; +} +.folder-delete-btn:hover { background: var(--red); } diff --git a/app/templates/admin_event_edit.html b/app/templates/admin_event_edit.html index 1171b67..63bd7c8 100644 --- a/app/templates/admin_event_edit.html +++ b/app/templates/admin_event_edit.html @@ -1,100 +1,71 @@ - - - - - Редактирование события — Morozovka Portal - - - - - +{% extends "base.html" %} +{% block title %}Редактирование события — Morozovka Portal{% endblock %} +{% block main_class %}container container-narrow{% endblock %} - - - -
- ← Назад к расписанию -

Редактирование события

- -
-
-
-
- - -
-
- - -
-
+{% block head %} + + + + +{% endblock %} -
-
- - -
-
+{% block content %} +← Назад к расписанию +

Редактирование события

-
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- - -
+
+
+
+ +
-
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + Отмена +
+ +
+{% endblock %} - - - +{% block scripts %} + +{% endblock %} diff --git a/app/templates/admin_event_edit.html.bak b/app/templates/admin_event_edit.html.bak deleted file mode 100644 index 8a04d0c..0000000 --- a/app/templates/admin_event_edit.html.bak +++ /dev/null @@ -1,100 +0,0 @@ - - - - - Редактирование события — Morozovka Portal - - - - - - - - - -
- ← Назад к расписанию -

Редактирование события

- -
-
-
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- - -
-
-
- - - - diff --git a/app/templates/admin_schedule.html b/app/templates/admin_schedule.html index d9e6916..3ffe3a1 100644 --- a/app/templates/admin_schedule.html +++ b/app/templates/admin_schedule.html @@ -1,199 +1,186 @@ - - - - - Управление расписанием — Morozovka Portal - - - - - +{% extends "base.html" %} +{% block title %}Управление расписанием — Morozovka Portal{% endblock %} +{% block main_class %}container{% endblock %} - +{% endblock %} - button[type="submit"] { padding: 10px 15px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; height: 38px; } - button[type="submit"]:hover { background: #2ea043; } - button.danger { background: #da3633; padding: 6px 10px; font-size: 0.8rem; height: auto; } - button.danger:hover { background: #f85149; } - button.edit-btn { background: #1f6feb; padding: 6px 10px; font-size: 0.8rem; height: auto; text-decoration: none; color: white; border-radius: 5px; margin-right: 5px; } - button.edit-btn:hover { background: #388bfd; } - - table { width: 100%; border-collapse: collapse; margin-top: 20px; } - th, td { padding: 10px; text-align: left; border-bottom: 1px solid #30363d; } - th { background: #21262d; color: #8b949e; font-size: 0.85rem; } - - .back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; } - .back-link:hover { color: #58a6ff; } - - .action-buttons a { display: inline-block; padding: 8px 15px; border-radius: 5px; text-decoration: none; font-weight: bold; font-size: 0.85rem; margin-left: 10px; color: white; } - .btn-import { background: #a371f7; } - .btn-export { background: #21262d; border: 1px solid #30363d; } - .btn-export:hover { background: #30363d; } - - - -
- ← На главную - -
-

Управление расписанием

- -
+{% block content %} +
+

Управление расписанием

+ +
-
-

Текущий статус

-
- - - - - -
- -
-
-
- - -
-
- - -
-
- - -
- -
-
-
- -
-

Добавить новое событие

-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
- -
-
-
- -
-

Последние 50 событий

- - - - - - - - - - - - {% for event in events %} - - - - - - - - {% else %} - - {% endfor %} - -
Дата и времяНазваниеТипПубличноеДействие
{{ event.start_time|msk_strftime('%d.%m.%Y %H:%M') }}
до {{ event.end_time|msk_strftime('%H:%M') }}
{{ event.title }}
{{ event.location or '' }}
{{ event.event_type }}{{ 'Да' if event.is_public else 'Нет' }} - ✎ Ред. -
- -
-
Нет событий
-
+
+

Текущий статус

+
+ + + + +
- - - +
+

Добавить новое событие

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+
+ +
+

Последние 50 событий

+ + + + + + + + + + + + {% for event in events %} + + + + + + + + {% else %} + + {% endfor %} + +
Дата и времяНазваниеТипПубличноеДействие
{{ event.start_time|msk_strftime('%d.%m.%Y %H:%M') }}
до {{ event.end_time|msk_strftime('%H:%M') }}
{{ event.title }}
{{ event.location or '' }}
{{ event.event_type }}{{ 'Да' if event.is_public else 'Нет' }} + ✎ Ред. +
+ +
+
Нет событий
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/admin_schedule.html.bak b/app/templates/admin_schedule.html.bak deleted file mode 100644 index b789b2d..0000000 --- a/app/templates/admin_schedule.html.bak +++ /dev/null @@ -1,199 +0,0 @@ - - - - - Управление расписанием — Morozovka Portal - - - - - - - - - -
- ← На главную - -
-

Управление расписанием

- -
- -
-

Текущий статус

-
- - - - - -
- -
-
-
- - -
-
- - -
-
- - -
- -
-
-
- -
-

Добавить новое событие

-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
- -
-
-
- -
-

Последние 50 событий

- - - - - - - - - - - - {% for event in events %} - - - - - - - - {% else %} - - {% endfor %} - -
Дата и времяНазваниеТипПубличноеДействие
{{ event.start_time.strftime('%d.%m.%Y %H:%M') }}
до {{ event.end_time.strftime('%H:%M') }}
{{ event.title }}
{{ event.location or '' }}
{{ event.event_type }}{{ 'Да' if event.is_public else 'Нет' }} - ✎ Ред. -
- -
-
Нет событий
-
-
- - - - diff --git a/app/templates/admin_schedule_import.html b/app/templates/admin_schedule_import.html index f0f8f8b..0d4ccbf 100644 --- a/app/templates/admin_schedule_import.html +++ b/app/templates/admin_schedule_import.html @@ -1,54 +1,42 @@ - - - - - Импорт расписания — Morozovka Portal - - - -
- ← Назад к расписанию -

Импорт расписания

- -
-
-
- - -

Файл не выбран

-
- -
- -
- Как выгрузить расписание из Google Calendar?
- 1. Зайдите в Google Calendar на компьютере.
- 2. Нажмите на три точки рядом с нужным календарем.
- 3. Выберите «Настройки и общий доступ».
- 4. Прокрутите вниз до раздела «Экспорт календаря» и скачайте ZIP-архив.
- 5. Распакуйте архив и загрузите сюда файл с расширением .ics. -
+{% extends "base.html" %} +{% block title %}Импорт расписания — Morozovka Portal{% endblock %} +{% block main_class %}container container-narrow{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +← Назад к расписанию +

Импорт расписания

+ +
+
+
+ + +

Файл не выбран

+ +
+ +
+ Как выгрузить расписание из Google Calendar?
+ 1. Зайдите в Google Calendar на компьютере.
+ 2. Нажмите на три точки рядом с нужным календарем.
+ 3. Выберите «Настройки и общий доступ».
+ 4. Прокрутите вниз до раздела «Экспорт календаря» и скачайте ZIP-архив.
+ 5. Распакуйте архив и загрузите сюда файл с расширением .ics.
- - +
+{% endblock %} diff --git a/app/templates/admin_users.html b/app/templates/admin_users.html index 50d59fb..31cb7e1 100644 --- a/app/templates/admin_users.html +++ b/app/templates/admin_users.html @@ -1,82 +1,114 @@ - - - - - Админка — Morozovka Portal - - - -

Управление пользователями

-

← На главную

- - - - - - - - - - - - - - {% for u in users %} - - - - - - - - - {% endfor %} - -
IDЛогинФИОEmailРольДействия
{{ u.id }}{{ u.username }}{{ u.full_name or '—' }}{{ u.email or '—' }} - {{ u.role }} - -
- -
- - -
+{% extends "base.html" %} +{% block title %}Управление пользователями — Morozovka Portal{% endblock %} +{% block main_class %}container{% endblock %} - - {% if u.id != session.get('user_id') %} -
- -
- {% endif %} -
-
- - +{% block head %} + +{% endblock %} + +{% block content %} +

Управление пользователями

+ +
+ + + {% if q %}✕ Сбросить{% endif %} +
+ + + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDЛогинФИОEmailРольСтатусДействия
{{ u.id }}{{ u.username }}{{ u.full_name or '—' }}{{ u.email or '—' }} + {{ u.role }} + + {% if u.is_active %}активен + {% else %}заблокирован{% endif %} + +
+
+ + +
+ + {% if u.id != session.get('user_id') %} +
+ +
+
+ +
+ {% endif %} +
+
Пользователи не найдены
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..22c71fd --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,58 @@ + + + + + + {% block title %}Morozovka Platform{% endblock %} + + {% block head %}{% endblock %} + + + + +{% block outer_content %} +
+ {% block content %}{% endblock %} +
+{% endblock %} + + + + +{% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..42a5597 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,459 @@ +{% extends "base.html" %} +{% block title %}Дашборд — Morozovka Portal{% endblock %} +{% block main_class %}container{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

Привет, {{ session.get('username') }}!

+
Твой персональный дашборд
+
+ +
+ + +
+
+

📅 Ближайшие события

+ Все → +
+ {% if upcoming_events %} +
+ {% for ev in upcoming_events %} +
+
{{ ev.start_time|msk_strftime('%d.%m %H:%M') }}
+
+
{{ ev.title }}
+ {% if ev.location %} +
📍 {{ ev.location }}
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
Ближайших событий нет
+ {% endif %} +
+ + +
+
+

🗂 Мои проекты {{ my_projects|length }}

+ Все → +
+ {% if my_projects %} + + {% else %} +
Вы пока не в проектах
+ {% endif %} +
+ + +
+
+

✅ Мои задачи {{ my_tasks|length }}

+ Все → +
+ {% if my_tasks %} +
+ {% for task, proj in my_tasks %} +
+
+
+ {{ task.title }} +
+ {{ proj.title }} + {% if task.due_date %} · до {{ task.due_date|date_ru }}{% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
Активных задач нет
+ {% endif %} +
+ + +
+
+

🔔 Уведомления {% if unread_count > 0 %}{{ unread_count }}{% endif %}

+ Все → +
+ {% if unread %} + + {% else %} +
Нет непрочитанных уведомлений
+ {% endif %} +
+ + +
+
+

📚 Материалы МК {{ new_materials|length }}

+ Все → +
+ {% if new_materials %} +
+ {% for material, ws in new_materials %} +
+
+ {% if material.external_url %}🔗{% else %}📎{% endif %} +
+
+
+ {% if material.external_url %} + {{ material.title }} + {% elif material.file_id %} + {{ material.title }} + {% else %} + {{ material.title }} + {% endif %} +
+
{{ ws.title }}
+
+
+ {% endfor %} +
+ {% else %} +
Пока нет новых материалов
+ {% endif %} +
+ + + {% if gitea_widget %} +
+
+

💻 Последние коммиты

+ Все → +
+
+ {% for repo in gitea_widget %} +
{{ repo.project_title }}
+ {% for c in repo.commits %} +
+ {{ c.sha_short }} + {{ c.message }} +
{{ c.author }}{% if c.date %} · {{ c.date[:16].replace('T', ' ') }}{% endif %}
+
+ {% endfor %} + {% endfor %} +
+
+ {% endif %} + + + + +
+{% endblock %} diff --git a/app/templates/feed.html b/app/templates/feed.html new file mode 100644 index 0000000..1ea5ebc --- /dev/null +++ b/app/templates/feed.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Лента — Morozovka Portal{% endblock %} +{% block main_class %}container container-narrow{% endblock %} + +{% block content %} + + +
+ Источник: + Все + 🎓 Мастер-классы + 🗂 Проекты + +
+ {% if current_source != 'all' %}{% endif %} + + + {% if q %}✕{% endif %} +
+
+ +{% if items %} +
+ Показано {{ items|length }}{% if total_count > items|length %} из {{ total_count }}{% endif %} +
+{% for item in items %} +
+
+
+
+ {% if item.is_pinned %}📌{% endif %} + {{ item.kind_icon }} {{ item.kind_label }} + {{ item.title }} +
+ +
+
+ {% if item.body %}
{{ item.body }}
{% endif %} + {% if item.attachments %} +
+ {% for fid in item.attachments %} + {% set f = files_map.get(fid) %} + {% if f %} + {% if f.original_name|is_image %} +
+ + {{ f.original_name }} + +
{{ f.original_name }} · {{ (f.size_bytes/1024)|round(1) }} КБ
+
+ {% else %} +
{{ f.original_name|file_icon }} {{ f.original_name }} ({{ (f.size_bytes/1024)|round(1) }} КБ)
+ {% endif %} + {% endif %} + {% endfor %} +
+ {% endif %} +
+{% endfor %} +{% else %} +
+

{% if q %}Ничего не найдено{% else %}Пока нет постов{% endif %}

+

{% if q %}Попробуйте другой запрос или сбросьте поиск.{% else %}Когда появятся новости в мастер-классах или проектах — они появятся здесь.{% endif %}

+
+{% endif %} +{% endblock %} diff --git a/app/templates/files.html b/app/templates/files.html index 26f1b1c..7289bbf 100644 --- a/app/templates/files.html +++ b/app/templates/files.html @@ -1,83 +1,553 @@ - - - - - Файлы — Morozovka Portal - - - -
- ← На главную -

Файлохранилище

- -
-
- - - Файл не выбран -

- -
-
+{% block content %} - - - - - - - - - - - - {% for db_file, uploader_name in files %} - - - - - - - - {% else %} - - - - {% endfor %} - -
Имя файлаРазмерЗагрузилДатаДействия
{{ db_file.original_name }}{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ{{ uploader_name }}{{ db_file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }} - Скачать - - {% if session.get('role') == 'admin' or db_file.uploaded_by == session.get('user_id') %} -
- -
- {% endif %} -
Файлов пока нет. Загрузите первый!
+{# ═══════════════════════════════════════════════════════════════════ #} +{# ROOT: пространства + файлы без пространства #} +{# ═══════════════════════════════════════════════════════════════════ #} +{% if view == 'root' %} + +
+
+

📁 Файлохранилище

+

Пространства, папки, файлы с версиями и правами доступа

- - +
+ {% if can_create_space %} + + Пространство + {% endif %} + 🔍 Поиск +
+
+ +
+
+

🗂 Пространства {{ spaces|length }}

+
+ {% if spaces %} + + {% else %} +
+
🗂
+

Пространств пока нет

+

{% if can_create_space %}Создайте первое — например, «Документы команды» или «Материалы проекта».{% else %}Попросите lead/admin создать пространство.{% endif %}

+ {% if can_create_space %}Создать пространство{% endif %} +
+ {% endif %} +
+ +
+
+

📎 Без пространства {{ loose_files|length }}

+
+

+ Старые файлы и вложения постов. Можно переместить в пространство кнопкой ↔. +

+ +
+ +
☁️
+
Перетащите файлы сюда или кликните
+
Можно выбрать несколько файлов · суммарно до 100 МБ
+
+ + {% if loose_files %} +
+ {% for f, uploader in loose_files %} +
+
+ {% if f.original_name|is_image %} + + {{ f.original_name }} + + {% else %} +
{{ f.original_name|file_icon }}
+ {% endif %} +
+
+
{{ f.original_name }}
+
+ {{ f.size_bytes|format_size }} + · + {{ uploader }} + · + {{ f.uploaded_at|msk_strftime('%d.%m.%Y') }} +
+
+
+ ⬇ + ↔ + {% if session.get('role') == 'admin' or f.uploaded_by == session.get('user_id') %} +
+ + +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+
📎
+

Файлов без пространства нет

+

Перетащите файл в зону выше или создайте пространство.

+
+ {% endif %} +
+ +{% endif %} + +{# ═══════════════════════════════════════════════════════════════════ #} +{# SPACE / FOLDER #} +{# ═══════════════════════════════════════════════════════════════════ #} +{% if view in ('space', 'folder') %} + + + +
+
+

{% if view == 'space' %}📂 {{ space.name }}{% else %}📁 {{ folder.name }}{% endif %}

+

+ {% if view == 'space' and space.description %}{{ space.description }} + {% elif view == 'space' %}Пространство · {{ space.visibility }} + {% else %}Папка в «{{ space.name }}»{% endif %} +

+
+
+ {% if perms.can_upload %} + + {% endif %} + {% if view == 'space' and perms.can_manage %} + 🔐 Права + ⚙ Настройки + {% endif %} + {% if view == 'folder' and perms.can_manage %} + 🔐 Права папки + {% endif %} + 🔍 Поиск + {% if view == 'space' and (perms.is_owner or session.get('role') == 'admin') %} +
+ +
+ {% endif %} + {% if view == 'folder' and perms.can_delete %} +
+ +
+ {% endif %} +
+
+ +{% if perms.can_upload %} +
+ +
☁️
+
Перетащите файлы сюда или кликните
+
+ Загрузится в {% if view == 'folder' %}«{{ folder.name }}»{% else %}корень пространства{% endif %} · суммарно до 100 МБ +
+
+{% endif %} + +{% if folders %} +
+
+

📁 Папки {{ folders|length }}

+
+
+ {% for fd in folders %} +
+ + 📁 +
+
{{ fd.name }}
+
{{ fd.created_at|msk_strftime('%d.%m.%Y') }}
+
+
+ {% if perms.can_delete or perms.can_manage %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+
+{% endif %} + +
+
+

📄 Файлы {{ files|length }}

+
+ {% if files %} +
+ {% for f, uploader in files %} +
+
+ {% if f.original_name|is_image %} + + {{ f.original_name }} + + {% else %} +
{{ f.original_name|file_icon }}
+ {% endif %} + v{{ f.version }} +
+
+
{{ f.original_name }}
+ {% if f.description %} +
{{ f.description }}
+ {% endif %} +
+ {{ f.size_bytes|format_size }} + · + {{ uploader }} + · + {{ f.uploaded_at|msk_strftime('%d.%m.%Y') }} +
+
+
+ ⬇ + 🕘 + ↔ + ✎ + {% if perms.can_delete or session.get('role') == 'admin' %} +
+ + +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+
📄
+

{% if folders %}В этой папке файлов нет{% else %}Пусто{% endif %}

+

{% if perms.can_upload %}Перетащите файл в зону выше или кликните по ней.{% else %}Файлы появятся, когда их загрузят.{% endif %}

+
+ {% endif %} +
+ +{% if perms.can_upload %} + + +{% endif %} + +{% endif %} + +{# ═══════════════════════════════════════════════════════════════════ #} +{# SEARCH #} +{# ═══════════════════════════════════════════════════════════════════ #} +{% if view == 'search' %} + +
+
+

🔍 Поиск файлов

+

{% if space_scope %}в пространстве «{{ space_scope.name }}»{% else %}по всем пространствам{% endif %}

+
+ +
+ +
+
+ {% if space_scope %}{% endif %} + + + {% if q %}✕ Сбросить{% endif %} +
+
+ +{% if q %} +
Найдено: {{ results|length }}
+{% endif %} + +{% if results %} +
+ {% for f, uploader in results %} +
+
+ {% if f.original_name|is_image %} + + {{ f.original_name }} + + {% else %} +
{{ f.original_name|file_icon }}
+ {% endif %} + {% if f.version and f.version > 1 %}v{{ f.version }}{% endif %} +
+
+
{{ f.original_name }}
+ {% if f.tags %} +
#{{ f.tags }}
+ {% elif f.description %} +
{{ f.description }}
+ {% endif %} +
+ {{ f.size_bytes|format_size }} + · + {{ uploader }} + · + {{ f.uploaded_at|msk_strftime('%d.%m.%Y') }} +
+
+
+ ⬇ + {% if f.space_id %} + 🕘 + {% endif %} +
+
+ {% endfor %} +
+{% elif q %} +
+
🔍
+

Ничего не найдено

+

Попробуйте другой запрос или вернитесь к списку.

+
+{% else %} +
+
🔍
+

Введите запрос

+

Поиск по имени файла, описанию и тегам.

+
+{% endif %} + +{% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/files.html.bak b/app/templates/files.html.bak deleted file mode 100644 index 9bc6266..0000000 --- a/app/templates/files.html.bak +++ /dev/null @@ -1,83 +0,0 @@ - - - - - Файлы — Morozovka Portal - - - -
- ← На главную -

Файлохранилище

- -
-
- - - Файл не выбран -

- -
-
- - - - - - - - - - - - - {% for db_file, uploader_name in files %} - - - - - - - - {% else %} - - - - {% endfor %} - -
Имя файлаРазмерЗагрузилДатаДействия
{{ db_file.original_name }}{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ{{ uploader_name }}{{ db_file.uploaded_at.strftime('%d.%m.%Y %H:%M') }} - Скачать - - {% if session.get('role') == 'admin' or db_file.uploaded_by == session.get('user_id') %} -
- -
- {% endif %} -
Файлов пока нет. Загрузите первый!
-
- - diff --git a/app/templates/files_acl.html b/app/templates/files_acl.html new file mode 100644 index 0000000..5b51480 --- /dev/null +++ b/app/templates/files_acl.html @@ -0,0 +1,197 @@ +{% extends "base.html" %} +{% block title %}Права доступа — Morozovka Portal{% endblock %} +{% block main_class %}container container-narrow{% endblock %} + +{% block content %} + +{% if mode == 'space' %} + +
+
+

🔐 Права доступа

+

Пространство «{{ space.name }}»

+
+ +
+{% else %} + +
+
+

🔐 Права доступа — папка

+

«{{ folder.name }}» в пространстве «{{ space.name }}»

+
+ +
+{% endif %} + +{% if mode == 'space' %} +
+

Владелец пространства

+
+
+
{{ owner.full_name or owner.username }}
+
@{{ owner.username }}
+
+ Владелец +
+

+ Владелец всегда имеет полные права. Роли ниже применяются к остальным пользователям. Если явных записей нет — действует видимость пространства: {{ space.visibility }}. +

+
+ +
+

Добавить / обновить доступ

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+
+

👥 Пользователи с доступом {{ acl_entries|length }}

+
+ {% if acl_entries %} + + + + + + {% for entry, u in acl_entries %} + + + + + + {% endfor %} + +
ПользовательРольДействия
+
{{ u.full_name or u.username }}
+
@{{ u.username }}
+
+ + {{ entry.role }} + + +
+ +
+
+ {% else %} +
+
👥
+

Явных записей нет

+

Действует видимость пространства: {{ space.visibility }}. Добавьте пользователей выше для гранулярного контроля.

+
+ {% endif %} +
+ +{% else %} + +
+

Добавить / обновить доступ на папку

+

+ Права на папку перекрывают права пространства. Если для пользователя нет записи в папке — действуют права пространства. +

+
+
+ + +
+
+ +
+ + + + +
+
+
+ +
+
+
+ +
+
+

👥 Пользователи с доступом на папку {{ acl_entries|length }}

+
+ {% if acl_entries %} + + + + + + {% for entry, u in acl_entries %} + + + + + + {% endfor %} + +
ПользовательПраваДействия
+
{{ u.full_name or u.username }}
+
@{{ u.username }}
+
+
+ + + + + +
+
+
+ +
+
+ {% else %} +
+
👥
+

Явных прав на папку нет

+

Действуют права пространства «{{ space.name }}».

+
+ {% endif %} +
+ +{% endif %} + +{% endblock %} diff --git a/app/templates/files_edit.html b/app/templates/files_edit.html new file mode 100644 index 0000000..b4bcf6d --- /dev/null +++ b/app/templates/files_edit.html @@ -0,0 +1,225 @@ +{% extends "base.html" %} +{% block title %} +{% if mode == 'space_new' %}Новое пространство +{% elif mode == 'space_edit' %}Настройки пространства +{% elif mode == 'file_edit' %}Редактирование файла +{% elif mode == 'file_move' %}Переместить файл +{% endif %} — Morozovka Portal +{% endblock %} +{% block main_class %}container container-narrow{% endblock %} + +{% block content %} + +{% if mode == 'space_new' %} +
+
+

📂 Новое пространство

+

Пространство — это верхний уровень: по проекту, направлению или тематике

+
+
+
+ {% if error %}
{{ error }}
{% endif %} +
+ + +
+
+ + +
+
+
+ + +
+ {% if projects %} +
+ + +
+ {% endif %} +
+
+ + Отмена +
+
+ +{% elif mode == 'space_edit' %} +
+
+

⚙ Настройки — {{ space.name }}

+

Изменение названия, описания и видимости

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Отмена +
+
+ +{% if perms.is_owner or session.get('role') == 'admin' %} +
+

⚠ Опасная зона

+

+ Удаление пространства удалит все папки, файлы и историю версий внутри. Необратимо. +

+
+ +
+
+{% endif %} + +{% elif mode == 'file_edit' %} +
+
+

✎ Редактирование файла

+

{{ db_file.original_name }} · {{ db_file.size_bytes|format_size }} · v{{ db_file.version }}

+
+
+ ⬇ Скачать + {% if db_file.space_id %} + 🕘 История + {% endif %} +
+
+
+
+ +
+ {{ db_file.original_name|file_icon }} + {{ db_file.original_name }} +
+
+
+ + +
+
+ + + Помогают искать файл через /files/search +
+ {% if db_file.space_id and folders %} +
+ + +
+ {% endif %} +
+ + Отмена +
+
+ +{% elif mode == 'file_move' %} +
+
+

↔ Переместить файл

+

{{ db_file.original_name }}

+
+
+
+
+ +
+ {{ db_file.original_name|file_icon }} + {{ db_file.original_name }} + + {% if db_file.space_id %}в пространстве{% else %}без пространства{% endif %} + {% if db_file.folder_id %} · папка #{{ db_file.folder_id }}{% endif %} + · v{{ db_file.version }} + +
+
+ +
+ + + {% if db_file.space_id %} + ⚠ При перемещении «Без пространства» версии будут сброшены. Блоб текущей версии сохранится. + {% endif %} +
+ +
+ + +
+ +
+ + Отмена +
+
+ + +{% endif %} + +{% endblock %} diff --git a/app/templates/files_history.html b/app/templates/files_history.html new file mode 100644 index 0000000..a07a0e3 --- /dev/null +++ b/app/templates/files_history.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} +{% block title %}История версий — {{ db_file.original_name }}{% endblock %} +{% block main_class %}container container-narrow{% endblock %} + +{% block content %} + + + +
+
+

🕘 История версий

+

+ {{ db_file.original_name }} + · v{{ db_file.version }} + · {{ db_file.size_bytes|format_size }} +

+
+ +
+ +{% if perms.can_edit and db_file.space_id %} +
+

Загрузить новую версию

+
+
+ + +
+
+ + +
+
+ +
+
+
+{% endif %} + +
+
+

📚 Версии {{ versions|length }}

+
+ {% if versions %} + + + + + + + + + + + + + {% for v, u in versions %} + + + + + + + + + {% endfor %} + +
ВерсияРазмерАвторДатаКомментарийДействия
+ v{{ v.version_num }} + {% if v.id == db_file.current_version_id %} + ← текущая + {% endif %} + {{ v.size_bytes|format_size }} +
{{ u.full_name or u.username }}
+
@{{ u.username }}
+
{{ v.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}{{ v.comment or '—' }} +
+ Скачать + {% if perms.can_edit and v.id != db_file.current_version_id %} +
+ +
+ {% endif %} +
+
+ {% else %} +
+
📚
+

Версий нет

+

Файл загружен в старой схеме (без пространства) — версий нет. Или это первый файл в пространстве, и запись о версии отсутствует.

+
+ {% endif %} +
+ +
+ {% if db_file.folder_id %} + ← К папке + {% elif db_file.space_id %} + ← К пространству + {% else %} + ← Все файлы + {% endif %} +
+ +{% endblock %} diff --git a/app/templates/forgot_password.html b/app/templates/forgot_password.html index fba8f0b..a3608e4 100644 --- a/app/templates/forgot_password.html +++ b/app/templates/forgot_password.html @@ -1,32 +1,20 @@ - - - - - Сброс пароля — Morozovka Portal - - - -
-

Сброс пароля

-

Введите свой Email, и мы отправим ссылку для сброса.

-
-
- - -
- -
- +{% extends "base.html" %} +{% block title %}Сброс пароля — Morozovka Portal{% endblock %} +{% block main_class %}auth-page{% endblock %} + +{% block content %} +
+

Сброс пароля

+

Введите свой Email, и мы отправим ссылку для сброса.

+
+
+ + +
+ +
+ - - +
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index 0a8ee28..3c854cc 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,166 +1,112 @@ - - - - - -Morozovka Platform - - - - - -
-
-
- {% if username %} -

Привет, {{ username }}!

-

Добро пожаловать на платформу команды Морозовки. Расписание, проекты, мастер-классы и файлы — всё в одном месте.

- {% else %} -

Morozovka Platform

-

Единая платформа команды: расписание, проекты, мастер-классы и файлы. Войдите, чтобы получить полный доступ.

- {% endif %} - -
-
-
-
Статус Никиты Сергеевича
-
Загрузка...
-
Проверяем расписание...
- Открыть расписание → -
+
- -
-

Разделы платформы

-
- - -