add some things.....

This commit is contained in:
Ivan Budakov 2026-10-21 07:04:51 +03:00
parent 31c03a698a
commit 97f53d4c4c
71 changed files with 10487 additions and 5956 deletions

26
.env Normal file
View File

@ -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

110
README.md Normal file
View File

@ -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_<ts>` пользователя, прогоняет все модули, чистит за собой.
## Лицензия
Внутренний проект команды morozovka-bridge. Не для распространения.

117
alembic.ini Normal file
View File

@ -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

1
alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

116
alembic/env.py Normal file
View File

@ -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()

26
alembic/script.py.mako Normal file
View File

@ -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"}

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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 "Извините, я пока не понимаю этот вопрос. Попробуйте спросить: 'Где сейчас?', 'Когда следующая пара?', 'Что сегодня?' или 'Как связаться?'."

View File

@ -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

View File

@ -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"))

View File

@ -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")

View File

@ -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),
}

View File

@ -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)

View File

@ -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)

628
app/helpers.py Normal file
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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/<token>")
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/<token>", 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/<int:event_id>/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/<int:event_id>/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/<int:project_id>")
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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/members/<int:user_id>/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/<int:project_id>/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/<int:file_id>")
@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/<int:file_id>/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/<int:user_id>/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/<int:user_id>/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"))

View File

@ -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

1
app/modules/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Модули роутов Morozovka Portal."""

104
app/modules/admin_routes.py Normal file
View File

@ -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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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"))

287
app/modules/auth_routes.py Normal file
View File

@ -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/<token>")
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/<token>", 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/<token>")
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/<token>", 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))

View File

@ -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,
)

157
app/modules/feed_routes.py Normal file
View File

@ -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", ""))

1063
app/modules/files_routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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,
})

View File

@ -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/<int:nid>/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)

View File

@ -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"))

View File

@ -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/<int:project_id>")
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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/members/<int:user_id>/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/<int:project_id>/members/<int:user_id>/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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/posts/<int:post_id>/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/<int:project_id>/posts/<int:post_id>/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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/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/<int:project_id>/tasks/<int:task_id>/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/<int:project_id>/tasks/<int:task_id>/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/<int:project_id>/tasks/<int:task_id>/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})

View File

@ -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/<int:event_id>/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/<int:event_id>/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")

View File

@ -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/<int:workshop_id>")
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/<int:workshop_id>/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/<int:workshop_id>/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/<int:workshop_id>/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/<int:workshop_id>/assistants/<int:uid>/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/<int:workshop_id>/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/<int:workshop_id>/sessions/<int:session_id>/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/<int:workshop_id>/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/<int:workshop_id>/materials/<int:material_id>/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/<int:workshop_id>/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/<int:workshop_id>/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/<int:workshop_id>/posts/<int:post_id>/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/<int:workshop_id>/posts/<int:post_id>/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/<int:workshop_id>/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/<int:workshop_id>/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/<int:workshop_id>/attendance/<int:reg_id>", 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))

1160
app/static/main.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,100 +1,71 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Редактирование события — Morozovka Portal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
{% extends "base.html" %}
{% block title %}Редактирование события — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; }
h2 { color: #58a6ff; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; }
.form-row { display: flex; gap: 15px; margin-bottom: 15px; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
textarea { resize: vertical; min-height: 100px; }
button[type="submit"] { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
button[type="submit"]:hover { background: #2ea043; }
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/admin/schedule" class="back-link">← Назад к расписанию</a>
<h2>Редактирование события</h2>
<div class="card">
<form method="POST">
<div class="form-row">
<div class="form-group">
<label>Название</label>
<input type="text" name="title" value="{{ event.title }}" required>
</div>
<div class="form-group" style="flex: 0.5;">
<label>Тип</label>
<select name="event_type">
<option value="lecture" {% if event.event_type == 'lecture' %}selected{% endif %}>Лекция</option>
<option value="workshop" {% if event.event_type == 'workshop' %}selected{% endif %}>Мастер-класс</option>
<option value="meeting" {% if event.event_type == 'meeting' %}selected{% endif %}>Встреча</option>
<option value="personal" {% if event.event_type == 'personal' %}selected{% endif %}>Личное</option>
</select>
</div>
</div>
{% block head %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
{% endblock %}
<div class="form-row">
<div class="form-group">
<label>Место</label>
<input type="text" name="location" value="{{ event.location or '' }}">
</div>
</div>
{% block content %}
<a href="/admin/schedule" style="display:inline-block;margin-bottom:20px;color:#8b949e;text-decoration:none;">← Назад к расписанию</a>
<h2 class="page-title">Редактирование события</h2>
<div class="form-row">
<div class="form-group">
<label>Начало</label>
<input type="text" class="flatpickr" name="start_time" required value="{{ event.start_time|msk_strftime('%Y-%m-%d %H:%M') }}">
</div>
<div class="form-group">
<label>Конец</label>
<input type="text" class="flatpickr" name="end_time" required value="{{ event.end_time|msk_strftime('%Y-%m-%d %H:%M') }}">
</div>
</div>
<div class="form-row" style="align-items: center;">
<div class="form-group" style="flex-direction: row; gap: 10px; align-items: center;">
<input type="checkbox" name="is_public" id="is_public" {% if event.is_public %}checked{% endif %}>
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное событие</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ event.description or '' }}</textarea>
</div>
</div>
<button type="submit">Сохранить изменения</button>
</form>
<div class="card">
<form method="POST">
<div class="form-group">
<label>Название</label>
<input type="text" name="title" value="{{ event.title }}" required>
</div>
</div>
<div class="form-group">
<label>Тип</label>
<select name="event_type">
<option value="lecture" {% if event.event_type == 'lecture' %}selected{% endif %}>Лекция</option>
<option value="workshop" {% if event.event_type == 'workshop' %}selected{% endif %}>Мастер-класс</option>
<option value="meeting" {% if event.event_type == 'meeting' %}selected{% endif %}>Встреча</option>
<option value="personal" {% if event.event_type == 'personal' %}selected{% endif %}>Личное</option>
</select>
</div>
<div class="form-group">
<label>Место</label>
<input type="text" name="location" value="{{ event.location or '' }}">
</div>
<div class="form-row">
<div class="form-group">
<label>Начало</label>
<input type="text" class="flatpickr" name="start_time" required value="{{ event.start_time|msk_strftime('%Y-%m-%d %H:%M') }}">
</div>
<div class="form-group">
<label>Конец</label>
<input type="text" class="flatpickr" name="end_time" required value="{{ event.end_time|msk_strftime('%Y-%m-%d %H:%M') }}">
</div>
</div>
<div class="form-group" style="flex-direction: row; gap: 10px; align-items: center;">
<input type="checkbox" name="is_public" id="is_public" {% if event.is_public %}checked{% endif %}>
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное событие</label>
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ event.description or '' }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить изменения</button>
<a href="/admin/schedule" class="btn btn-default">Отмена</a>
</div>
</form>
</div>
{% endblock %}
<script>
flatpickr(".flatpickr", {
enableTime: true,
dateFormat: "Y-m-d H:i",
time_24hr: true,
locale: "ru",
minuteIncrement: 15
});
</script>
</body>
</html>
{% block scripts %}
<script>
flatpickr(".flatpickr", {
enableTime: true,
dateFormat: "Y-m-d H:i",
time_24hr: true,
locale: "ru",
minuteIncrement: 15
});
</script>
{% endblock %}

View File

@ -1,100 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Редактирование события — Morozovka Portal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; }
h2 { color: #58a6ff; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; }
.form-row { display: flex; gap: 15px; margin-bottom: 15px; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
textarea { resize: vertical; min-height: 100px; }
button[type="submit"] { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
button[type="submit"]:hover { background: #2ea043; }
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/admin/schedule" class="back-link">← Назад к расписанию</a>
<h2>Редактирование события</h2>
<div class="card">
<form method="POST">
<div class="form-row">
<div class="form-group">
<label>Название</label>
<input type="text" name="title" value="{{ event.title }}" required>
</div>
<div class="form-group" style="flex: 0.5;">
<label>Тип</label>
<select name="event_type">
<option value="lecture" {% if event.event_type == 'lecture' %}selected{% endif %}>Лекция</option>
<option value="workshop" {% if event.event_type == 'workshop' %}selected{% endif %}>Мастер-класс</option>
<option value="meeting" {% if event.event_type == 'meeting' %}selected{% endif %}>Встреча</option>
<option value="personal" {% if event.event_type == 'personal' %}selected{% endif %}>Личное</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Место</label>
<input type="text" name="location" value="{{ event.location or '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Начало</label>
<input type="text" class="flatpickr" name="start_time" required value="{{ event.start_time.strftime('%Y-%m-%d %H:%M') }}">
</div>
<div class="form-group">
<label>Конец</label>
<input type="text" class="flatpickr" name="end_time" required value="{{ event.end_time.strftime('%Y-%m-%d %H:%M') }}">
</div>
</div>
<div class="form-row" style="align-items: center;">
<div class="form-group" style="flex-direction: row; gap: 10px; align-items: center;">
<input type="checkbox" name="is_public" id="is_public" {% if event.is_public %}checked{% endif %}>
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное событие</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ event.description or '' }}</textarea>
</div>
</div>
<button type="submit">Сохранить изменения</button>
</form>
</div>
</div>
<script>
flatpickr(".flatpickr", {
enableTime: true,
dateFormat: "Y-m-d H:i",
time_24hr: true,
locale: "ru",
minuteIncrement: 15
});
</script>
</body>
</html>

View File

@ -1,199 +1,186 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Управление расписанием — Morozovka Portal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
{% extends "base.html" %}
{% block title %}Управление расписанием — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 1000px; margin: 0 auto; }
.header-actions { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; }
h2 { color: #58a6ff; margin: 0; }
h3 { color: #8b949e; margin-top: 30px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.form-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: flex-end; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
textarea { resize: vertical; min-height: 60px; }
.quick-btns { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.quick-btn { padding: 8px 15px; border-radius: 5px; border: none; cursor: pointer; font-weight: bold; font-size: 0.85rem; color: white; transition: opacity 0.2s; }
.quick-btn:hover { opacity: 0.8; }
.btn-green { background: #238636; }
.btn-yellow { background: #d29922; }
.btn-red { background: #da3633; }
.btn-grey { background: #30363d; }
{% block head %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
<style>
.header-actions { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
h2 { color: #58a6ff; margin: 0; }
h3 { color: #8b949e; margin-top: 0; font-size: 1rem; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.form-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: flex-end; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
textarea { resize: vertical; min-height: 60px; }
.quick-btns { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.quick-btn { padding: 8px 15px; border-radius: 5px; border: none; cursor: pointer; font-weight: bold; font-size: 0.85rem; color: white; transition: opacity 0.2s; }
.quick-btn:hover { opacity: 0.8; }
.btn-green { background: #238636; }
.btn-yellow { background: #d29922; }
.btn-red { background: #da3633; }
.btn-grey { background: #30363d; }
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; }
a.edit-btn { display: inline-block; background: #1f6feb; padding: 6px 10px; font-size: 0.8rem; color: white; border-radius: 5px; margin-right: 5px; text-decoration: none; }
a.edit-btn:hover { background: #388bfd; text-decoration: none; }
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; }
.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-import:hover { background: #bc8cff; text-decoration: none; }
.btn-export { background: #21262d; border: 1px solid #30363d; }
.btn-export:hover { background: #30363d; text-decoration: none; }
</style>
{% 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; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<div class="header-actions">
<h2>Управление расписанием</h2>
<div class="action-buttons">
<a href="/admin/schedule/import" class="btn-import">📥 Импорт (.ics)</a>
<a href="/schedule/export.ics" class="btn-export">📤 Экспорт (.ics)</a>
</div>
</div>
{% block content %}
<div class="header-actions">
<h2>Управление расписанием</h2>
<div class="action-buttons">
<a href="/admin/schedule/import" class="btn-import">📥 Импорт (.ics)</a>
<a href="/schedule/export.ics" class="btn-export">📤 Экспорт (.ics)</a>
</div>
</div>
<div class="card">
<h3>Текущий статус</h3>
<div class="quick-btns">
<button type="button" class="quick-btn btn-green" onclick="setFastStatus('on_place', 0, 'На месте')">✅ Я на месте</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 5, 'Задерживаюсь на 5 мин')">⏳ Опоздаю на 5 мин</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 15, 'Задерживаюсь на 15 мин')">⏳ Опоздаю на 15 мин</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 30, 'Задерживаюсь на 30 мин')">⏳ Опоздаю на 30 мин</button>
<button type="button" class="quick-btn btn-grey" onclick="setFastStatus('away', 0, 'Вне вуза')">🚶 Вне вуза</button>
</div>
<form action="/admin/schedule/status" method="POST" id="status-form">
<div class="form-row">
<div class="form-group">
<label>Статус (вручную)</label>
<select name="status" id="status-select">
<option value="free" {% if status.status == 'free' %}selected{% endif %}>Свободен / Вне расписания</option>
<option value="on_place" {% if status.status == 'on_place' %}selected{% endif %}>На месте</option>
<option value="delayed" {% if status.status == 'delayed' %}selected{% endif %}>Опаздываю</option>
<option value="away" {% if status.status == 'away' %}selected{% endif %}>Вне вуза</option>
<option value="cancelled" {% if status.status == 'cancelled' %}selected{% endif %}>Отменено</option>
</select>
</div>
<div class="form-group" style="flex: 0.5;">
<label>Опоздание (мин)</label>
<input type="number" name="delay_minutes" id="delay-input" value="{{ status.delay_minutes or 0 }}" min="0">
</div>
<div class="form-group">
<label>Сообщение для студентов</label>
<input type="text" name="message" id="message-input" value="{{ status.message or '' }}" placeholder="Например: задерживаюсь на 15 минут">
</div>
<button type="submit">Обновить</button>
</div>
</form>
</div>
<div class="card">
<h3>Добавить новое событие</h3>
<form action="/admin/schedule/event/add" method="POST">
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label>Название</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>Тип</label>
<select name="event_type">
<option value="lecture">Лекция</option>
<option value="workshop">Мастер-класс</option>
<option value="meeting">Встреча</option>
<option value="personal">Личное</option>
</select>
</div>
<div class="form-group">
<label>Место</label>
<input type="text" name="location">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Начало</label>
<input type="text" class="flatpickr" name="start_time" required placeholder="Выберите дату и время">
</div>
<div class="form-group">
<label>Конец</label>
<input type="text" class="flatpickr" name="end_time" required placeholder="Выберите дату и время">
</div>
<div class="form-group" style="flex: 0.5; flex-direction: row; align-items: center; gap: 10px;">
<input type="checkbox" name="is_public" checked id="is_public">
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Описание</label>
<textarea name="description"></textarea>
</div>
<button type="submit" style="height: 60px;">Добавить</button>
</div>
</form>
</div>
<div class="card">
<h3>Последние 50 событий</h3>
<table>
<thead>
<tr>
<th>Дата и время</th>
<th>Название</th>
<th>Тип</th>
<th>Публичное</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.start_time|msk_strftime('%d.%m.%Y %H:%M') }}<br><small style="color:#8b949e">до {{ event.end_time|msk_strftime('%H:%M') }}</small></td>
<td><strong>{{ event.title }}</strong><br><small style="color:#8b949e">{{ event.location or '' }}</small></td>
<td>{{ event.event_type }}</td>
<td>{{ 'Да' if event.is_public else 'Нет' }}</td>
<td style="display:flex; gap: 5px;">
<a href="/admin/schedule/event/{{ event.id }}/edit" class="edit-btn">✎ Ред.</a>
<form action="/admin/schedule/event/{{ event.id }}/delete" method="POST" onsubmit="return confirm('Удалить событие?');">
<button type="submit" class="danger">✕ Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align: center; color: #8b949e;">Нет событий</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3>Текущий статус</h3>
<div class="quick-btns">
<button type="button" class="quick-btn btn-green" onclick="setFastStatus('on_place', 0, 'На месте')">✅ Я на месте</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 5, 'Задерживаюсь на 5 мин')">⏳ Опоздаю на 5 мин</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 15, 'Задерживаюсь на 15 мин')">⏳ Опоздаю на 15 мин</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 30, 'Задерживаюсь на 30 мин')">⏳ Опоздаю на 30 мин</button>
<button type="button" class="quick-btn btn-grey" onclick="setFastStatus('away', 0, 'Вне вуза')">🚶 Вне вуза</button>
</div>
<script>
flatpickr(".flatpickr", {
enableTime: true,
dateFormat: "Y-m-d H:i",
time_24hr: true,
locale: "ru",
minuteIncrement: 15,
defaultDate: "today"
});
<form action="/admin/schedule/status" method="POST" id="status-form">
<div class="form-row">
<div class="form-group">
<label>Статус (вручную)</label>
<select name="status" id="status-select">
<option value="free" {% if status.status == 'free' %}selected{% endif %}>Свободен / Вне расписания</option>
<option value="on_place" {% if status.status == 'on_place' %}selected{% endif %}>На месте</option>
<option value="delayed" {% if status.status == 'delayed' %}selected{% endif %}>Опаздываю</option>
<option value="away" {% if status.status == 'away' %}selected{% endif %}>Вне вуза</option>
<option value="cancelled" {% if status.status == 'cancelled' %}selected{% endif %}>Отменено</option>
</select>
</div>
<div class="form-group" style="flex: 0.5;">
<label>Опоздание (мин)</label>
<input type="number" name="delay_minutes" id="delay-input" value="{{ status.delay_minutes or 0 }}" min="0">
</div>
<div class="form-group">
<label>Сообщение для студентов</label>
<input type="text" name="message" id="message-input" value="{{ status.message or '' }}" placeholder="Например: задерживаюсь на 15 минут">
</div>
<button type="submit">Обновить</button>
</div>
</form>
</div>
function setFastStatus(status, minutes, message) {
document.getElementById('status-select').value = status;
document.getElementById('delay-input').value = minutes;
document.getElementById('message-input').value = message;
document.getElementById('status-form').submit();
}
</script>
</body>
</html>
<div class="card">
<h3>Добавить новое событие</h3>
<form action="/admin/schedule/event/add" method="POST">
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label>Название</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>Тип</label>
<select name="event_type">
<option value="lecture">Лекция</option>
<option value="workshop">Мастер-класс</option>
<option value="meeting">Встреча</option>
<option value="personal">Личное</option>
</select>
</div>
<div class="form-group">
<label>Место</label>
<input type="text" name="location">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Начало</label>
<input type="text" class="flatpickr" name="start_time" required placeholder="Выберите дату и время">
</div>
<div class="form-group">
<label>Конец</label>
<input type="text" class="flatpickr" name="end_time" required placeholder="Выберите дату и время">
</div>
<div class="form-group" style="flex: 0.5; flex-direction: row; align-items: center; gap: 10px;">
<input type="checkbox" name="is_public" checked id="is_public">
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Описание</label>
<textarea name="description"></textarea>
</div>
<button type="submit" style="height: 60px;">Добавить</button>
</div>
</form>
</div>
<div class="card">
<h3>Последние 50 событий</h3>
<table>
<thead>
<tr>
<th>Дата и время</th>
<th>Название</th>
<th>Тип</th>
<th>Публичное</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.start_time|msk_strftime('%d.%m.%Y %H:%M') }}<br><small style="color:#8b949e">до {{ event.end_time|msk_strftime('%H:%M') }}</small></td>
<td><strong>{{ event.title }}</strong><br><small style="color:#8b949e">{{ event.location or '' }}</small></td>
<td>{{ event.event_type }}</td>
<td>{{ 'Да' if event.is_public else 'Нет' }}</td>
<td style="display:flex; gap: 5px;">
<a href="/admin/schedule/event/{{ event.id }}/edit" class="edit-btn">✎ Ред.</a>
<form action="/admin/schedule/event/{{ event.id }}/delete" method="POST" onsubmit="return confirm('Удалить событие?');">
<button type="submit" class="danger">✕ Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align: center; color: #8b949e;">Нет событий</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
<script>
flatpickr(".flatpickr", {
enableTime: true,
dateFormat: "Y-m-d H:i",
time_24hr: true,
locale: "ru",
minuteIncrement: 15,
defaultDate: "today"
});
function setFastStatus(status, minutes, message) {
document.getElementById('status-select').value = status;
document.getElementById('delay-input').value = minutes;
document.getElementById('message-input').value = message;
document.getElementById('status-form').submit();
}
</script>
{% endblock %}

View File

@ -1,199 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Управление расписанием — Morozovka Portal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 1000px; margin: 0 auto; }
.header-actions { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; }
h2 { color: #58a6ff; margin: 0; }
h3 { color: #8b949e; margin-top: 30px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.form-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: flex-end; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
textarea { resize: vertical; min-height: 60px; }
.quick-btns { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.quick-btn { padding: 8px 15px; border-radius: 5px; border: none; cursor: pointer; font-weight: bold; font-size: 0.85rem; color: white; transition: opacity 0.2s; }
.quick-btn:hover { opacity: 0.8; }
.btn-green { background: #238636; }
.btn-yellow { background: #d29922; }
.btn-red { background: #da3633; }
.btn-grey { background: #30363d; }
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; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<div class="header-actions">
<h2>Управление расписанием</h2>
<div class="action-buttons">
<a href="/admin/schedule/import" class="btn-import">📥 Импорт (.ics)</a>
<a href="/schedule/export.ics" class="btn-export">📤 Экспорт (.ics)</a>
</div>
</div>
<div class="card">
<h3>Текущий статус</h3>
<div class="quick-btns">
<button type="button" class="quick-btn btn-green" onclick="setFastStatus('on_place', 0, 'На месте')">✅ Я на месте</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 5, 'Задерживаюсь на 5 мин')">⏳ Опоздаю на 5 мин</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 15, 'Задерживаюсь на 15 мин')">⏳ Опоздаю на 15 мин</button>
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 30, 'Задерживаюсь на 30 мин')">⏳ Опоздаю на 30 мин</button>
<button type="button" class="quick-btn btn-grey" onclick="setFastStatus('away', 0, 'Вне вуза')">🚶 Вне вуза</button>
</div>
<form action="/admin/schedule/status" method="POST" id="status-form">
<div class="form-row">
<div class="form-group">
<label>Статус (вручную)</label>
<select name="status" id="status-select">
<option value="free" {% if status.status == 'free' %}selected{% endif %}>Свободен / Вне расписания</option>
<option value="on_place" {% if status.status == 'on_place' %}selected{% endif %}>На месте</option>
<option value="delayed" {% if status.status == 'delayed' %}selected{% endif %}>Опаздываю</option>
<option value="away" {% if status.status == 'away' %}selected{% endif %}>Вне вуза</option>
<option value="cancelled" {% if status.status == 'cancelled' %}selected{% endif %}>Отменено</option>
</select>
</div>
<div class="form-group" style="flex: 0.5;">
<label>Опоздание (мин)</label>
<input type="number" name="delay_minutes" id="delay-input" value="{{ status.delay_minutes or 0 }}" min="0">
</div>
<div class="form-group">
<label>Сообщение для студентов</label>
<input type="text" name="message" id="message-input" value="{{ status.message or '' }}" placeholder="Например: задерживаюсь на 15 минут">
</div>
<button type="submit">Обновить</button>
</div>
</form>
</div>
<div class="card">
<h3>Добавить новое событие</h3>
<form action="/admin/schedule/event/add" method="POST">
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label>Название</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>Тип</label>
<select name="event_type">
<option value="lecture">Лекция</option>
<option value="workshop">Мастер-класс</option>
<option value="meeting">Встреча</option>
<option value="personal">Личное</option>
</select>
</div>
<div class="form-group">
<label>Место</label>
<input type="text" name="location">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Начало</label>
<input type="text" class="flatpickr" name="start_time" required placeholder="Выберите дату и время">
</div>
<div class="form-group">
<label>Конец</label>
<input type="text" class="flatpickr" name="end_time" required placeholder="Выберите дату и время">
</div>
<div class="form-group" style="flex: 0.5; flex-direction: row; align-items: center; gap: 10px;">
<input type="checkbox" name="is_public" checked id="is_public">
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Описание</label>
<textarea name="description"></textarea>
</div>
<button type="submit" style="height: 60px;">Добавить</button>
</div>
</form>
</div>
<div class="card">
<h3>Последние 50 событий</h3>
<table>
<thead>
<tr>
<th>Дата и время</th>
<th>Название</th>
<th>Тип</th>
<th>Публичное</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.start_time.strftime('%d.%m.%Y %H:%M') }}<br><small style="color:#8b949e">до {{ event.end_time.strftime('%H:%M') }}</small></td>
<td><strong>{{ event.title }}</strong><br><small style="color:#8b949e">{{ event.location or '' }}</small></td>
<td>{{ event.event_type }}</td>
<td>{{ 'Да' if event.is_public else 'Нет' }}</td>
<td style="display:flex; gap: 5px;">
<a href="/admin/schedule/event/{{ event.id }}/edit" class="edit-btn">✎ Ред.</a>
<form action="/admin/schedule/event/{{ event.id }}/delete" method="POST" onsubmit="return confirm('Удалить событие?');">
<button type="submit" class="danger">✕ Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align: center; color: #8b949e;">Нет событий</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
flatpickr(".flatpickr", {
enableTime: true,
dateFormat: "Y-m-d H:i",
time_24hr: true,
locale: "ru",
minuteIncrement: 15,
defaultDate: "today"
});
function setFastStatus(status, minutes, message) {
document.getElementById('status-select').value = status;
document.getElementById('delay-input').value = minutes;
document.getElementById('message-input').value = message;
document.getElementById('status-form').submit();
}
</script>
</body>
</html>

View File

@ -1,54 +1,42 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Импорт расписания — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; }
h2 { color: #58a6ff; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 30px; margin-top: 20px; }
.upload-area { border: 2px dashed #30363d; border-radius: 8px; padding: 40px; text-align: center; margin-bottom: 20px; transition: border-color 0.2s; }
.upload-area:hover { border-color: #58a6ff; }
input[type="file"] { display: none; }
.btn-file { display: inline-block; padding: 10px 20px; background: #21262d; color: white; border: 1px solid #30363d; border-radius: 5px; cursor: pointer; font-weight: bold; }
.btn-file:hover { background: #30363d; }
button[type="submit"] { width: 100%; padding: 12px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 1rem; }
button[type="submit"]:hover { background: #2ea043; }
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
.hint { color: #8b949e; font-size: 0.85rem; margin-top: 15px; line-height: 1.5; }
</style>
</head>
<body>
<div class="container">
<a href="/admin/schedule" class="back-link">← Назад к расписанию</a>
<h2>Импорт расписания</h2>
<div class="card">
<form action="/admin/schedule/import" method="POST" enctype="multipart/form-data">
<div class="upload-area">
<label for="file-upload" class="btn-file">Выбрать .ics файл</label>
<input id="file-upload" type="file" name="file" accept=".ics" required onchange="document.getElementById('file-name').textContent = this.files[0].name">
<p id="file-name" style="margin-top: 15px; color: #3fb950;">Файл не выбран</p>
</div>
<button type="submit">Загрузить и импортировать</button>
</form>
<div class="hint">
<b>Как выгрузить расписание из Google Calendar?</b><br>
1. Зайдите в Google Calendar на компьютере.<br>
2. Нажмите на три точки рядом с нужным календарем.<br>
3. Выберите «Настройки и общий доступ».<br>
4. Прокрутите вниз до раздела «Экспорт календаря» и скачайте ZIP-архив.<br>
5. Распакуйте архив и загрузите сюда файл с расширением .ics.
</div>
{% extends "base.html" %}
{% block title %}Импорт расписания — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
{% block head %}
<style>
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 30px; margin-top: 20px; }
.upload-area { border: 2px dashed #30363d; border-radius: 8px; padding: 40px; text-align: center; margin-bottom: 20px; transition: border-color 0.2s; }
.upload-area:hover { border-color: #58a6ff; }
input[type="file"] { display: none; }
.btn-file { display: inline-block; padding: 10px 20px; background: #21262d; color: white; border: 1px solid #30363d; border-radius: 5px; cursor: pointer; font-weight: bold; }
.btn-file:hover { background: #30363d; }
button[type="submit"] { width: 100%; padding: 12px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 1rem; }
button[type="submit"]:hover { background: #2ea043; }
.hint { color: #8b949e; font-size: 0.85rem; margin-top: 15px; line-height: 1.5; }
</style>
{% endblock %}
{% block content %}
<a href="/admin/schedule" style="display:inline-block;margin-bottom:20px;color:#8b949e;text-decoration:none;">← Назад к расписанию</a>
<h2 class="page-title">Импорт расписания</h2>
<div class="card">
<form action="/admin/schedule/import" method="POST" enctype="multipart/form-data">
<div class="upload-area">
<label for="file-upload" class="btn-file">Выбрать .ics файл</label>
<input id="file-upload" type="file" name="file" accept=".ics" required onchange="document.getElementById('file-name').textContent = this.files[0].name">
<p id="file-name" style="margin-top: 15px; color: #3fb950;">Файл не выбран</p>
</div>
<button type="submit">Загрузить и импортировать</button>
</form>
<div class="hint">
<b>Как выгрузить расписание из Google Calendar?</b><br>
1. Зайдите в Google Calendar на компьютере.<br>
2. Нажмите на три точки рядом с нужным календарем.<br>
3. Выберите «Настройки и общий доступ».<br>
4. Прокрутите вниз до раздела «Экспорт календаря» и скачайте ZIP-архив.<br>
5. Распакуйте архив и загрузите сюда файл с расширением .ics.
</div>
</body>
</html>
</div>
{% endblock %}

View File

@ -1,82 +1,114 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Админка — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
h2 { color: #58a6ff; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; background: #161b22; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; font-size: 0.85rem; text-transform: uppercase; }
tr:hover { background: #21262d; }
.badge { padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; }
.role-admin { background: #da3633; color: white; }
.role-professor { background: #f0883e; color: white; }
.role-lead { background: #d29922; color: white; }
.role-member { background: #238636; color: white; }
.role-student { background: #1f6feb; color: white; }
.role-guest { background: #30363d; color: white; }
select { padding: 6px; border-radius: 4px; background: #0d1117; color: white; border: 1px solid #30363d; }
button { padding: 6px 12px; background: #238636; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #2ea043; }
button.danger { background: #da3633; }
button.danger:hover { background: #f85149; }
a { color: #58a6ff; text-decoration: none; }
.actions-cell { display: flex; gap: 10px; align-items: center; }
</style>
</head>
<body>
<h2>Управление пользователями</h2>
<p><a href="/">← На главную</a></p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>ФИО</th>
<th>Email</th>
<th>Роль</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.full_name or '—' }}</td>
<td>{{ u.email or '—' }}</td>
<td>
<span class="badge role-{{ u.role }}">{{ u.role }}</span>
</td>
<td>
<div class="actions-cell">
<!-- Форма смены роли -->
<form action="/admin/users/{{ u.id }}/role" method="POST" style="display:flex; gap: 5px;">
<select name="role">
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
<option value="professor" {% if u.role == 'professor' %}selected{% endif %}>professor</option>
<option value="lead" {% if u.role == 'lead' %}selected{% endif %}>lead</option>
<option value="member" {% if u.role == 'member' %}selected{% endif %}>member</option>
<option value="student" {% if u.role == 'student' %}selected{% endif %}>student</option>
<option value="guest" {% if u.role == 'guest' %}selected{% endif %}>guest</option>
</select>
<button type="submit">Сменить</button>
</form>
{% extends "base.html" %}
{% block title %}Управление пользователями — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
<!-- Форма удаления (нельзя удалить себя) -->
{% if u.id != session.get('user_id') %}
<form action="/admin/users/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя {{ u.username }}?');">
<button type="submit" class="danger">Удалить</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
{% block head %}
<style>
.search-row { display: flex; gap: 10px; align-items: center; margin: 20px 0; flex-wrap: wrap; }
.search-row input { padding: 8px 12px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; min-width: 240px; font-family: inherit; }
.search-row input:focus { outline: none; border-color: #58a6ff; }
.search-row a { color: #8b949e; font-size: 0.9rem; text-decoration: none; }
.search-row a:hover { color: #f85149; }
.search-row button.secondary { padding: 8px 16px; background: #21262d; color: #e6edf3; border: 1px solid #30363d; border-radius: 5px; cursor: pointer; font-weight: 600; }
.search-row button.secondary:hover { background: #30363d; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; background: #161b22; border-radius: 8px; overflow: hidden; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; font-size: 0.85rem; text-transform: uppercase; }
tr:hover { background: #21262d; }
.badge { padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; }
.role-admin { background: #da3633; color: white; }
.role-professor { background: #f0883e; color: white; }
.role-lead { background: #d29922; color: white; }
.role-member { background: #238636; color: white; }
.role-student { background: #1f6feb; color: white; }
.role-guest { background: #30363d; color: white; }
.status-pill { padding: 2px 8px; border-radius: 10px; font-size: 0.75rem; }
.status-active { background: rgba(63,185,80,0.2); color: #3fb950; }
.status-inactive { background: rgba(248,81,73,0.2); color: #f85149; }
select { padding: 6px; border-radius: 4px; background: #0d1117; color: white; border: 1px solid #30363d; font-family: inherit; }
button { padding: 6px 12px; background: #238636; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
button:hover { background: #2ea043; }
button.danger { background: #da3633; }
button.danger:hover { background: #f85149; }
button.secondary { background: #21262d; border: 1px solid #30363d; }
button.secondary:hover { background: #30363d; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.actions-cell { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.inactive { opacity: 0.55; }
</style>
{% endblock %}
{% block content %}
<h1 class="page-title">Управление пользователями</h1>
<form class="search-row" method="GET">
<input type="text" name="q" placeholder="Поиск по логину / ФИО / email" value="{{ q or '' }}">
<button type="submit" class="secondary">Найти</button>
{% if q %}<a href="/admin/users">✕ Сбросить</a>{% endif %}
</form>
<table>
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>ФИО</th>
<th>Email</th>
<th>Роль</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr class="{% if not u.is_active %}inactive{% endif %}">
<td>{{ u.id }}</td>
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.full_name or '—' }}</td>
<td>{{ u.email or '—' }}</td>
<td>
<span class="badge role-{{ u.role }}">{{ u.role }}</span>
</td>
<td>
{% if u.is_active %}<span class="status-pill status-active">активен</span>
{% else %}<span class="status-pill status-inactive">заблокирован</span>{% endif %}
</td>
<td>
<div class="actions-cell">
<form action="/admin/users/{{ u.id }}/role" method="POST" style="display:flex; gap:5px;">
<select name="role">
{% for r in ['admin','professor','lead','member','student','guest'] %}
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
<button type="submit">Сменить</button>
</form>
{% if u.id != session.get('user_id') %}
<form action="/admin/users/{{ u.id }}/toggle_active" method="POST" style="display:inline;">
<button type="submit" class="secondary">
{% if u.is_active %}Заблокировать{% else %}Разблокировать{% endif %}
</button>
</form>
<form action="/admin/users/{{ u.id }}/delete" method="POST" style="display:inline;"
onsubmit="return confirm('Удалить {{ u.username }} НАВСЕГДА? Блокировка безопаснее.');">
<button type="submit" class="danger">Удалить</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" style="text-align: center; color: #8b949e; padding: 24px;">Пользователи не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

58
app/templates/base.html Normal file
View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Morozovka Platform{% endblock %}</title>
<link rel="stylesheet" href="/static/main.css">
{% block head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
{% if session.get('user_id') %}
<a href="/dashboard" class="{% if request.path == '/dashboard' %}active{% endif %}">🏠 Мои</a>
{% endif %}
<a href="/schedule" class="{% if request.path.startswith('/schedule') %}active{% endif %}">Расписание</a>
<a href="/projects" class="{% if request.path.startswith('/projects') %}active{% endif %}">Проекты</a>
<a href="/workshops" class="{% if request.path.startswith('/workshops') %}active{% endif %}">Мастер-классы</a>
<a href="/files" class="{% if request.path.startswith('/files') %}active{% endif %}">Файлы</a>
<a href="/feed" class="{% if request.path.startswith('/feed') %}active{% endif %}">Лента</a>
</div>
<div class="topnav-actions">
{% if session.get('username') %}
{% if session.get('role') in ['admin', 'professor'] %}
<a href="/admin/schedule" class="btn btn-outline btn-sm">⚙️ Админ</a>
{% endif %}
<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>
{% else %}
<a href="/login" class="btn btn-default">Войти</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
{% endif %}
</div>
</nav>
{% block outer_content %}
<main class="{% block main_class %}container{% endblock %}">
{% block content %}{% endblock %}
</main>
{% endblock %}
<footer class="footer">
Morozovka Platform ·
<a href="/schedule">Расписание</a> ·
<a href="/projects">Проекты</a> ·
<a href="/workshops">Мастер-классы</a> ·
<a href="/files">Файлы</a> ·
<a href="/feed">Лента</a>
{% if session.get('username') %} · <a href="/onboarding">Тур по платформе</a>{% endif %}
</footer>
<script src="/static/notif_bell.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,459 @@
{% extends "base.html" %}
{% block title %}Дашборд — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
{% block head %}
<style>
.dashboard-hero {
background: linear-gradient(135deg, #161b22 0%, #1c2128 100%);
border: 1px solid #30363d;
border-radius: 12px;
padding: 24px 32px;
margin-bottom: 24px;
}
.dashboard-hero h1 { margin: 0 0 6px; font-size: 24px; }
.dashboard-hero .greeting { color: #8b949e; font-size: 14px; }
.widgets {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
}
.widget {
background: #161b22;
border: 1px solid #30363d;
border-radius: 10px;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.widget-header {
padding: 14px 18px;
border-bottom: 1px solid #30363d;
display: flex;
justify-content: space-between;
align-items: center;
background: #1c2128;
}
.widget-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e6edf3;
display: flex;
align-items: center;
gap: 8px;
}
.widget-badge {
background: #30363d;
color: #e6edf3;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.widget-badge.alert { background: #da3633; color: #fff; }
.widget-header a {
color: #58a6ff;
font-size: 12px;
text-decoration: none;
}
.widget-header a:hover { text-decoration: underline; }
.widget-body { padding: 8px 18px; flex: 1; }
.widget-empty {
padding: 24px 18px;
color: #8b949e;
font-size: 13px;
text-align: center;
}
/* Event item */
.dash-event {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #21262d;
}
.dash-event:last-child { border-bottom: none; }
.dash-event-time {
color: #58a6ff;
font-weight: 600;
font-size: 13px;
min-width: 90px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
.dash-event-body { flex: 1; min-width: 0; }
.dash-event-title {
color: #e6edf3;
font-weight: 500;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-event-meta { color: #8b949e; font-size: 11px; margin-top: 2px; }
/* Task item */
.dash-task {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid #21262d;
}
.dash-task:last-child { border-bottom: none; }
.dash-task-priority {
width: 4px;
height: 32px;
border-radius: 2px;
flex-shrink: 0;
}
.dash-task-priority.critical { background: #f85149; }
.dash-task-priority.high { background: #d29922; }
.dash-task-priority.medium { background: #58a6ff; }
.dash-task-priority.low { background: #8b949e; }
.dash-task-body { flex: 1; min-width: 0; }
.dash-task-title {
color: #e6edf3;
font-weight: 500;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
display: block;
}
.dash-task-title:hover { color: #58a6ff; text-decoration: none; }
.dash-task-meta { color: #8b949e; font-size: 11px; margin-top: 2px; }
/* Project item */
.dash-project {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #21262d;
text-decoration: none;
}
.dash-project:last-child { border-bottom: none; }
.dash-project:hover { text-decoration: none; }
.dash-project:hover .dash-project-title { color: #58a6ff; }
.dash-project-info { flex: 1; min-width: 0; }
.dash-project-title {
color: #e6edf3;
font-weight: 500;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash-project-meta { color: #8b949e; font-size: 11px; margin-top: 2px; }
.dash-project-role {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
background: #30363d;
color: #c9d1d9;
}
/* Notification item */
.dash-notif {
display: block;
padding: 8px 0;
border-bottom: 1px solid #21262d;
text-decoration: none;
color: inherit;
}
.dash-notif:last-child { border-bottom: none; }
.dash-notif:hover { text-decoration: none; }
.dash-notif:hover .dash-notif-title { color: #58a6ff; }
.dash-notif-title { color: #e6edf3; font-size: 13px; font-weight: 500; }
.dash-notif-body { color: #8b949e; font-size: 12px; margin-top: 2px; }
.dash-notif-time { color: #6e7681; font-size: 11px; margin-top: 2px; }
/* Material item */
.dash-material {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid #21262d;
}
.dash-material:last-child { border-bottom: none; }
.dash-material-icon { font-size: 18px; flex-shrink: 0; }
.dash-material-body { flex: 1; min-width: 0; }
.dash-material-title { color: #e6edf3; font-size: 13px; font-weight: 500; }
.dash-material-title a { color: inherit; text-decoration: none; }
.dash-material-title a:hover { color: #58a6ff; text-decoration: none; }
.dash-material-meta { color: #8b949e; font-size: 11px; margin-top: 2px; }
/* Quick links */
.quick-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
padding: 16px;
}
.quick-link {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 14px 8px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 8px;
color: #e6edf3;
text-decoration: none;
transition: all .15s;
font-size: 12px;
text-align: center;
}
.quick-link:hover {
border-color: #58a6ff;
background: #161b22;
text-decoration: none;
color: #58a6ff;
}
.quick-link-icon { font-size: 22px; }
/* Commit item */
.dash-commit-repo {
font-size: 11px;
font-weight: 700;
color: #58a6ff;
padding: 4px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dash-commit-repo:not(:first-child) {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #21262d;
}
.dash-commit { padding: 4px 0; }
.dash-commit-sha {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 11px;
color: #58a6ff;
background: rgba(88,166,255,0.1);
padding: 1px 6px;
border-radius: 3px;
margin-right: 4px;
}
.dash-commit-msg { color: #c9d1d9; font-size: 12px; }
.dash-commit-meta { color: #8b949e; font-size: 11px; margin-top: 2px; margin-left: 2px; }
@media (max-width: 768px) {
.dashboard-hero { padding: 16px 20px; }
.dashboard-hero h1 { font-size: 20px; }
.widgets { grid-template-columns: 1fr; }
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-hero">
<h1>Привет, {{ session.get('username') }}!</h1>
<div class="greeting">Твой персональный дашборд</div>
</div>
<div class="widgets">
<!-- 1. Ближайшие события -->
<div class="widget">
<div class="widget-header">
<h3>📅 Ближайшие события</h3>
<a href="/schedule">Все →</a>
</div>
{% if upcoming_events %}
<div class="widget-body">
{% for ev in upcoming_events %}
<div class="dash-event">
<div class="dash-event-time">{{ ev.start_time|msk_strftime('%d.%m %H:%M') }}</div>
<div class="dash-event-body">
<div class="dash-event-title">{{ ev.title }}</div>
{% if ev.location %}
<div class="dash-event-meta">📍 {{ ev.location }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="widget-empty">Ближайших событий нет</div>
{% endif %}
</div>
<!-- 2. Мои проекты -->
<div class="widget">
<div class="widget-header">
<h3>🗂 Мои проекты <span class="widget-badge">{{ my_projects|length }}</span></h3>
<a href="/projects">Все →</a>
</div>
{% if my_projects %}
<div class="widget-body">
{% for proj, member in my_projects %}
<a class="dash-project" href="/projects/{{ proj.id }}">
<div class="dash-project-info">
<div class="dash-project-title">{{ proj.title }}</div>
<div class="dash-project-meta">
{% if proj.status == 'idea' %}Идея
{% elif proj.status == 'in_progress' %}В работе
{% elif proj.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %}
</div>
</div>
<span class="dash-project-role">{{ member.role }}</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="widget-empty">Вы пока не в проектах</div>
{% endif %}
</div>
<!-- 3. Мои задачи -->
<div class="widget">
<div class="widget-header">
<h3>✅ Мои задачи <span class="widget-badge">{{ my_tasks|length }}</span></h3>
<a href="/projects">Все →</a>
</div>
{% if my_tasks %}
<div class="widget-body">
{% for task, proj in my_tasks %}
<div class="dash-task">
<div class="dash-task-priority {{ task.priority }}"></div>
<div class="dash-task-body">
<a class="dash-task-title" href="/projects/{{ proj.id }}/board">{{ task.title }}</a>
<div class="dash-task-meta">
{{ proj.title }}
{% if task.due_date %} · до {{ task.due_date|date_ru }}{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="widget-empty">Активных задач нет</div>
{% endif %}
</div>
<!-- 4. Непрочитанные уведомления -->
<div class="widget">
<div class="widget-header">
<h3>🔔 Уведомления {% if unread_count > 0 %}<span class="widget-badge alert">{{ unread_count }}</span>{% endif %}</h3>
<a href="/notifications">Все →</a>
</div>
{% if unread %}
<div class="widget-body">
{% for n in unread %}
<a class="dash-notif" href="{{ n.link_url or '/notifications' }}">
<div class="dash-notif-title">{{ n.title }}</div>
{% if n.body %}<div class="dash-notif-body">{{ n.body }}</div>{% endif %}
<div class="dash-notif-time">{{ n.created_at|msk_strftime('%d.%m.%Y %H:%M') }}</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="widget-empty">Нет непрочитанных уведомлений</div>
{% endif %}
</div>
<!-- 5. Новые материалы МК -->
<div class="widget">
<div class="widget-header">
<h3>📚 Материалы МК <span class="widget-badge">{{ new_materials|length }}</span></h3>
<a href="/workshops">Все →</a>
</div>
{% if new_materials %}
<div class="widget-body">
{% for material, ws in new_materials %}
<div class="dash-material">
<div class="dash-material-icon">
{% if material.external_url %}🔗{% else %}📎{% endif %}
</div>
<div class="dash-material-body">
<div class="dash-material-title">
{% if material.external_url %}
<a href="{{ material.external_url }}" target="_blank">{{ material.title }}</a>
{% elif material.file_id %}
<a href="/files/download/{{ material.file_id }}">{{ material.title }}</a>
{% else %}
{{ material.title }}
{% endif %}
</div>
<div class="dash-material-meta">{{ ws.title }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="widget-empty">Пока нет новых материалов</div>
{% endif %}
</div>
<!-- 6. Gitea-коммиты (только если есть данные) -->
{% if gitea_widget %}
<div class="widget">
<div class="widget-header">
<h3>💻 Последние коммиты</h3>
<a href="/projects">Все →</a>
</div>
<div class="widget-body">
{% for repo in gitea_widget %}
<div class="dash-commit-repo">{{ repo.project_title }}</div>
{% for c in repo.commits %}
<div class="dash-commit">
<span class="dash-commit-sha">{{ c.sha_short }}</span>
<span class="dash-commit-msg">{{ c.message }}</span>
<div class="dash-commit-meta">{{ c.author }}{% if c.date %} · {{ c.date[:16].replace('T', ' ') }}{% endif %}</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>
{% endif %}
<!-- 7. Быстрые ссылки -->
<div class="widget">
<div class="widget-header">
<h3>⚡ Быстрые ссылки</h3>
</div>
<div class="quick-links">
<a href="/schedule" class="quick-link">
<span class="quick-link-icon">📅</span>
Расписание
</a>
<a href="/projects" class="quick-link">
<span class="quick-link-icon">🗂</span>
Проекты
</a>
<a href="/workshops" class="quick-link">
<span class="quick-link-icon">🎓</span>
Мастер-классы
</a>
<a href="/feed" class="quick-link">
<span class="quick-link-icon">📰</span>
Лента
</a>
<a href="/files" class="quick-link">
<span class="quick-link-icon">📁</span>
Файлы
</a>
<a href="/onboarding" class="quick-link">
<span class="quick-link-icon">🎯</span>
Тур
</a>
</div>
</div>
</div>
{% endblock %}

75
app/templates/feed.html Normal file
View File

@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Лента — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Лента</h1>
<p class="page-subtitle">Все новости из мастер-классов и проектов в одном потоке</p>
</div>
</div>
<div class="filters">
<span class="filter-label">Источник:</span>
<a href="?source=all{% if q %}&q={{ q }}{% endif %}" class="filter-chip {% if current_source == 'all' %}active{% endif %}">Все</a>
<a href="?source=workshops{% if q %}&q={{ q }}{% endif %}" class="filter-chip {% if current_source == 'workshops' %}active{% endif %}">🎓 Мастер-классы</a>
<a href="?source=projects{% if q %}&q={{ q }}{% endif %}" class="filter-chip {% if current_source == 'projects' %}active{% endif %}">🗂 Проекты</a>
<form method="GET" class="search-form" style="margin-left: auto;">
{% if current_source != 'all' %}<input type="hidden" name="source" value="{{ current_source }}">{% endif %}
<input type="text" name="q" class="search-input" placeholder="Поиск по постам..." value="{{ q }}">
<button type="submit" class="search-btn">🔍</button>
{% if q %}<a href="?source={{ current_source }}" class="filter-chip filter-chip-clear">✕</a>{% endif %}
</form>
</div>
{% if items %}
<div class="muted" style="font-size:12px;margin-bottom:12px;">
Показано {{ items|length }}{% if total_count > items|length %} из {{ total_count }}{% endif %}
</div>
{% for item in items %}
<div class="post-card{% if item.is_pinned %} pinned{% endif %}">
<div class="post-head">
<div style="flex:1;min-width:0;">
<div class="post-title">
{% if item.is_pinned %}<span class="pin-mark">📌</span>{% endif %}
<span class="source-pill {{ item.kind }}">{{ item.kind_icon }} {{ item.kind_label }}</span>
{{ item.title }}
</div>
<div class="post-meta">
в <a href="{{ item.source_url }}">{{ item.source_title }}</a>
· {{ item.author_name }} <span class="dim">@{{ item.author_username }}</span>
· {{ item.created_at|msk_strftime('%d.%m.%Y %H:%M') }}
</div>
</div>
</div>
{% if item.body %}<div class="post-body">{{ item.body }}</div>{% endif %}
{% if item.attachments %}
<div class="post-attachments">
{% for fid in item.attachments %}
{% set f = files_map.get(fid) %}
{% if f %}
{% if f.original_name|is_image %}
<div class="post-image-wrap">
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
<div class="post-image-name">{{ f.original_name }} · {{ (f.size_bytes/1024)|round(1) }} КБ</div>
</div>
{% else %}
<div class="post-attachment">{{ f.original_name|file_icon }} <a href="/files/download/{{ f.id }}">{{ f.original_name }}</a> <span class="dim">({{ (f.size_bytes/1024)|round(1) }} КБ)</span></div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<h3>{% if q %}Ничего не найдено{% else %}Пока нет постов{% endif %}</h3>
<p>{% if q %}Попробуйте другой запрос или <a href="?source={{ current_source }}">сбросьте поиск</a>.{% else %}Когда появятся новости в мастер-классах или проектах — они появятся здесь.{% endif %}</p>
</div>
{% endif %}
{% endblock %}

View File

@ -1,83 +1,553 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Файлы — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 1000px; margin: 0 auto; }
h1 { color: #58a6ff; margin-top: 0; }
.upload-box { background: #161b22; border: 1px dashed #30363d; border-radius: 8px; padding: 30px; text-align: center; margin-bottom: 30px; }
.upload-box input[type="file"] { display: none; }
.upload-label { display: inline-block; padding: 10px 20px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; cursor: pointer; font-weight: bold; margin-bottom: 15px; }
.upload-label:hover { background: #30363d; }
.upload-btn { background: #238636; color: white; border: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; cursor: pointer; }
.upload-btn:hover { background: #2ea043; }
#file-name-display { margin-left: 15px; color: #8b949e; font-size: 0.9rem; }
{% extends "base.html" %}
{% block title %}Файлы — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
table { width: 100%; border-collapse: collapse; background: #161b22; border-radius: 8px; overflow: hidden; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; font-size: 0.85rem; text-transform: uppercase; }
tr:hover { background: #21262d; }
.btn-download { color: #58a6ff; text-decoration: none; font-weight: bold; }
.btn-download:hover { text-decoration: underline; }
.btn-delete { background: none; border: none; color: #da3633; cursor: pointer; font-weight: bold; }
.btn-delete:hover { text-decoration: underline; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<h1>Файлохранилище</h1>
<div class="upload-box">
<form action="/files/upload" method="POST" enctype="multipart/form-data">
<label for="file-upload" class="upload-label">Выбрать файл</label>
<input id="file-upload" type="file" name="file" required onchange="document.getElementById('file-name-display').textContent = this.files[0].name">
<span id="file-name-display">Файл не выбран</span>
<br><br>
<button type="submit" class="upload-btn">Загрузить (макс 100 МБ)</button>
</form>
</div>
{% block content %}
<table>
<thead>
<tr>
<th>Имя файла</th>
<th>Размер</th>
<th>Загрузил</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for db_file, uploader_name in files %}
<tr>
<td><strong>{{ db_file.original_name }}</strong></td>
<td>{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ</td>
<td>{{ uploader_name }}</td>
<td>{{ db_file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}</td>
<td style="display: flex; gap: 15px;">
<a href="/files/download/{{ db_file.id }}" class="btn-download">Скачать</a>
{% if session.get('role') == 'admin' or db_file.uploaded_by == session.get('user_id') %}
<form action="/files/{{ db_file.id }}/delete" method="POST" onsubmit="return confirm('Удалить файл?');">
<button type="submit" class="btn-delete">Удалить</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="text-align: center; color: #8b949e;">Файлов пока нет. Загрузите первый!</td>
</tr>
{% endfor %}
</tbody>
</table>
{# ═══════════════════════════════════════════════════════════════════ #}
{# ROOT: пространства + файлы без пространства #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% if view == 'root' %}
<div class="files-hero">
<div class="files-hero-left">
<h1>📁 Файлохранилище</h1>
<p class="subtitle">Пространства, папки, файлы с версиями и правами доступа</p>
</div>
</body>
</html>
<div class="files-hero-actions">
{% if can_create_space %}
<a href="/files/space/new" class="btn btn-primary">+ Пространство</a>
{% endif %}
<a href="/files/search" class="btn btn-default">🔍 Поиск</a>
</div>
</div>
<div class="files-section">
<div class="files-section-head">
<h2>🗂 Пространства <span class="count">{{ spaces|length }}</span></h2>
</div>
{% if spaces %}
<div class="space-grid-v2">
{% for item in spaces %}
{% set sp = item.space %}
<a href="/files/space/{{ sp.id }}" class="space-card-v2">
<div class="space-card-v2-head">
<div class="space-card-v2-icon">{% if sp.project_id %}📦{% else %}📂{% endif %}</div>
<span class="space-card-v2-vis {{ sp.visibility }}">
{% if sp.visibility == 'public' %}Публичное
{% elif sp.visibility == 'internal' %}Внутреннее
{% else %}Приватное{% endif %}
</span>
</div>
<div class="space-card-v2-title">{{ sp.name }}</div>
{% if sp.description %}
<div class="space-card-v2-desc">{{ sp.description }}</div>
{% else %}
<div class="space-card-v2-desc" style="font-style:italic;opacity:.5;">Без описания</div>
{% endif %}
<div class="space-card-v2-meta">
<span>📄 {{ item.file_count }} файлов</span>
{% if sp.project_id %}<span>🔗 Проект</span>{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="files-empty">
<div class="icon">🗂</div>
<h3>Пространств пока нет</h3>
<p>{% if can_create_space %}Создайте первое — например, «Документы команды» или «Материалы проекта».{% else %}Попросите lead/admin создать пространство.{% endif %}</p>
{% if can_create_space %}<a href="/files/space/new" class="btn btn-primary">Создать пространство</a>{% endif %}
</div>
{% endif %}
</div>
<div class="files-section">
<div class="files-section-head">
<h2>📎 Без пространства <span class="count">{{ loose_files|length }}</span></h2>
</div>
<p style="color:var(--text-muted);font-size:13px;margin:0 0 14px;">
Старые файлы и вложения постов. Можно переместить в пространство кнопкой ↔.
</p>
<div class="upload-zone"
id="upload-zone-loose"
data-space-id=""
data-folder-id=""
role="button"
tabindex="0"
aria-label="Загрузить файлы без пространства">
<input type="file" id="upload-input-loose" multiple>
<div class="upload-zone-icon">☁️</div>
<div class="upload-zone-title">Перетащите файлы сюда или кликните</div>
<div class="upload-zone-hint">Можно выбрать несколько файлов · суммарно до 100 МБ</div>
</div>
{% if loose_files %}
<div class="file-grid-v2">
{% for f, uploader in loose_files %}
<div class="file-tile-v2">
<div class="file-tile-v2-preview">
{% if f.original_name|is_image %}
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
{% else %}
<div class="file-tile-v2-icon-big">{{ f.original_name|file_icon }}</div>
{% endif %}
</div>
<div class="file-tile-v2-body">
<div class="file-tile-v2-name" title="{{ f.original_name }}">{{ f.original_name }}</div>
<div class="file-tile-v2-meta">
<span>{{ f.size_bytes|format_size }}</span>
<span>·</span>
<span>{{ uploader }}</span>
<span>·</span>
<span>{{ f.uploaded_at|msk_strftime('%d.%m.%Y') }}</span>
</div>
</div>
<div class="file-tile-v2-actions">
<a href="/files/download/{{ f.id }}" title="Скачать">⬇</a>
<a href="/files/{{ f.id }}/move" title="Переместить">↔</a>
{% if session.get('role') == 'admin' or f.uploaded_by == session.get('user_id') %}
<form action="/files/{{ f.id }}/delete" method="POST" style="display:inline;" onsubmit="return confirm('Удалить файл?');">
<input type="hidden" name="next" value="/files">
<button type="submit" class="danger" title="Удалить">✕</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="files-empty">
<div class="icon">📎</div>
<h3>Файлов без пространства нет</h3>
<p>Перетащите файл в зону выше или создайте пространство.</p>
</div>
{% endif %}
</div>
{% endif %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# SPACE / FOLDER #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% if view in ('space', 'folder') %}
<div class="breadcrumbs-bar">
{% for cr in breadcrumbs %}
{% if not loop.last %}
<a href="{{ cr.url }}">{{ cr.name }}</a>
<span class="sep">/</span>
{% else %}
<span class="current">{{ cr.name }}</span>
{% endif %}
{% endfor %}
</div>
<div class="files-hero">
<div class="files-hero-left">
<h1>{% if view == 'space' %}📂 {{ space.name }}{% else %}📁 {{ folder.name }}{% endif %}</h1>
<p class="subtitle">
{% if view == 'space' and space.description %}{{ space.description }}
{% elif view == 'space' %}Пространство · {{ space.visibility }}
{% else %}Папка в «{{ space.name }}»{% endif %}
</p>
</div>
<div class="files-hero-actions">
{% if perms.can_upload %}
<button type="button" class="btn btn-default" onclick="document.getElementById('new-folder-modal').classList.add('open')">+ Папка</button>
{% endif %}
{% if view == 'space' and perms.can_manage %}
<a href="/files/space/{{ space.id }}/acl" class="btn btn-default">🔐 Права</a>
<a href="/files/space/{{ space.id }}/edit" class="btn btn-default">⚙ Настройки</a>
{% endif %}
{% if view == 'folder' and perms.can_manage %}
<a href="/files/folder/{{ folder.id }}/acl" class="btn btn-default">🔐 Права папки</a>
{% endif %}
<a href="/files/search{% if view == 'space' %}?space_id={{ space.id }}{% endif %}" class="btn btn-default">🔍 Поиск</a>
{% if view == 'space' and (perms.is_owner or session.get('role') == 'admin') %}
<form action="/files/space/{{ space.id }}/delete" method="POST" style="display:inline;"
onsubmit="return confirm('Удалить пространство «{{ space.name }}» со всем содержимым? Это необратимо.');">
<button type="submit" class="btn btn-danger">🗑 Удалить</button>
</form>
{% endif %}
{% if view == 'folder' and perms.can_delete %}
<form action="/files/folder/{{ folder.id }}/delete" method="POST" style="display:inline;"
onsubmit="return confirm('Удалить папку «{{ folder.name }}» со всем содержимым?');">
<button type="submit" class="btn btn-danger">🗑 Удалить</button>
</form>
{% endif %}
</div>
</div>
{% if perms.can_upload %}
<div class="upload-zone"
id="upload-zone-current"
data-space-id="{{ space.id }}"
data-folder-id="{% if view == 'folder' %}{{ folder.id }}{% endif %}"
role="button"
tabindex="0"
aria-label="Загрузить файлы в {% if view == 'folder' %}{{ folder.name }}{% else %}{{ space.name }}{% endif %}">
<input type="file" id="upload-input-current" multiple>
<div class="upload-zone-icon">☁️</div>
<div class="upload-zone-title">Перетащите файлы сюда или кликните</div>
<div class="upload-zone-hint">
Загрузится в {% if view == 'folder' %}«{{ folder.name }}»{% else %}корень пространства{% endif %} · суммарно до 100 МБ
</div>
</div>
{% endif %}
{% if folders %}
<div class="files-section">
<div class="files-section-head">
<h2>📁 Папки <span class="count">{{ folders|length }}</span></h2>
</div>
<div class="folder-grid-v2">
{% for fd in folders %}
<div class="folder-tile-v2-wrap">
<a href="/files/folder/{{ fd.id }}" class="folder-tile-v2">
<span class="folder-tile-v2-icon">📁</span>
<div class="folder-tile-v2-info">
<div class="folder-tile-v2-name">{{ fd.name }}</div>
<div class="folder-tile-v2-meta">{{ fd.created_at|msk_strftime('%d.%m.%Y') }}</div>
</div>
</a>
{% if perms.can_delete or perms.can_manage %}
<form action="/files/folder/{{ fd.id }}/delete" method="POST" class="folder-delete-form"
onsubmit="return confirm('Удалить папку «{{ fd.name }}» со всем содержимым?');">
<button type="submit" class="folder-delete-btn" title="Удалить папку">✕</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="files-section">
<div class="files-section-head">
<h2>📄 Файлы <span class="count">{{ files|length }}</span></h2>
</div>
{% if files %}
<div class="file-grid-v2">
{% for f, uploader in files %}
<div class="file-tile-v2">
<div class="file-tile-v2-preview">
{% if f.original_name|is_image %}
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
{% else %}
<div class="file-tile-v2-icon-big">{{ f.original_name|file_icon }}</div>
{% endif %}
<span class="file-tile-v2-badge">v{{ f.version }}</span>
</div>
<div class="file-tile-v2-body">
<div class="file-tile-v2-name" title="{{ f.original_name }}">{{ f.original_name }}</div>
{% if f.description %}
<div class="file-tile-v2-desc">{{ f.description }}</div>
{% endif %}
<div class="file-tile-v2-meta">
<span>{{ f.size_bytes|format_size }}</span>
<span>·</span>
<span>{{ uploader }}</span>
<span>·</span>
<span>{{ f.uploaded_at|msk_strftime('%d.%m.%Y') }}</span>
</div>
</div>
<div class="file-tile-v2-actions">
<a href="/files/download/{{ f.id }}" title="Скачать">⬇</a>
<a href="/files/{{ f.id }}/history" title="История версий">🕘</a>
<a href="/files/{{ f.id }}/move" title="Переместить">↔</a>
<a href="/files/{{ f.id }}/edit" title="Редактировать">✎</a>
{% if perms.can_delete or session.get('role') == 'admin' %}
<form action="/files/{{ f.id }}/delete" method="POST" style="display:inline;" onsubmit="return confirm('Удалить файл и все версии?');">
<input type="hidden" name="next" value="{% if view == 'folder' %}/files/folder/{{ folder.id }}{% else %}/files/space/{{ space.id }}{% endif %}">
<button type="submit" class="danger" title="Удалить">✕</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="files-empty">
<div class="icon">📄</div>
<h3>{% if folders %}В этой папке файлов нет{% else %}Пусто{% endif %}</h3>
<p>{% if perms.can_upload %}Перетащите файл в зону выше или кликните по ней.{% else %}Файлы появятся, когда их загрузят.{% endif %}</p>
</div>
{% endif %}
</div>
{% if perms.can_upload %}
<div class="modal-overlay" id="new-folder-modal">
<div class="modal">
<h2>📁 Новая папка</h2>
<form action="/files/folder/new" method="POST">
<input type="hidden" name="space_id" value="{{ space.id }}">
<input type="hidden" name="parent_id" value="{% if view == 'folder' %}{{ folder.id }}{% endif %}">
<div class="form-group">
<label>Название *</label>
<input type="text" name="name" required maxlength="200" autofocus>
</div>
<div class="form-group">
<label>Описание</label>
<input type="text" name="description" maxlength="300">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('new-folder-modal').classList.remove('open')">Отмена</button>
<button type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</div>
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; width: 100%; max-width: 450px; padding: 24px; }
.modal h2 { margin: 0 0 18px; color: var(--accent); font-size: 18px; }
.modal .form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.modal label { color: var(--text-muted); font-size: 13px; font-weight: 600; }
.modal input { padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: inherit; }
.modal input:focus { outline: none; border-color: var(--accent); }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
</style>
{% endif %}
{% endif %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# SEARCH #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% if view == 'search' %}
<div class="files-hero">
<div class="files-hero-left">
<h1>🔍 Поиск файлов</h1>
<p class="subtitle">{% if space_scope %}в пространстве «{{ space_scope.name }}»{% else %}по всем пространствам{% endif %}</p>
</div>
<div class="files-hero-actions">
<a href="/files" class="btn btn-default">← Все файлы</a>
</div>
</div>
<div class="filters" style="margin-bottom:20px;">
<form method="GET" action="/files/search" style="display:flex;gap:8px;flex:1;flex-wrap:wrap;">
{% if space_scope %}<input type="hidden" name="space_id" value="{{ space_scope.id }}">{% endif %}
<input type="text" name="q" value="{{ q }}" placeholder="Имя, описание или теги..." class="search-input" style="flex:1;min-width:220px;" autofocus>
<button type="submit" class="btn btn-primary">Найти</button>
{% if q %}<a href="/files/search{% if space_scope %}?space_id={{ space_scope.id }}{% endif %}" class="filter-chip filter-chip-clear">✕ Сбросить</a>{% endif %}
</form>
</div>
{% if q %}
<div class="muted" style="font-size:13px;margin-bottom:14px;">Найдено: <strong style="color:var(--text);">{{ results|length }}</strong></div>
{% endif %}
{% if results %}
<div class="file-grid-v2">
{% for f, uploader in results %}
<div class="file-tile-v2">
<div class="file-tile-v2-preview">
{% if f.original_name|is_image %}
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
{% else %}
<div class="file-tile-v2-icon-big">{{ f.original_name|file_icon }}</div>
{% endif %}
{% if f.version and f.version > 1 %}<span class="file-tile-v2-badge">v{{ f.version }}</span>{% endif %}
</div>
<div class="file-tile-v2-body">
<div class="file-tile-v2-name" title="{{ f.original_name }}">{{ f.original_name }}</div>
{% if f.tags %}
<div class="file-tile-v2-desc" style="color:#58a6ff;">#{{ f.tags }}</div>
{% elif f.description %}
<div class="file-tile-v2-desc">{{ f.description }}</div>
{% endif %}
<div class="file-tile-v2-meta">
<span>{{ f.size_bytes|format_size }}</span>
<span>·</span>
<span>{{ uploader }}</span>
<span>·</span>
<span>{{ f.uploaded_at|msk_strftime('%d.%m.%Y') }}</span>
</div>
</div>
<div class="file-tile-v2-actions">
<a href="/files/download/{{ f.id }}" title="Скачать">⬇</a>
{% if f.space_id %}
<a href="/files/{{ f.id }}/history" title="История">🕘</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% elif q %}
<div class="files-empty">
<div class="icon">🔍</div>
<h3>Ничего не найдено</h3>
<p>Попробуйте другой запрос или <a href="/files">вернитесь к списку</a>.</p>
</div>
{% else %}
<div class="files-empty">
<div class="icon">🔍</div>
<h3>Введите запрос</h3>
<p>Поиск по имени файла, описанию и тегам.</p>
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
'use strict';
const MAX_TOTAL_BYTES = 100 * 1024 * 1024; // 100 МБ суммарно
function formatBytes(n) {
if (n < 1024) return n + ' Б';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' КБ';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' МБ';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' ГБ';
}
function uploadFiles(files, spaceId, folderId, zone) {
const arr = Array.from(files || []);
if (arr.length === 0) return;
// ── Проверка суммарного размера ──────────────────────────────
const totalBytes = arr.reduce(function(s, f) { return s + (f.size || 0); }, 0);
if (totalBytes > MAX_TOTAL_BYTES) {
alert(
'Суммарный размер выбранных файлов (' + formatBytes(totalBytes) + ') ' +
'превышает лимит 100 МБ.\n\n' +
'Выбрано файлов: ' + arr.length + '\n' +
'Уберите часть файлов или загрузите их по очереди.'
);
return;
}
// ── UI: блокируем зону и показываем прогресс ─────────────────
const titleEl = zone.querySelector('.upload-zone-title');
const hintEl = zone.querySelector('.upload-zone-hint');
const originalTitle = titleEl ? titleEl.textContent : '';
const originalHint = hintEl ? hintEl.textContent : '';
zone.classList.add('uploading');
zone.style.pointerEvents = 'none';
let done = 0;
let failed = 0;
function updateProgress() {
if (titleEl) {
titleEl.textContent = 'Загрузка ' + done + ' из ' + arr.length + '…';
}
if (hintEl) {
hintEl.textContent = 'Суммарно: ' + formatBytes(totalBytes);
}
}
updateProgress();
// ── Загружаем по одному (каждый POST ≤ 100 МБ гарантирован) ──
arr.forEach(function(file) {
const fd = new FormData();
fd.append('file', file);
if (spaceId) fd.append('space_id', spaceId);
if (folderId) fd.append('folder_id', folderId);
fetch('/files/upload', {
method: 'POST',
body: fd,
credentials: 'same-origin'
}).then(function(r) {
if (!r.ok) failed++;
}).catch(function() {
failed++;
}).finally(function() {
done++;
updateProgress();
if (done === arr.length) {
zone.classList.remove('uploading');
zone.style.pointerEvents = '';
if (failed > 0) {
alert(
'Загружено: ' + (arr.length - failed) + ' из ' + arr.length + '.\n' +
'Не удалось загрузить: ' + failed + '.'
);
}
if (titleEl) titleEl.textContent = originalTitle;
if (hintEl) hintEl.textContent = originalHint;
if (failed === 0) {
location.reload();
} else {
// даём возможность увидеть ошибку
setTimeout(function() { location.reload(); }, 1500);
}
}
});
});
}
// ── Обработчик для каждой upload-зоны ─────────────────────────────
document.querySelectorAll('.upload-zone').forEach(function(zone) {
const input = zone.querySelector('input[type="file"]');
if (!input) return;
const spaceId = zone.dataset.spaceId || '';
const folderId = zone.dataset.folderId || '';
// Клик по зоне → открыть диалог
zone.addEventListener('click', function(e) {
if (zone.classList.contains('uploading')) return;
// если клик пришёлся на input (что маловероятно — display:none) — не мешаем
if (e.target === input) return;
input.click();
});
// Клавиатура: Enter / Space
zone.addEventListener('keydown', function(e) {
if (zone.classList.contains('uploading')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
input.click();
}
});
// Выбор через диалог
input.addEventListener('change', function() {
if (input.files && input.files.length > 0) {
uploadFiles(input.files, spaceId, folderId, zone);
input.value = '';
}
});
// Drag & drop
['dragenter', 'dragover'].forEach(function(evt) {
zone.addEventListener(evt, function(e) {
e.preventDefault();
e.stopPropagation();
if (zone.classList.contains('uploading')) return;
zone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(function(evt) {
zone.addEventListener(evt, function(e) {
e.preventDefault();
e.stopPropagation();
zone.classList.remove('dragover');
});
});
zone.addEventListener('drop', function(e) {
if (zone.classList.contains('uploading')) return;
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length > 0) {
uploadFiles(files, spaceId, folderId, zone);
}
});
});
})();
</script>
{% endblock %}

View File

@ -1,83 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Файлы — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 1000px; margin: 0 auto; }
h1 { color: #58a6ff; margin-top: 0; }
.upload-box { background: #161b22; border: 1px dashed #30363d; border-radius: 8px; padding: 30px; text-align: center; margin-bottom: 30px; }
.upload-box input[type="file"] { display: none; }
.upload-label { display: inline-block; padding: 10px 20px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; cursor: pointer; font-weight: bold; margin-bottom: 15px; }
.upload-label:hover { background: #30363d; }
.upload-btn { background: #238636; color: white; border: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; cursor: pointer; }
.upload-btn:hover { background: #2ea043; }
#file-name-display { margin-left: 15px; color: #8b949e; font-size: 0.9rem; }
table { width: 100%; border-collapse: collapse; background: #161b22; border-radius: 8px; overflow: hidden; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; font-size: 0.85rem; text-transform: uppercase; }
tr:hover { background: #21262d; }
.btn-download { color: #58a6ff; text-decoration: none; font-weight: bold; }
.btn-download:hover { text-decoration: underline; }
.btn-delete { background: none; border: none; color: #da3633; cursor: pointer; font-weight: bold; }
.btn-delete:hover { text-decoration: underline; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<h1>Файлохранилище</h1>
<div class="upload-box">
<form action="/files/upload" method="POST" enctype="multipart/form-data">
<label for="file-upload" class="upload-label">Выбрать файл</label>
<input id="file-upload" type="file" name="file" required onchange="document.getElementById('file-name-display').textContent = this.files[0].name">
<span id="file-name-display">Файл не выбран</span>
<br><br>
<button type="submit" class="upload-btn">Загрузить (макс 100 МБ)</button>
</form>
</div>
<table>
<thead>
<tr>
<th>Имя файла</th>
<th>Размер</th>
<th>Загрузил</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for db_file, uploader_name in files %}
<tr>
<td><strong>{{ db_file.original_name }}</strong></td>
<td>{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ</td>
<td>{{ uploader_name }}</td>
<td>{{ db_file.uploaded_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td style="display: flex; gap: 15px;">
<a href="/files/download/{{ db_file.id }}" class="btn-download">Скачать</a>
{% if session.get('role') == 'admin' or db_file.uploaded_by == session.get('user_id') %}
<form action="/files/{{ db_file.id }}/delete" method="POST" onsubmit="return confirm('Удалить файл?');">
<button type="submit" class="btn-delete">Удалить</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="text-align: center; color: #8b949e;">Файлов пока нет. Загрузите первый!</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,197 @@
{% extends "base.html" %}
{% block title %}Права доступа — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
{% block content %}
{% if mode == 'space' %}
<div class="breadcrumbs-bar">
<a href="/files">Файлы</a>
<span class="sep">/</span>
<a href="/files/space/{{ space.id }}">{{ space.name }}</a>
<span class="sep">/</span>
<span class="current">Права доступа</span>
</div>
<div class="files-hero">
<div class="files-hero-left">
<h1>🔐 Права доступа</h1>
<p class="subtitle">Пространство «{{ space.name }}»</p>
</div>
<div class="files-hero-actions">
<a href="/files/space/{{ space.id }}" class="btn btn-default">← К пространству</a>
</div>
</div>
{% else %}
<div class="breadcrumbs-bar">
<a href="/files">Файлы</a>
<span class="sep">/</span>
<a href="/files/space/{{ space.id }}">{{ space.name }}</a>
<span class="sep">/</span>
<a href="/files/folder/{{ folder.id }}">{{ folder.name }}</a>
<span class="sep">/</span>
<span class="current">Права доступа</span>
</div>
<div class="files-hero">
<div class="files-hero-left">
<h1>🔐 Права доступа — папка</h1>
<p class="subtitle">«{{ folder.name }}» в пространстве «{{ space.name }}»</p>
</div>
<div class="files-hero-actions">
<a href="/files/folder/{{ folder.id }}" class="btn btn-default">← К папке</a>
</div>
</div>
{% endif %}
{% if mode == 'space' %}
<div class="form-card" style="margin-bottom:16px;">
<h3 style="margin-top:0;font-size:15px;">Владелец пространства</h3>
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-top:1px solid var(--border);">
<div>
<div style="color:var(--text);font-weight:600;font-size:14px;">{{ owner.full_name or owner.username }}</div>
<div style="color:var(--text-muted);font-size:12px;">@{{ owner.username }}</div>
</div>
<span style="padding:3px 10px;border-radius:10px;background:#a371f7;color:#fff;font-size:11px;font-weight:700;text-transform:uppercase;">Владелец</span>
</div>
<p style="color:var(--text-muted);font-size:12px;margin:8px 0 0;line-height:1.5;">
Владелец всегда имеет полные права. Роли ниже применяются к остальным пользователям. Если явных записей нет — действует <strong>видимость пространства</strong>: <code>{{ space.visibility }}</code>.
</p>
</div>
<div class="form-card" style="margin-bottom:20px;">
<h3 style="margin-top:0;font-size:15px;">Добавить / обновить доступ</h3>
<form action="/files/space/{{ space.id }}/acl/add" method="POST">
<div class="form-row">
<div class="form-group">
<label>Логин пользователя</label>
<input type="text" name="username" placeholder="username" required>
</div>
<div class="form-group">
<label>Роль</label>
<select name="role">
<option value="viewer">Viewer — просмотр и скачивание</option>
<option value="editor">Editor — загрузка, удаление, редактирование</option>
<option value="manager">Manager — + управление правами</option>
</select>
</div>
</div>
<div class="form-actions" style="margin-top:8px;padding-top:8px;">
<button type="submit" class="btn btn-primary">Добавить / обновить</button>
</div>
</form>
</div>
<div class="files-section">
<div class="files-section-head">
<h2>👥 Пользователи с доступом <span class="count">{{ acl_entries|length }}</span></h2>
</div>
{% if acl_entries %}
<table class="meta-table">
<thead>
<tr><th>Пользователь</th><th>Роль</th><th>Действия</th></tr>
</thead>
<tbody>
{% for entry, u in acl_entries %}
<tr>
<td>
<div style="font-weight:600;">{{ u.full_name or u.username }}</div>
<div style="color:var(--text-muted);font-size:12px;">@{{ u.username }}</div>
</td>
<td>
<span style="padding:3px 10px;border-radius:10px;font-size:11px;font-weight:700;text-transform:uppercase;
{% if entry.role == 'manager' %}background:#d29922;color:#000;
{% elif entry.role == 'editor' %}background:#1f6feb;color:#fff;
{% else %}background:#30363d;color:#e6edf3;{% endif %}">
{{ entry.role }}
</span>
</td>
<td>
<form action="/files/space/{{ space.id }}/acl/{{ entry.id }}/remove" method="POST" style="display:inline;" onsubmit="return confirm('Убрать доступ?');">
<button type="submit" class="btn btn-danger btn-sm">Удалить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="files-empty">
<div class="icon">👥</div>
<h3>Явных записей нет</h3>
<p>Действует видимость пространства: <code>{{ space.visibility }}</code>. Добавьте пользователей выше для гранулярного контроля.</p>
</div>
{% endif %}
</div>
{% else %}
<div class="form-card" style="margin-bottom:20px;">
<h3 style="margin-top:0;font-size:15px;">Добавить / обновить доступ на папку</h3>
<p style="color:var(--text-muted);font-size:13px;line-height:1.5;margin:0 0 14px;">
Права на папку <strong>перекрывают</strong> права пространства. Если для пользователя нет записи в папке — действуют права пространства.
</p>
<form action="/files/folder/{{ folder.id }}/acl/add" method="POST">
<div class="form-group">
<label>Логин пользователя</label>
<input type="text" name="username" placeholder="username" required>
</div>
<div class="form-group">
<label>Права</label>
<div style="display:flex;flex-wrap:wrap;gap:14px;margin-top:4px;">
<label style="display:flex;align-items:center;gap:6px;color:var(--text);font-size:13px;cursor:pointer;"><input type="checkbox" name="can_view" checked> 👁 Видеть</label>
<label style="display:flex;align-items:center;gap:6px;color:var(--text);font-size:13px;cursor:pointer;"><input type="checkbox" name="can_upload"> ⬆ Загружать</label>
<label style="display:flex;align-items:center;gap:6px;color:var(--text);font-size:13px;cursor:pointer;"><input type="checkbox" name="can_delete"> 🗑 Удалять</label>
<label style="display:flex;align-items:center;gap:6px;color:var(--text);font-size:13px;cursor:pointer;"><input type="checkbox" name="can_manage"> ⚙ Управлять правами</label>
</div>
</div>
<div class="form-actions" style="margin-top:8px;padding-top:8px;">
<button type="submit" class="btn btn-primary">Добавить / обновить</button>
</div>
</form>
</div>
<div class="files-section">
<div class="files-section-head">
<h2>👥 Пользователи с доступом на папку <span class="count">{{ acl_entries|length }}</span></h2>
</div>
{% if acl_entries %}
<table class="meta-table">
<thead>
<tr><th>Пользователь</th><th>Права</th><th>Действия</th></tr>
</thead>
<tbody>
{% for entry, u in acl_entries %}
<tr>
<td>
<div style="font-weight:600;">{{ u.full_name or u.username }}</div>
<div style="color:var(--text-muted);font-size:12px;">@{{ u.username }}</div>
</td>
<td>
<form action="/files/folder/{{ folder.id }}/acl/{{ entry.id }}/save" method="POST" style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;">
<label style="display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--text);cursor:pointer;"><input type="checkbox" name="can_view" {% if entry.can_view %}checked{% endif %}> 👁</label>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--text);cursor:pointer;"><input type="checkbox" name="can_upload" {% if entry.can_upload %}checked{% endif %}> ⬆</label>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--text);cursor:pointer;"><input type="checkbox" name="can_delete" {% if entry.can_delete %}checked{% endif %}> 🗑</label>
<label style="display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--text);cursor:pointer;"><input type="checkbox" name="can_manage" {% if entry.can_manage %}checked{% endif %}> ⚙</label>
<button type="submit" class="btn btn-default btn-sm">Сохранить</button>
</form>
</td>
<td>
<form action="/files/folder/{{ folder.id }}/acl/{{ entry.id }}/remove" method="POST" style="display:inline;" onsubmit="return confirm('Убрать доступ?');">
<button type="submit" class="btn btn-danger btn-sm">Удалить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="files-empty">
<div class="icon">👥</div>
<h3>Явных прав на папку нет</h3>
<p>Действуют права пространства «{{ space.name }}».</p>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -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' %}
<div class="files-hero">
<div class="files-hero-left">
<h1>📂 Новое пространство</h1>
<p class="subtitle">Пространство — это верхний уровень: по проекту, направлению или тематике</p>
</div>
</div>
<form method="POST" class="form-card">
{% if error %}<div style="color:#f85149;margin-bottom:12px;padding:8px 12px;background:rgba(248,81,73,0.1);border-radius:6px;">{{ error }}</div>{% endif %}
<div class="form-group">
<label>Название <span class="req">*</span></label>
<input type="text" name="name" value="{{ form.get('name', '') }}" required maxlength="200" autofocus>
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description" style="min-height:80px;">{{ form.get('description', '') }}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Видимость</label>
<select name="visibility">
<option value="internal" {% if form.get('visibility') == 'internal' or not form.get('visibility') %}selected{% endif %}>Внутреннее (все залогиненные)</option>
<option value="public" {% if form.get('visibility') == 'public' %}selected{% endif %}>Публичное (видят все)</option>
<option value="private" {% if form.get('visibility') == 'private' %}selected{% endif %}>Приватное (только по ACL)</option>
</select>
</div>
{% if projects %}
<div class="form-group">
<label>Привязать к проекту (опционально)</label>
<select name="project_id">
<option value="">— не привязано —</option>
{% for proj, member in projects %}
<option value="{{ proj.id }}" {% if form.get('project_id') == proj.id|string %}selected{% endif %}>{{ proj.title }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Создать</button>
<a href="/files" class="btn btn-default">Отмена</a>
</div>
</form>
{% elif mode == 'space_edit' %}
<div class="files-hero">
<div class="files-hero-left">
<h1>⚙ Настройки — {{ space.name }}</h1>
<p class="subtitle">Изменение названия, описания и видимости</p>
</div>
<div class="files-hero-actions">
<a href="/files/space/{{ space.id }}" class="btn btn-default">← Назад</a>
</div>
</div>
<form method="POST" class="form-card">
<div class="form-group">
<label>Название</label>
<input type="text" name="name" value="{{ space.name }}" required maxlength="200">
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description" style="min-height:80px;">{{ space.description or '' }}</textarea>
</div>
<div class="form-group">
<label>Видимость</label>
<select name="visibility">
<option value="internal" {% if space.visibility == 'internal' %}selected{% endif %}>Внутреннее (все залогиненные)</option>
<option value="public" {% if space.visibility == 'public' %}selected{% endif %}>Публичное (видят все)</option>
<option value="private" {% if space.visibility == 'private' %}selected{% endif %}>Приватное (только по ACL)</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/files/space/{{ space.id }}" class="btn btn-default">Отмена</a>
</div>
</form>
{% if perms.is_owner or session.get('role') == 'admin' %}
<div class="form-card" style="margin-top:16px;border-color:rgba(248,81,73,0.4);background:rgba(248,81,73,0.03);">
<h3 style="margin-top:0;color:#f85149;font-size:15px;">⚠ Опасная зона</h3>
<p style="color:var(--text-muted);font-size:13px;line-height:1.5;">
Удаление пространства удалит <strong style="color:#f85149;">все папки, файлы и историю версий</strong> внутри. Необратимо.
</p>
<form action="/files/space/{{ space.id }}/delete" method="POST" onsubmit="return confirm('Удалить пространство и ВСЕ файлы в нём? Это действие необратимо.');">
<button type="submit" class="btn btn-danger">Удалить пространство</button>
</form>
</div>
{% endif %}
{% elif mode == 'file_edit' %}
<div class="files-hero">
<div class="files-hero-left">
<h1>✎ Редактирование файла</h1>
<p class="subtitle">{{ db_file.original_name }} · {{ db_file.size_bytes|format_size }} · v{{ db_file.version }}</p>
</div>
<div class="files-hero-actions">
<a href="/files/download/{{ db_file.id }}" class="btn btn-primary">⬇ Скачать</a>
{% if db_file.space_id %}
<a href="/files/{{ db_file.id }}/history" class="btn btn-default">🕘 История</a>
{% endif %}
</div>
</div>
<form method="POST" class="form-card">
<div class="form-group">
<label>Файл</label>
<div style="padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;display:flex;align-items:center;gap:10px;">
<span style="font-size:22px;">{{ db_file.original_name|file_icon }}</span>
<span style="color:var(--text);font-family:ui-monospace,monospace;font-size:13px;">{{ db_file.original_name }}</span>
</div>
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description" style="min-height:80px;" placeholder="Что это за файл, для чего, кому пригодится...">{{ db_file.description or '' }}</textarea>
</div>
<div class="form-group">
<label>Теги</label>
<input type="text" name="tags" value="{{ db_file.tags or '' }}" placeholder="через, запятую">
<span class="hint">Помогают искать файл через /files/search</span>
</div>
{% if db_file.space_id and folders %}
<div class="form-group">
<label>Папка</label>
<select name="folder_id">
<option value="">— корень пространства —</option>
{% for fd in folders %}
<option value="{{ fd.id }}" {% if db_file.folder_id == fd.id %}selected{% endif %}>{{ fd.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{% if db_file.folder_id %}/files/folder/{{ db_file.folder_id }}{% elif db_file.space_id %}/files/space/{{ db_file.space_id }}{% else %}/files{% endif %}" class="btn btn-default">Отмена</a>
</div>
</form>
{% elif mode == 'file_move' %}
<div class="files-hero">
<div class="files-hero-left">
<h1>↔ Переместить файл</h1>
<p class="subtitle">{{ db_file.original_name }}</p>
</div>
</div>
<form method="POST" class="form-card">
<div class="form-group">
<label>Файл</label>
<div style="padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:22px;">{{ db_file.original_name|file_icon }}</span>
<span style="color:var(--text);font-family:ui-monospace,monospace;font-size:13px;">{{ db_file.original_name }}</span>
<span style="color:var(--text-muted);font-size:12px;margin-left:auto;">
{% if db_file.space_id %}в пространстве{% else %}без пространства{% endif %}
{% if db_file.folder_id %} · папка #{{ db_file.folder_id }}{% endif %}
· v{{ db_file.version }}
</span>
</div>
</div>
<div class="form-group">
<label>Куда переместить?</label>
<select name="space_id" id="move-space-select" onchange="updateFolderOptions()">
<option value="">— Без пространства (личное) —</option>
{% for sp in available_spaces %}
<option value="{{ sp.id }}" {% if db_file.space_id == sp.id %}selected{% endif %}>{{ sp.name }}</option>
{% endfor %}
</select>
{% if db_file.space_id %}
<span class="hint">⚠ При перемещении «Без пространства» версии будут сброшены. Блоб текущей версии сохранится.</span>
{% endif %}
</div>
<div class="form-group" id="move-folder-group">
<label>Папка (опционально)</label>
<select name="folder_id" id="move-folder-select">
<option value="">— корень пространства —</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Переместить</button>
<a href="{% if db_file.folder_id %}/files/folder/{{ db_file.folder_id }}{% elif db_file.space_id %}/files/space/{{ db_file.space_id }}{% else %}/files{% endif %}" class="btn btn-default">Отмена</a>
</div>
</form>
<script>
const FOLDERS_BY_SPACE = {{ (folders_by_space or {})|tojson }};
const CURRENT_FOLDER_ID = {{ db_file.folder_id or 'null' }};
function updateFolderOptions() {
const spaceSelect = document.getElementById('move-space-select');
const folderSelect = document.getElementById('move-folder-select');
const folderGroup = document.getElementById('move-folder-group');
const spaceId = spaceSelect.value;
folderSelect.innerHTML = '<option value="">— корень пространства —</option>';
if (!spaceId) {
folderGroup.style.display = 'none';
return;
}
folderGroup.style.display = '';
const folders = FOLDERS_BY_SPACE[spaceId] || [];
folders.forEach(function(f) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
if (String(f.id) === String(CURRENT_FOLDER_ID)) opt.selected = true;
folderSelect.appendChild(opt);
});
}
document.addEventListener('DOMContentLoaded', updateFolderOptions);
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}История версий — {{ db_file.original_name }}{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
{% block content %}
<div class="breadcrumbs-bar">
<a href="/files">Файлы</a>
{% if db_file.space_id %}
<span class="sep">/</span>
<a href="/files/space/{{ db_file.space_id }}">Пространство</a>
{% endif %}
{% if db_file.folder_id %}
<span class="sep">/</span>
<a href="/files/folder/{{ db_file.folder_id }}">Папка</a>
{% endif %}
<span class="sep">/</span>
<span class="current">История — {{ db_file.original_name }}</span>
</div>
<div class="files-hero">
<div class="files-hero-left">
<h1>🕘 История версий</h1>
<p class="subtitle">
{{ db_file.original_name }}
· <strong style="color:#58a6ff;">v{{ db_file.version }}</strong>
· {{ db_file.size_bytes|format_size }}
</p>
</div>
<div class="files-hero-actions">
<a href="/files/download/{{ db_file.id }}" class="btn btn-primary">⬇ Скачать текущую</a>
</div>
</div>
{% if perms.can_edit and db_file.space_id %}
<div class="form-card" style="margin-bottom:20px;">
<h3 style="margin-top:0;font-size:15px;">Загрузить новую версию</h3>
<form action="/files/{{ db_file.id }}/new_version" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label>Файл *</label>
<input type="file" name="file" required>
</div>
<div class="form-group">
<label>Комментарий к версии</label>
<input type="text" name="comment" placeholder="Например: обновил раздел 2.3" maxlength="200">
</div>
<div class="form-actions" style="margin-top:8px;padding-top:8px;">
<button type="submit" class="btn btn-primary">Загрузить v{{ (db_file.version or 1) + 1 }}</button>
</div>
</form>
</div>
{% endif %}
<div class="files-section">
<div class="files-section-head">
<h2>📚 Версии <span class="count">{{ versions|length }}</span></h2>
</div>
{% if versions %}
<table class="meta-table">
<thead>
<tr>
<th>Версия</th>
<th>Размер</th>
<th>Автор</th>
<th>Дата</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for v, u in versions %}
<tr {% if v.id == db_file.current_version_id %}style="background:rgba(88,166,255,0.08);"{% endif %}>
<td>
<span style="display:inline-block;padding:2px 8px;border-radius:8px;background:rgba(88,166,255,0.15);color:#58a6ff;border:1px solid rgba(88,166,255,0.3);font-weight:700;font-size:11px;">v{{ v.version_num }}</span>
{% if v.id == db_file.current_version_id %}
<small style="color:#3fb950;margin-left:6px;font-weight:600;">← текущая</small>
{% endif %}
</td>
<td>{{ v.size_bytes|format_size }}</td>
<td>
<div style="font-weight:500;">{{ u.full_name or u.username }}</div>
<div style="color:var(--text-muted);font-size:11px;">@{{ u.username }}</div>
</td>
<td>{{ v.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}</td>
<td style="color:var(--text-muted);font-size:12px;">{{ v.comment or '—' }}</td>
<td>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<a href="/files/{{ db_file.id }}/version/{{ v.id }}/download" class="btn btn-default btn-sm">Скачать</a>
{% if perms.can_edit and v.id != db_file.current_version_id %}
<form action="/files/{{ db_file.id }}/revert/{{ v.id }}" method="POST" style="display:inline;" onsubmit="return confirm('Откатиться к v{{ v.version_num }}? Будет создана новая версия v{{ (db_file.version or 1) + 1 }} с содержимым v{{ v.version_num }}.');">
<button type="submit" class="btn btn-default btn-sm">↺ Откатить</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="files-empty">
<div class="icon">📚</div>
<h3>Версий нет</h3>
<p>Файл загружен в старой схеме (без пространства) — версий нет. Или это первый файл в пространстве, и запись о версии отсутствует.</p>
</div>
{% endif %}
</div>
<div style="margin-top:24px;">
{% if db_file.folder_id %}
<a href="/files/folder/{{ db_file.folder_id }}" class="btn btn-default">← К папке</a>
{% elif db_file.space_id %}
<a href="/files/space/{{ db_file.space_id }}" class="btn btn-default">← К пространству</a>
{% else %}
<a href="/files" class="btn btn-default">← Все файлы</a>
{% endif %}
</div>
{% endblock %}

View File

@ -1,32 +1,20 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Сброс пароля — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
.links a { color: #58a6ff; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<h2>Сброс пароля</h2>
<p style="color: #8b949e; font-size: 0.85rem; text-align: center; margin-bottom: 15px;">Введите свой Email, и мы отправим ссылку для сброса.</p>
<form method="POST">
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<button type="submit">Отправить ссылку</button>
</form>
<div class="links"><a href="/login">← Назад ко входу</a></div>
{% extends "base.html" %}
{% block title %}Сброс пароля — Morozovka Portal{% endblock %}
{% block main_class %}auth-page{% endblock %}
{% block content %}
<div class="auth-card">
<h2>Сброс пароля</h2>
<p style="color: #8b949e; font-size: 0.85rem; text-align: center; margin-bottom: 15px;">Введите свой Email, и мы отправим ссылку для сброса.</p>
<form method="POST">
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<button type="submit">Отправить ссылку</button>
</form>
<div class="links">
<a href="/login">← Назад ко входу</a>
</div>
</body>
</html>
</div>
{% endblock %}

View File

@ -1,166 +1,112 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Morozovka Platform</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; min-height: 100vh; display: flex; flex-direction: column; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; 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: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo:hover { text-decoration: none; color: #e6edf3; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; font-size: 14px; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.topnav-actions { display: flex; gap: 12px; align-items: center; padding-right: 80px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; transition: all .15s; white-space: nowrap; }
.btn:hover { text-decoration: none; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; color: #fff; }
.btn-default { background: #21262d; color: #c9d1d9; }
.btn-default:hover { background: #30363d; color: #c9d1d9; }
.btn-outline { background: transparent; color: #c9d1d9; border-color: #30363d; }
.btn-outline:hover { background: #21262d; color: #e6edf3; border-color: #8b949e; }
.btn-lg { padding: 10px 24px; font-size: 15px; }
.container { max-width: 1200px; margin: 0 auto; padding: 32px; flex: 1; width: 100%; }
.hero { display: grid; grid-template-columns: 2fr 1fr; gap: 32px; margin-bottom: 32px; }
.hero-main { padding: 32px; background: linear-gradient(135deg, #161b22 0%, #1c2128 100%); border: 1px solid #30363d; border-radius: 6px; 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: #8b949e; 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 #30363d; border-radius: 6px; background: #161b22; }
.status-label { color: #8b949e; 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: #8b949e; font-size: 13px; }
.status-link { display: inline-flex; align-items: center; gap: 4px; margin-top: 12px; font-size: 13px; font-weight: 500; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; margin-top: 32px; }
.section-title { font-size: 18px; font-weight: 600; color: #e6edf3; margin: 0; }
.cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.feature-card { padding: 20px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; transition: all .15s; display: flex; flex-direction: column; text-decoration: none; color: inherit; }
.feature-card:hover { border-color: #58a6ff; transform: translateY(-2px); text-decoration: none; }
.feature-icon { font-size: 32px; margin-bottom: 12px; }
.feature-title { font-size: 16px; font-weight: 600; color: #e6edf3; margin: 0 0 6px; }
.feature-desc { color: #8b949e; 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); }
.footer { border-top: 1px solid #30363d; padding: 24px 32px; text-align: center; color: #8b949e; font-size: 13px; }
.footer a { color: #8b949e; }
.footer a:hover { color: #58a6ff; }
@media (max-width: 900px) { .hero { grid-template-columns: 1fr; } .hero-side { flex-direction: row; flex-wrap: wrap; } .hero-side > * { flex: 1; min-width: 200px; } }
@media (max-width: 768px) { .topnav { padding: 12px 16px; gap: 12px; } .topnav-links { display: none; } .topnav-actions { padding-right: 60px; } .container { padding: 16px; } .hero-main { padding: 20px; } .hero-title { font-size: 24px; } .section-header { flex-direction: column; align-items: flex-start; gap: 8px; } }
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops">Мастер-классы</a>
<a href="/files">Файлы</a>
{% extends "base.html" %}
{% block title %}Morozovka Platform — главная{% endblock %}
{% block main_class %}container{% endblock %}
{% block content %}
{% if session.get('user_id') and not onboarding_completed and not request.args.get('skip_tour') %}
<div class="welcome-banner">
<div class="welcome-banner-icon">🎓</div>
<div class="welcome-banner-body">
<h3 class="welcome-banner-title">Первый раз здесь?</h3>
<p class="welcome-banner-text">Пройди короткий тур по разделам платформы — это займёт 2 минуты.</p>
</div>
<div class="topnav-actions">
{% if username %}
<a href="/profile" class="btn btn-default">{{ username }}</a>
{% if session.get('role') == 'admin' %}<a href="/admin/users" class="btn btn-outline">Админ</a>{% endif %}
<a href="/logout" class="btn btn-outline">Выйти</a>
<a href="/onboarding" class="btn btn-primary">Пройти тур →</a>
</div>
{% endif %}
<div class="hero-grid">
<div class="hero-main">
{% if session.get('username') %}
<h1 class="hero-title">Привет, {{ session.get('username') }}!</h1>
<p class="hero-subtitle">Расписание, проекты, мастер-классы и файлы — всё в одном месте. Выбирай, чем займёшься сегодня.</p>
{% else %}
<a href="/login" class="btn btn-default">Войти</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
<h1 class="hero-title">Morozovka Platform</h1>
<p class="hero-subtitle">Единая платформа команды: расписание, проекты, мастер-классы, файлы и лента. Войди, чтобы получить полный доступ.</p>
{% endif %}
</div>
</nav>
<div class="container">
<div class="hero">
<div class="hero-main">
{% if username %}
<h1 class="hero-title">Привет, {{ username }}!</h1>
<p class="hero-subtitle">Добро пожаловать на платформу команды Морозовки. Расписание, проекты, мастер-классы и файлы — всё в одном месте.</p>
{% else %}
<h1 class="hero-title">Morozovka Platform</h1>
<p class="hero-subtitle">Единая платформа команды: расписание, проекты, мастер-классы и файлы. Войдите, чтобы получить полный доступ.</p>
{% endif %}
<div class="hero-actions">
<a href="/schedule" class="btn btn-primary btn-lg">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M3.75 1.5a.75.75 0 0 1 .75.75V3h7V2.25a.75.75 0 0 1 1.5 0V3h.5A1.75 1.75 0 0 1 15 4.75v9A1.75 1.75 0 0 1 13.5 15.5h-11A1.75 1.75 0 0 1 1 13.75v-9A1.75 1.75 0 0 1 2.5 3H3V2.25a.75.75 0 0 1 .75-.75Zm10.75 5.5h-13v6.75c0 .138.112.25.25.25h11a.25.25 0 0 0 .25-.25Z"/></svg>
Расписание
</a>
<a href="/projects" class="btn btn-outline btn-lg">Проекты</a>
<a href="/workshops" class="btn btn-outline btn-lg">Мастер-классы</a>
</div>
</div>
<div class="hero-side">
<div class="status-card">
<div class="status-label">Статус Никиты Сергеевича</div>
<div class="status-value" id="status-value"><span class="status-dot status-dot-grey"></span><span>Загрузка...</span></div>
<div class="status-meta" id="status-meta">Проверяем расписание...</div>
<a href="/schedule" class="status-link">Открыть расписание →</a>
</div>
<div class="hero-actions">
<a href="/schedule" class="btn btn-primary btn-lg">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M3.75 1.5a.75.75 0 0 1 .75.75V3h7V2.25a.75.75 0 0 1 1.5 0V3h.5A1.75 1.75 0 0 1 15 4.75v9A1.75 1.75 0 0 1 13.5 15.5h-11A1.75 1.75 0 0 1 1 13.75v-9A1.75 1.75 0 0 1 2.5 3H3V2.25a.75.75 0 0 1 .75-.75Zm10.75 5.5h-13v6.75c0 .138.112.25.25.25h11a.25.25 0 0 0 .25-.25Z"/></svg>
Расписание
</a>
<a href="/projects" class="btn btn-outline btn-lg">Проекты</a>
<a href="/workshops" class="btn btn-outline btn-lg">Мастер-классы</a>
</div>
</div>
<div class="section-header">
<h2 class="section-title">Разделы платформы</h2>
</div>
<div class="cards-grid">
<a href="/schedule" class="feature-card">
<div class="feature-icon">📅</div>
<h3 class="feature-title">Расписание</h3>
<p class="feature-desc">Календарь событий, статус преподавателя, встроенный ассистент</p>
</a>
<a href="/projects" class="feature-card">
<div class="feature-icon">🗂</div>
<h3 class="feature-title">Проекты</h3>
<p class="feature-desc">Каталог проектов команды, канбан-доски, участники и роли</p>
</a>
<a href="/workshops" class="feature-card">
<div class="feature-icon">🎓</div>
<h3 class="feature-title">Мастер-классы</h3>
<p class="feature-desc">Пайка, embedded, Linux и творческие занятия. Регистрация и материалы</p>
<span class="feature-badge feature-badge-new">NEW</span>
</a>
<a href="/files" class="feature-card">
<div class="feature-icon">📁</div>
<h3 class="feature-title">Файлы</h3>
<p class="feature-desc">Единое хранилище документов, схем и материалов</p>
</a>
{% if session.get('role') in ['admin', 'professor'] %}
<a href="/admin/schedule" class="feature-card">
<div class="feature-icon">⚙️</div>
<h3 class="feature-title">Управление расписанием</h3>
<p class="feature-desc">Быстрые действия, события, импорт/экспорт .ics</p>
</a>
{% endif %}
{% if session.get('role') == 'admin' %}
<a href="/admin/users" class="feature-card">
<div class="feature-icon">👥</div>
<h3 class="feature-title">Пользователи</h3>
<p class="feature-desc">Управление ролями и доступом</p>
</a>
{% endif %}
<div class="hero-side">
<div class="status-card">
<div class="status-label">Статус Никиты Сергеевича</div>
<div class="status-value" id="status-value"><span class="status-dot status-dot-grey"></span><span>Загрузка...</span></div>
<div class="status-meta" id="status-meta">Проверяем расписание...</div>
<a href="/schedule" class="status-link">Открыть расписание →</a>
</div>
</div>
</div>
<div class="footer">
Morozovka Platform · <a href="/schedule">Расписание</a> · <a href="/projects">Проекты</a> · <a href="/workshops">Мастер-классы</a> · <a href="/files">Файлы</a>
<div class="section-header-row">
<h2>Разделы платформы</h2>
</div>
<div class="cards-grid">
{% if session.get('username') %}
<a href="/dashboard" class="feature-card">
<div class="feature-icon">🏠</div>
<h3 class="feature-title">Мой дашборд</h3>
<p class="feature-desc">Проекты, задачи, уведомления, материалы — всё в одном месте</p>
<span class="feature-badge feature-badge-new">NEW</span>
</a>
{% endif %}
<a href="/schedule" class="feature-card">
<div class="feature-icon">📅</div>
<h3 class="feature-title">Расписание</h3>
<p class="feature-desc">Календарь событий, статус преподавателя, встроенный ассистент</p>
</a>
<a href="/projects" class="feature-card">
<div class="feature-icon">🗂</div>
<h3 class="feature-title">Проекты</h3>
<p class="feature-desc">Каталог проектов, канбан-доски, участники и роли</p>
</a>
<a href="/workshops" class="feature-card">
<div class="feature-icon">🎓</div>
<h3 class="feature-title">Мастер-классы</h3>
<p class="feature-desc">Пайка, embedded, Linux, творчество. Регистрация и материалы</p>
</a>
<a href="/files" class="feature-card">
<div class="feature-icon">📁</div>
<h3 class="feature-title">Файлы</h3>
<p class="feature-desc">Единое хранилище документов, схем и материалов</p>
</a>
<a href="/feed" class="feature-card">
<div class="feature-icon">📰</div>
<h3 class="feature-title">Лента</h3>
<p class="feature-desc">Все новости из проектов и мастер-классов в одном потоке</p>
</a>
{% if session.get('username') %}
<a href="/onboarding" class="feature-card">
<div class="feature-icon">🎯</div>
<h3 class="feature-title">Тур по платформе</h3>
<p class="feature-desc">Короткое знакомство со всеми разделами</p>
<span class="feature-badge feature-badge-new">NEW</span>
</a>
{% endif %}
{% if session.get('role') in ['admin', 'professor'] %}
<a href="/admin/schedule" class="feature-card">
<div class="feature-icon">⚙️</div>
<h3 class="feature-title">Управление расписанием</h3>
<p class="feature-desc">Быстрые действия, события, импорт/экспорт .ics</p>
</a>
{% endif %}
{% if session.get('role') == 'admin' %}
<a href="/admin/users" class="feature-card">
<div class="feature-icon">👥</div>
<h3 class="feature-title">Пользователи</h3>
<p class="feature-desc">Управление ролями, блокировка, поиск</p>
</a>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
fetch('/api/public-status')
@ -189,6 +135,4 @@ a:hover { text-decoration: underline; }
});
})();
</script>
<script src="/static/notif_bell.js"></script>
</body>
</html>
{% endblock %}

View File

@ -1,50 +1,31 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Вход — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
button:hover { background: #2ea043; }
.gitea-btn { background: #30363d; margin-top: 15px; display: block; text-align: center; text-decoration: none; padding: 10px; border-radius: 5px; color: white; font-weight: bold; font-size: 0.9rem; }
.gitea-btn:hover { background: #21262d; }
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
.links a { color: #58a6ff; text-decoration: none; display: block; margin-bottom: 5px; }
.msg { color: #3fb950; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
.error { color: #f85149; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="card">
<h2>Вход</h2>
{% if msg %}<div class="msg">{{ msg }}</div>{% endif %}
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="POST">
<div class="form-group">
<label>Логин</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" name="password" required>
</div>
<button type="submit">Войти</button>
</form>
<a href="/login/gitea" class="gitea-btn">Войти через Gitea</a>
<div class="links">
<a href="/register">Нет аккаунта? Регистрация</a>
<a href="/forgot_password">Забыли пароль?</a>
<a href="/" style="color: #8b949e;">← На главную</a>
{% extends "base.html" %}
{% block title %}Вход — Morozovka Portal{% endblock %}
{% block main_class %}auth-page{% endblock %}
{% block content %}
<div class="auth-card">
<h2>Вход</h2>
{% if msg %}<div class="msg">{{ msg }}</div>{% endif %}
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="POST">
<div class="form-group">
<label>Логин</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" name="password" required>
</div>
<button type="submit">Войти</button>
</form>
<a href="/login/gitea" class="gitea-btn">Войти через Gitea</a>
<div class="links">
<a href="/register">Нет аккаунта? Регистрация</a>
<a href="/forgot_password">Забыли пароль?</a>
<a href="/" style="color: #8b949e;">← На главную</a>
</div>
</body>
</html>
</div>
{% endblock %}

View File

@ -1,24 +1,9 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Уведомления — Morozovka Portal</title>
{% extends "base.html" %}
{% block title %}Уведомления — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
{% block head %}
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; }
.topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; }
.btn-default { background: #21262d; color: #c9d1d9; }
.container { max-width: 900px; margin: 0 auto; padding: 24px 32px; }
h1 { font-size: 24px; font-weight: 600; margin: 0 0 24px; color: #e6edf3; }
.notif-list { background: #161b22; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
.notif-row { display: block; padding: 14px 20px; border-bottom: 1px solid #21262d; color: inherit; text-decoration: none; transition: background 0.15s; position: relative; }
.notif-row:last-child { border-bottom: none; }
@ -31,41 +16,26 @@ h1 { font-size: 24px; font-weight: 600; margin: 0 0 24px; color: #e6edf3; }
.notif-row.kind-success .notif-row-title { color: #3fb950; }
.notif-row.kind-warning .notif-row-title { color: #d29922; }
.notif-row.kind-error .notif-row-title { color: #f85149; }
.empty { text-align: center; padding: 60px 20px; color: #8b949e; font-size: 14px; background: #161b22; border: 1px dashed #30363d; border-radius: 6px; }
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
{% endblock %}
{% block content %}
<h1 class="page-title">Уведомления</h1>
{% if notifications %}
<div class="notif-list">
{% for n in notifications %}
<a class="notif-row kind-{{ n.kind }}{% if not n.is_read %} unread{% endif %}" href="{{ n.link_url or '#' }}">
<div class="notif-row-title">{{ n.title }}</div>
{% if n.body %}<div class="notif-row-body">{{ n.body }}</div>{% endif %}
<div class="notif-row-time">{{ n.created_at|msk_strftime('%d.%m.%Y %H:%M') }}</div>
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops">Мастер-классы</a>
<a href="/files">Файлы</a>
</div>
{% if session.get('username') %}
<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>
{% endif %}
</nav>
<div class="container">
<h1>Уведомления</h1>
{% if notifications %}
<div class="notif-list">
{% for n in notifications %}
<a class="notif-row kind-{{ n.kind }}{% if not n.is_read %} unread{% endif %}" href="{{ n.link_url or '#' }}">
<div class="notif-row-title">{{ n.title }}</div>
{% if n.body %}<div class="notif-row-body">{{ n.body }}</div>{% endif %}
<div class="notif-row-time">{{ n.created_at|msk_strftime('%d.%m.%Y %H:%M') }}</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty">Уведомлений пока нет</div>
{% endif %}
{% endfor %}
</div>
<script src="/static/notif_bell.js"></script>
</body>
</html>
{% else %}
<div class="empty-state">
<h3>Уведомлений пока нет</h3>
<p>Когда появятся новости — они будут здесь.</p>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Тур по платформе — Morozovka{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">👋 Тур по платформе</h1>
<p class="page-subtitle">Короткое знакомство с разделами. Займёт 2 минуты.</p>
</div>
</div>
{% if just_done %}
<div class="welcome-banner">
<div class="welcome-banner-icon">✅</div>
<div class="welcome-banner-body">
<h3 class="welcome-banner-title">Готово! Тур пройден.</h3>
<p class="welcome-banner-text">Все разделы отмечены. Можешь возвращаться сюда из футера, чтобы освежить память.</p>
</div>
<a href="/" class="btn btn-primary">На главную →</a>
</div>
{% endif %}
<div class="onboarding-progress">
<div class="onboarding-progress-label">
<span>Прогресс</span>
<strong>{{ completed }} / {{ total }}</strong>
</div>
<div class="onboarding-progress-bar">
<div class="onboarding-progress-fill" id="progress-fill"
style="width: {{ (completed / total * 100)|round(1) }}%"></div>
</div>
</div>
{% for step in steps %}
<div class="onboarding-step {% if ob[step.key] %}done{% endif %}" data-key="{{ step.key }}">
<div class="onboarding-step-num">{{ step.num if not ob[step.key] else '✓' }}</div>
<div class="onboarding-step-body">
<h3 class="onboarding-step-title">
<span>{{ step.icon }}</span>
{{ step.title }}
</h3>
<p class="onboarding-step-desc">{{ step.desc }}</p>
<div class="onboarding-step-actions">
<a href="{{ step.url }}" class="btn btn-outline btn-sm">Открыть →</a>
<label class="onboarding-step-check">
<input type="checkbox" data-key="{{ step.key }}"
{% if ob[step.key] %}checked{% endif %}>
Освоил(а)
</label>
</div>
</div>
</div>
{% endfor %}
<div style="text-align: center; margin-top: 32px;">
<form action="/onboarding/complete" method="POST" style="display: inline;">
<button type="submit" class="btn btn-primary"
onclick="return confirm('Отметить все разделы как освоенные?');">
✅ Отметить всё сразу
</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
const checks = document.querySelectorAll('.onboarding-step-check input[data-key]');
const fill = document.getElementById('progress-fill');
function recalcProgress(completed, total) {
if (!fill) return;
fill.style.width = (completed / total * 100) + '%';
document.querySelector('.onboarding-progress-label strong').textContent = completed + ' / ' + total;
}
checks.forEach(function(cb) {
cb.addEventListener('change', async function() {
const key = cb.dataset.key;
const value = cb.checked;
const stepEl = cb.closest('.onboarding-step');
try {
const r = await fetch('/onboarding/mark', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key: key, value: value})
});
if (!r.ok) throw new Error('bad response');
const data = await r.json();
if (stepEl) stepEl.classList.toggle('done', value);
const numEl = stepEl.querySelector('.onboarding-step-num');
if (numEl) {
if (value) numEl.textContent = '✓';
else numEl.textContent = Array.from(document.querySelectorAll('.onboarding-step')).indexOf(stepEl) + 1;
}
recalcProgress(data.completed, data.total);
} catch (e) {
cb.checked = !value; // откатить
console.error('onboarding mark failed', e);
}
});
});
})();
</script>
{% endblock %}

View File

@ -1,117 +1,132 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Профиль — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 20px; max-width: 560px; margin: 0 auto 20px; }
h2 { color: #58a6ff; margin-top: 0; }
h3 { color: #e6edf3; margin-top: 0; font-size: 1.1rem; }
.field { margin-bottom: 15px; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
.label { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; }
.value { font-size: 1.1rem; font-family: monospace; }
a { color: #58a6ff; text-decoration: none; }
.btn-sessions { display: inline-block; margin-top: 15px; padding: 8px 15px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; color: #e6edf3; }
.btn-sessions:hover { background: #30363d; }
{% extends "base.html" %}
{% block title %}Профиль — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
.sub-status { padding: 12px 15px; border-radius: 6px; margin-bottom: 15px; font-size: .9rem; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.sub-active { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
.sub-none { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; }
.sub-badge { font-size: .75rem; padding: 2px 8px; border-radius: 10px; background: rgba(63,185,80,.25); border: 1px solid #238636; color: #3fb950; }
{% block head %}
<style>
.profile-card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 20px; margin-bottom: 20px; }
.profile-card h2 { color: #58a6ff; margin-top: 0; font-size: 1.3rem; }
.profile-card h3 { color: #e6edf3; margin-top: 0; font-size: 1.1rem; }
.field { margin-bottom: 15px; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
.field:last-of-type { border-bottom: none; }
.label { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; }
.value { font-size: 1.1rem; font-family: monospace; }
.btn-sessions { display: inline-block; padding: 8px 15px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; color: #e6edf3; text-decoration: none; }
.btn-sessions:hover { background: #30363d; text-decoration: none; }
.profile-actions { margin-top: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.profile-actions a { color: #58a6ff; text-decoration: none; }
.profile-actions a:hover { text-decoration: underline; }
.sub-form { display: flex; flex-direction: column; gap: 12px; }
.sub-checkbox { display: flex; align-items: center; gap: 8px; color: #c9d1d9; font-size: .9rem; cursor: pointer; }
.sub-checkbox input { width: 16px; height: 16px; }
.btn-primary { background: #238636; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: 600; }
.btn-danger:hover { background: #f85149; }
.muted { color: #8b949e; font-size: .85rem; line-height: 1.5; }
</style>
</head>
<body>
<div class="card">
<h2>Личный кабинет</h2>
<div class="field">
<div class="label">Логин</div>
<div class="value">{{ user.username }}</div>
.sub-status { padding: 12px 15px; border-radius: 6px; margin-bottom: 15px; font-size: .9rem; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.sub-active { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
.sub-none { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; }
.sub-badge { font-size: .75rem; padding: 2px 8px; border-radius: 10px; background: rgba(63,185,80,.25); border: 1px solid #238636; color: #3fb950; }
.sub-form { display: flex; flex-direction: column; gap: 12px; }
.sub-checkbox { display: flex; align-items: center; gap: 8px; color: #c9d1d9; font-size: .9rem; cursor: pointer; }
.sub-checkbox input { width: 16px; height: 16px; }
.btn-primary { background: #238636; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: 600; }
.btn-danger:hover { background: #f85149; }
.muted { color: #8b949e; font-size: .85rem; line-height: 1.5; }
</style>
{% endblock %}
{% block content %}
<div class="profile-card">
<h2>Личный кабинет</h2>
<div class="field">
<div class="label">Логин</div>
<div class="value">{{ user.username }}</div>
</div>
<div class="field">
<div class="label">ФИО</div>
<div class="value">{{ user.full_name or 'Не указано' }}</div>
</div>
<div class="field">
<div class="label">Email</div>
<div class="value">{{ user.email or 'Не указано' }}
{% if user.is_verified %}<span style="color: #3fb950; font-size: 0.8rem;">✓ Подтвержден</span>{% endif %}
</div>
<div class="field">
<div class="label">ФИО</div>
<div class="value">{{ user.full_name or 'Не указано' }}</div>
</div>
<div class="field">
<div class="label">Организация</div>
<div class="value">{{ user.organization or 'Не указана' }}</div>
</div>
<div class="field">
<div class="label">Роль в системе</div>
<div class="value">{{ user.role }}</div>
</div>
<div class="field">
<div class="label">Тур по платформе</div>
<div class="value">
{% if onboarding and onboarding.completed_at %}
✅ Пройден
{% elif onboarding %}
⏳ В процессе — <a href="/onboarding">продолжить</a>
{% else %}
<a href="/onboarding">Пройти</a>
{% endif %}
</div>
<div class="field">
<div class="label">Email</div>
<div class="value">{{ user.email or 'Не указано' }}
{% if user.is_verified %}<span style="color: #3fb950; font-size: 0.8rem;">✓ Подтвержден</span>{% endif %}
</div>
</div>
<div class="profile-actions">
<div>
<a href="/">← На главную</a> |
<a href="/onboarding">🎯 Тур</a> |
<a href="/logout" style="color: #f85149;">Выйти</a>
</div>
<div class="field">
<div class="label">Организация</div>
<div class="value">{{ user.organization or 'Не указана' }}</div>
<a href="/sessions" class="btn-sessions">Безопасность (сессии)</a>
</div>
</div>
<div class="profile-card">
<h3>📬 Уведомления о расписании</h3>
{% if not user.email %}
<div class="sub-status sub-none">⚠ Укажите email в профиле, чтобы получать уведомления.</div>
{% elif subscription and subscription.is_confirmed %}
<div class="sub-status sub-active">
<span>✅ Подписка активна</span>
<span class="sub-badge">{{ user.email }}</span>
</div>
<div class="field" style="border-bottom: none;">
<div class="label">Роль в системе</div>
<div class="value">{{ user.role }}</div>
</div>
<div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="next" value="/profile">
<label class="sub-checkbox">
<input type="checkbox" name="notify_status" {% if subscription.notify_status %}checked{% endif %} disabled>
Изменения статуса (опаздываю / на месте / отменено)
</label>
<label class="sub-checkbox">
<input type="checkbox" name="notify_events" {% if subscription.notify_events %}checked{% endif %} disabled>
Изменения событий в расписании
</label>
<div>
<a href="/">← На главную</a> | <a href="/logout" style="color: #f85149;">Выйти</a>
<button type="submit" class="btn-danger">Отписаться</button>
</div>
<a href="/sessions" class="btn-sessions">Безопасность (сессии)</a>
</div>
</div>
<div class="card">
<h3>📬 Уведомления о расписании</h3>
{% if not user.email %}
<div class="sub-status sub-none">⚠ Укажите email в профиле, чтобы получать уведомления.</div>
{% elif subscription and subscription.is_confirmed %}
<div class="sub-status sub-active">
<span>✅ Подписка активна</span>
<span class="sub-badge">{{ user.email }}</span>
</form>
<p class="muted" style="margin-top: 12px;">
Отписаться можно в один клик. Повторная подписка тоже в один клик — никаких подтверждений по почте.
</p>
{% else %}
<div class="sub-status sub-none">Вы пока не подписаны на уведомления</div>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="next" value="/profile">
<label class="sub-checkbox">
<input type="checkbox" name="notify_status" checked>
Уведомлять об изменениях статуса
</label>
<label class="sub-checkbox">
<input type="checkbox" name="notify_events" checked>
Уведомлять об изменениях в расписании
</label>
<div>
<button type="submit" class="btn-primary">Подписаться на {{ user.email }}</button>
</div>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="next" value="/profile">
<label class="sub-checkbox">
<input type="checkbox" name="notify_status" {% if subscription.notify_status %}checked{% endif %} disabled>
Изменения статуса (опаздываю / на месте / отменено)
</label>
<label class="sub-checkbox">
<input type="checkbox" name="notify_events" {% if subscription.notify_events %}checked{% endif %} disabled>
Изменения событий в расписании
</label>
<div>
<button type="submit" class="btn-danger">Отписаться</button>
</div>
</form>
<p class="muted" style="margin-top: 12px;">
Отписаться можно в один клик. Повторная подписка тоже в один клик — никаких подтверждений по почте.
</p>
{% else %}
<div class="sub-status sub-none">Вы пока не подписаны на уведомления</div>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="next" value="/profile">
<label class="sub-checkbox">
<input type="checkbox" name="notify_status" checked>
Уведомлять об изменениях статуса
</label>
<label class="sub-checkbox">
<input type="checkbox" name="notify_events" checked>
Уведомлять об изменениях в расписании
</label>
<div>
<button type="submit" class="btn-primary">Подписаться на {{ user.email }}</button>
</div>
</form>
<p class="muted" style="margin-top: 12px;">
Email берётся из вашего аккаунта — подтверждение не требуется.
</p>
{% endif %}
</div>
</body>
</html>
</form>
<p class="muted" style="margin-top: 12px;">
Email берётся из вашего аккаунта — подтверждение не требуется.
</p>
{% endif %}
</div>
{% endblock %}

View File

@ -1,146 +1,126 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ project.title }} — Доска — Morozovka Portal</title>
{% extends "base.html" %}
{% block title %}{{ project.title }} — Доска — Morozovka Portal{% endblock %}
{% block main_class %}container container-wide{% endblock %}
{% block head %}
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 20px; margin: 0; }
.container { max-width: 1400px; margin: 0 auto; }
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
h1 { color: #58a6ff; margin: 0; font-size: 1.8rem; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid #30363d; margin-bottom: 25px; }
.tab { padding: 10px 20px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; }
.tab:hover { color: #e6edf3; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge { background: #30363d; color: #e6edf3; padding: 2px 8px; border-radius: 10px; font-size: .75rem; margin-left: 6px; }
.board { display: grid; grid-template-columns: repeat(5, minmax(240px, 1fr)); gap: 15px; align-items: flex-start; overflow-x: auto; padding-bottom: 15px; }
.column { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 12px; min-height: 200px; }
.column-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #30363d; }
.column-title { font-weight: 700; color: #e6edf3; font-size: .9rem; text-transform: uppercase; letter-spacing: .5px; }
.column-count { color: #8b949e; font-size: .8rem; background: #0d1117; padding: 2px 8px; border-radius: 10px; }
.column.drop-target { border-color: #58a6ff; background: rgba(88,166,255,.05); }
.task-list { min-height: 50px; display: flex; flex-direction: column; gap: 8px; }
.task-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; cursor: grab; position: relative; transition: all .15s; }
.task-card:hover { border-color: #58a6ff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,.3); }
.task-card.dragging { opacity: .5; cursor: grabbing; }
.task-card::before { content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 2px; }
.priority-low::before { background: #8b949e; }
.priority-medium::before { background: #58a6ff; }
.priority-high::before { background: #d29922; }
.priority-critical::before { background: #f85149; }
.task-title { color: #e6edf3; font-weight: 600; font-size: .9rem; margin-left: 6px; margin-bottom: 6px; word-wrap: break-word; }
.task-desc { color: #8b949e; font-size: .8rem; margin-left: 6px; margin-bottom: 8px; line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.task-meta { display: flex; justify-content: space-between; align-items: center; gap: 6px; margin-left: 6px; font-size: .75rem; }
.task-assignee { color: #58a6ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.task-due { color: #8b949e; }
.task-due.overdue { color: #f85149; }
.task-actions { display: flex; gap: 4px; margin-left: auto; opacity: 0; transition: opacity .15s; }
.task-card:hover .task-actions { opacity: 1; }
.task-action-btn { background: none; border: none; color: #8b949e; cursor: pointer; padding: 2px 4px; font-size: .85rem; border-radius: 3px; }
.task-action-btn:hover { background: #30363d; color: #e6edf3; }
.task-action-btn.danger:hover { color: #f85149; }
.add-task-btn { width: 100%; padding: 8px; margin-top: 8px; background: transparent; border: 1px dashed #30363d; color: #8b949e; border-radius: 6px; cursor: pointer; font-size: .85rem; transition: all .15s; }
.add-task-btn:hover { border-color: #58a6ff; color: #58a6ff; }
/* Модалка */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; width: 100%; max-width: 550px; padding: 25px; max-height: 90vh; overflow-y: auto; }
.modal h2 { margin: 0 0 20px; color: #58a6ff; }
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { color: #8b949e; font-size: .85rem; font-weight: 600; }
input, select, textarea { padding: 10px; background: #0d1117; color: #fff; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; font-size: .95rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { resize: vertical; min-height: 80px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: 600; font-size: .9rem; text-decoration: none; display: inline-block; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
@media (max-width: 1100px) {
.board { grid-template-columns: repeat(5, 280px); }
}
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
h1 { color: #58a6ff; margin: 0; font-size: 1.8rem; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid #30363d; margin-bottom: 25px; }
.tab { padding: 10px 20px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; }
.tab:hover { color: #e6edf3; text-decoration: none; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge { background: #30363d; color: #e6edf3; padding: 2px 8px; border-radius: 10px; font-size: .75rem; margin-left: 6px; }
.board { display: grid; grid-template-columns: repeat(5, minmax(240px, 1fr)); gap: 15px; align-items: flex-start; overflow-x: auto; padding-bottom: 15px; }
.column { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 12px; min-height: 200px; }
.column-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #30363d; }
.column-title { font-weight: 700; color: #e6edf3; font-size: .9rem; text-transform: uppercase; letter-spacing: .5px; }
.column-count { color: #8b949e; font-size: .8rem; background: #0d1117; padding: 2px 8px; border-radius: 10px; }
.column.drop-target { border-color: #58a6ff; background: rgba(88,166,255,.05); }
.task-list { min-height: 50px; display: flex; flex-direction: column; gap: 8px; }
.task-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; cursor: grab; position: relative; transition: all .15s; }
.task-card:hover { border-color: #58a6ff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,.3); }
.task-card.dragging { opacity: .5; cursor: grabbing; }
.task-card::before { content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 2px; }
.priority-low::before { background: #8b949e; }
.priority-medium::before { background: #58a6ff; }
.priority-high::before { background: #d29922; }
.priority-critical::before { background: #f85149; }
.task-title { color: #e6edf3; font-weight: 600; font-size: .9rem; margin-left: 6px; margin-bottom: 6px; word-wrap: break-word; }
.task-desc { color: #8b949e; font-size: .8rem; margin-left: 6px; margin-bottom: 8px; line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.task-meta { display: flex; justify-content: space-between; align-items: center; gap: 6px; margin-left: 6px; font-size: .75rem; }
.task-assignee { color: #58a6ff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.task-due { color: #8b949e; }
.task-due.overdue { color: #f85149; }
.task-actions { display: flex; gap: 4px; margin-left: auto; opacity: 0; transition: opacity .15s; }
.task-card:hover .task-actions { opacity: 1; }
.task-action-btn { background: none; border: none; color: #8b949e; cursor: pointer; padding: 2px 4px; font-size: .85rem; border-radius: 3px; text-decoration: none; }
.task-action-btn:hover { background: #30363d; color: #e6edf3; }
.task-action-btn.danger:hover { color: #f85149; }
.add-task-btn { width: 100%; padding: 8px; margin-top: 8px; background: transparent; border: 1px dashed #30363d; color: #8b949e; border-radius: 6px; cursor: pointer; font-size: .85rem; transition: all .15s; }
.add-task-btn:hover { border-color: #58a6ff; color: #58a6ff; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; width: 100%; max-width: 550px; padding: 25px; max-height: 90vh; overflow-y: auto; }
.modal h2 { margin: 0 0 20px; color: #58a6ff; }
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { color: #8b949e; font-size: .85rem; font-weight: 600; }
input, select, textarea { padding: 10px; background: #0d1117; color: #fff; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; font-size: .95rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { resize: vertical; min-height: 80px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: 600; font-size: .9rem; text-decoration: none; display: inline-block; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
@media (max-width: 1100px) { .board { grid-template-columns: repeat(5, 280px); } }
</style>
</head>
<body>
<div class="container">
<a href="/projects/{{ project.id }}" class="back-link">← К проекту</a>
<div class="header">
<h1>{{ project.title }} — Доска</h1>
</div>
{% endblock %}
<div class="tabs">
<a href="/projects/{{ project.id }}" class="tab">Обзор</a>
<a href="/projects/{{ project.id }}/board" class="tab active">
Доска
</a>
<a href="/projects/{{ project.id }}#files" class="tab">
Файлы
</a>
</div>
<div class="board" id="board">
{% for status in TASK_STATUSES %}
<div class="column" data-status="{{ status }}">
<div class="column-header">
<span class="column-title">{{ TASK_STATUS_LABELS[status] }}</span>
<span class="column-count">{{ columns[status]|length }}</span>
</div>
<div class="task-list" data-status="{{ status }}">
{% for item in columns[status] %}
{% set task = item.task %}
<div class="task-card priority-{{ task.priority }}"
draggable="true"
data-task-id="{{ task.id }}"
data-status="{{ task.status }}">
<div class="task-title">{{ task.title }}</div>
{% if task.description %}
<div class="task-desc">{{ task.description }}</div>
{% endif %}
<div class="task-meta">
{% if item.assignee %}
<span class="task-assignee">👤 {{ item.assignee.full_name or item.assignee.username }}</span>
{% else %}
<span class="task-assignee" style="color:#6e7681;">— не назначено</span>
{% endif %}
{% if task.due_date %}
<span class="task-due">{{ task.due_date|date_ru }}</span>
{% endif %}
{% if perms.can_edit %}
<div class="task-actions">
<a href="/projects/{{ project.id }}/tasks/{{ task.id }}/edit" class="task-action-btn" title="Редактировать">✎</a>
{% if perms.can_manage %}
<form action="/projects/{{ project.id }}/tasks/{{ task.id }}/delete" method="POST" onsubmit="return confirm('Удалить задачу?');" style="display:inline;">
<button type="submit" class="task-action-btn danger" title="Удалить">✕</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if perms.can_edit %}
<button type="button" class="add-task-btn" onclick="openTaskModal('{{ status }}')">+ Добавить задачу</button>
{% endif %}
</div>
{% endfor %}
</div>
{% block content %}
<a href="/projects/{{ project.id }}" class="back-link">← К проекту</a>
<div class="header">
<h1>{{ project.title }} — Доска</h1>
</div>
<div class="tabs">
<a href="/projects/{{ project.id }}" class="tab">Обзор</a>
<a href="/projects/{{ project.id }}/board" class="tab active">Доска</a>
<a href="/projects/{{ project.id }}#files" class="tab">Файлы</a>
</div>
<div class="board" id="board">
{% for status in TASK_STATUSES %}
<div class="column" data-status="{{ status }}">
<div class="column-header">
<span class="column-title">{{ TASK_STATUS_LABELS[status] }}</span>
<span class="column-count">{{ columns[status]|length }}</span>
</div>
<div class="task-list" data-status="{{ status }}">
{% for item in columns[status] %}
{% set task = item.task %}
<div class="task-card priority-{{ task.priority }}"
draggable="true"
data-task-id="{{ task.id }}"
data-status="{{ task.status }}">
<div class="task-title">{{ task.title }}</div>
{% if task.description %}
<div class="task-desc">{{ task.description }}</div>
{% endif %}
<div class="task-meta">
{% if item.assignee %}
<span class="task-assignee">👤 {{ item.assignee.full_name or item.assignee.username }}</span>
{% else %}
<span class="task-assignee" style="color:#6e7681;">— не назначено</span>
{% endif %}
{% if task.due_date %}
<span class="task-due">{{ task.due_date|date_ru }}</span>
{% endif %}
{% if perms.can_edit %}
<div class="task-actions">
<a href="/projects/{{ project.id }}/tasks/{{ task.id }}/edit" class="task-action-btn" title="Редактировать">✎</a>
{% if perms.can_manage %}
<form action="/projects/{{ project.id }}/tasks/{{ task.id }}/delete" method="POST" onsubmit="return confirm('Удалить задачу?');" style="display:inline;">
<button type="submit" class="task-action-btn danger" title="Удалить">✕</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if perms.can_edit %}
<button type="button" class="add-task-btn" onclick="openTaskModal('{{ status }}')">+ Добавить задачу</button>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Модалка создания задачи -->
<div class="modal-overlay" id="task-modal">
<div class="modal">
<h2>Новая задача</h2>
@ -185,7 +165,9 @@
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const PROJECT_ID = {{ project.id }};
const CAN_EDIT = {{ 'true' if perms.can_edit else 'false' }};
@ -206,7 +188,6 @@ document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeTaskModal();
});
// Drag & drop
let draggedCard = null;
let draggedFromStatus = null;
@ -244,7 +225,6 @@ document.querySelectorAll('.column').forEach(column => {
const taskId = draggedCard.dataset.taskId;
const list = column.querySelector('.task-list');
// Определяем позицию: относительно других карточек
let position = list.children.length;
const cards = Array.from(list.querySelectorAll('.task-card'));
const mouseY = e.clientY;
@ -253,13 +233,11 @@ document.querySelectorAll('.column').forEach(column => {
if (mouseY < rect.top + rect.height / 2) { position = i; break; }
}
// Оптимистичное обновление UI
const insertBefore = position < cards.length ? cards[position] : null;
list.insertBefore(draggedCard, insertBefore);
draggedCard.dataset.status = newStatus;
updateCounts();
// Отправка на сервер
try {
const res = await fetch(`/projects/${PROJECT_ID}/tasks/${taskId}/move`, {
method: 'POST',
@ -284,5 +262,4 @@ function updateCounts() {
});
}
</script>
</body>
</html>
{% endblock %}

View File

@ -1,355 +1,354 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ project.title }} — Morozovka Portal</title>
{% extends "base.html" %}
{% block title %}{{ project.title }} — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
{% block head %}
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #58a6ff; margin: 0 0 10px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 20px; }
.header-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid #30363d; margin-bottom: 25px; }
.tab { padding: 10px 20px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; }
.tab:hover { color: #e6edf3; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge { background: #30363d; color: #e6edf3; padding: 2px 8px; border-radius: 10px; font-size: .75rem; margin-left: 6px; }
.badges { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
.badge { padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; text-transform: uppercase; }
.status-in_progress { background: #1f6feb; color: white; }
.status-idea { background: #30363d; color: white; }
.status-completed { background: #238636; color: white; }
.status-frozen { background: #da3633; color: white; }
.vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; }
.vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; }
.vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; }
.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.tag { font-size: .8rem; background: rgba(88,166,255,.1); color: #58a6ff; padding: 3px 10px; border-radius: 12px; border: 1px solid rgba(88,166,255,.3); }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-bottom: 25px; padding: 15px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; }
.meta-item .label { color: #8b949e; font-size: .75rem; text-transform: uppercase; margin-bottom: 4px; }
.meta-item .value { color: #e6edf3; font-size: 1rem; }
.desc { font-size: 1.05rem; line-height: 1.7; color: #c9d1d9; margin-bottom: 30px; white-space: pre-wrap; }
.repo-btn { display: inline-block; background: #21262d; color: #58a6ff; text-decoration: none; padding: 10px 20px; border-radius: 5px; border: 1px solid #30363d; font-weight: bold; margin-bottom: 30px; }
.repo-btn:hover { background: #30363d; }
.section-title { border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; color: #e6edf3; font-size: 1.2rem; display: flex; justify-content: space-between; align-items: center; }
.section-title .count { color: #8b949e; font-size: .85rem; font-weight: normal; }
.members-list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 25px; }
.member { background: #161b22; border: 1px solid #30363d; padding: 12px 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.member-info { flex: 1; min-width: 0; }
.member-name { font-weight: bold; overflow: hidden; text-overflow: ellipsis; }
.member-role-badge { font-size: 0.7rem; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; display: inline-block; margin-top: 4px; }
.role-manager { background: #d29922; color: #000; }
.role-editor { background: #1f6feb; color: #fff; }
.role-viewer { background: #30363d; color: #e6edf3; }
.member-actions { display: flex; gap: 5px; align-items: center; flex-shrink: 0; }
.role-select { padding: 4px 6px; background: #0d1117; border: 1px solid #30363d; color: #e6edf3; border-radius: 4px; font-size: .8rem; }
.btn { background: #238636; color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer; font-weight: bold; text-decoration: none; display: inline-block; font-size: .9rem; }
.btn:hover { background: #2ea043; }
.btn-secondary { background: #21262d; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
.btn-danger { background: #da3633; }
.btn-danger:hover { background: #f85149; }
.btn-sm { padding: 5px 10px; font-size: .8rem; }
.add-form { background: #161b22; border: 1px solid #30363d; padding: 15px; border-radius: 8px; margin-bottom: 30px; }
.add-form form { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
.add-form .form-group { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 140px; }
.add-form label { color: #8b949e; font-size: .8rem; }
.add-form input, .add-form select { padding: 8px 10px; background: #0d1117; border: 1px solid #30363d; border-radius: 5px; color: #fff; font-size: .9rem; }
.add-form input:focus, .add-form select:focus { outline: none; border-color: #58a6ff; }
.files-list { background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
.file-row { display: flex; align-items: center; gap: 15px; padding: 12px 15px; border-bottom: 1px solid #30363d; }
.file-row:last-child { border-bottom: none; }
.file-row:hover { background: #1c2128; }
.file-info { flex: 1; min-width: 0; }
.file-name { color: #e6edf3; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.file-meta { color: #8b949e; font-size: .8rem; margin-top: 2px; }
.file-actions { display: flex; gap: 8px; flex-shrink: 0; }
.file-actions a { color: #58a6ff; text-decoration: none; font-size: .9rem; }
.file-actions a:hover { text-decoration: underline; }
.empty-state { text-align: center; padding: 30px; color: #8b949e; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
/* Gitea block */
.gitea-block { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 30px; }
.gitea-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.gitea-title { color: #e6edf3; font-weight: 700; font-size: 1.05rem; }
.gitea-title a { color: #58a6ff; text-decoration: none; }
.gitea-stats-row { display: flex; gap: 20px; margin-bottom: 15px; flex-wrap: wrap; }
.gitea-stat { display: flex; align-items: center; gap: 6px; color: #8b949e; font-size: .9rem; }
.gitea-stat strong { color: #e6edf3; }
.commits-list { display: flex; flex-direction: column; gap: 8px; }
.commit-row { display: flex; gap: 12px; padding: 8px 12px; background: #0d1117; border-radius: 6px; border: 1px solid #30363d; align-items: center; }
.commit-sha { color: #58a6ff; font-family: monospace; font-size: .8rem; text-decoration: none; flex-shrink: 0; }
.commit-sha:hover { text-decoration: underline; }
.commit-msg { flex: 1; color: #c9d1d9; font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.commit-author { color: #8b949e; font-size: .8rem; flex-shrink: 0; }
.gitea-loading { color: #8b949e; padding: 15px 0; font-size: .9rem; }
.gitea-error { color: #f85149; padding: 15px 0; font-size: .9rem; }
.breadcrumbs { color: #8b949e; font-size: 13px; margin-bottom: 16px; }
.breadcrumbs a { color: #8b949e; }
.breadcrumbs a:hover { color: #58a6ff; }
.ws-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.ws-header-left { flex: 1; }
.ws-title { font-size: 24px; font-weight: 600; margin: 0 0 12px; color: #e6edf3; }
.badges { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.badge { display: inline-block; padding: 0 7px; border-radius: 2em; font-size: 12px; font-weight: 500; line-height: 18px; border: 1px solid; }
.status-in_progress { background: rgba(31,111,235,0.15); color: #1f6feb; border-color: rgba(31,111,235,0.4); }
.status-idea { background: rgba(139,148,158,0.15); color: #8b949e; border-color: rgba(139,148,158,0.4); }
.status-completed { background: rgba(35,134,54,0.15); color: #3fb950; border-color: rgba(35,134,54,0.4); }
.status-frozen { background: rgba(218,54,51,0.15); color: #f85149; border-color: rgba(218,54,51,0.4); }
.vis-public { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.vis-internal { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.vis-private { background: rgba(248,81,73,0.15); color: #f85149; border-color: rgba(248,81,73,0.4); }
.ws-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.layout { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
.section { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-bottom: 16px; }
.section h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; color: #e6edf3; padding-bottom: 8px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.section h2 .counter { color: #8b949e; font-size: 13px; font-weight: normal; }
.section-content { color: #c9d1d9; white-space: pre-wrap; line-height: 1.6; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid #30363d; margin-bottom: 20px; }
.tab { padding: 8px 16px; color: #8b949e; text-decoration: none; border-bottom: 2px solid transparent; font-weight: 600; font-size: 14px; }
.tab:hover { color: #e6edf3; text-decoration: none; }
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
.tab .badge-count { background: #30363d; color: #e6edf3; padding: 0 6px; border-radius: 10px; font-size: 11px; margin-left: 4px; font-weight: 500; }
.post-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 14px 16px; margin-bottom: 12px; position: relative; }
.post-card:last-child { margin-bottom: 0; }
.post-card.pinned { border-color: #d29922; 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: #e6edf3; margin: 0 0 4px; }
.post-title .pin-mark { color: #d29922; margin-right: 4px; font-size: 12px; }
.post-meta { color: #8b949e; font-size: 12px; }
.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 #21262d; }
.post-attachment { display: flex; align-items: center; gap: 8px; padding: 4px 8px; background: #161b22; border-radius: 4px; font-size: 12px; }
.post-attachment a { color: #58a6ff; }
.post-image-wrap { margin: 6px 0; }
.post-image-wrap img { max-width: 100%; max-height: 400px; border-radius: 6px; border: 1px solid #30363d; display: block; cursor: zoom-in; }
.post-image-name { color: #6e7681; 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: #8b949e; cursor: pointer; padding: 2px 6px; font-size: 12px; border-radius: 3px; }
.post-actions button:hover { background: #30363d; color: #e6edf3; }
.post-actions .del:hover { color: #f85149; }
.post-form-card { background: #0d1117; border: 1px dashed #30363d; border-radius: 6px; padding: 14px 16px; margin-top: 12px; }
.post-form-row { display: grid; grid-template-columns: 1fr; gap: 8px; margin-bottom: 8px; }
.post-form-row label { color: #8b949e; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.post-form-row input, .post-form-row textarea { padding: 5px 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 13px; font-family: inherit; width: 100%; }
.post-form-row textarea { min-height: 80px; resize: vertical; }
.post-form-row input:focus, .post-form-row textarea:focus { outline: none; border-color: #58a6ff; }
.post-form-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.post-form-actions input[type=file] { font-size: 12px; color: #8b949e; }
.post-form-actions button { padding: 5px 14px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 13px; }
.post-form-actions button:hover { background: #2ea043; }
.checkbox-line { display: flex; align-items: center; gap: 6px; color: #8b949e; font-size: 12px; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 20px; padding: 14px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; }
.meta-item .label { color: #8b949e; font-size: 11px; text-transform: uppercase; margin-bottom: 3px; font-weight: 600; }
.meta-item .value { color: #e6edf3; font-size: 14px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.tag { font-size: 12px; background: rgba(88,166,255,.1); color: #58a6ff; padding: 2px 10px; border-radius: 12px; border: 1px solid rgba(88,166,255,.3); }
.member-row { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px 14px; margin-bottom: 10px; }
.member-row:last-child { margin-bottom: 0; }
.member-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
.member-name { font-weight: 600; color: #e6edf3; }
.member-name .username { color: #8b949e; font-weight: normal; font-size: 12px; margin-left: 4px; }
.role-pill { font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600; text-transform: uppercase; }
.role-manager { background: #d29922; color: #000; }
.role-editor { background: #1f6feb; color: #fff; }
.role-viewer { background: #30363d; color: #e6edf3; }
.role-owner { background: #a371f7; color: #fff; }
.perm-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px; margin-top: 6px; }
.perm-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #c9d1d9; }
.perm-item input { width: 14px; height: 14px; }
.perm-actions { display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
.add-form { background: #0d1117; border: 1px solid #30363d; padding: 12px; border-radius: 6px; margin-bottom: 12px; }
.add-form form { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; }
.add-form .form-group { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 140px; }
.add-form label { color: #8b949e; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.add-form input, .add-form select { padding: 5px 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 13px; font-family: inherit; }
.add-form input:focus, .add-form select:focus { outline: none; border-color: #58a6ff; }
.files-list { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
.file-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #21262d; }
.file-row:last-child { border-bottom: none; }
.file-row:hover { background: #161b22; }
.file-info { flex: 1; min-width: 0; }
.file-name { color: #e6edf3; font-weight: 500; font-size: 13px; overflow: hidden; text-overflow: ellipsis; }
.file-meta { color: #8b949e; font-size: 11px; margin-top: 2px; }
.file-actions { display: flex; gap: 8px; flex-shrink: 0; }
.file-actions a { color: #58a6ff; font-size: 12px; }
.sub-btn { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; padding: 8px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 14px; border: 1px solid; }
.sub-btn.on { background: rgba(88,166,255,0.15); border-color: #58a6ff; color: #58a6ff; }
.sub-btn.on:hover { background: rgba(88,166,255,0.25); }
.sub-btn.off { background: #21262d; border-color: #30363d; color: #c9d1d9; }
.sub-btn.off:hover { background: #30363d; }
.empty-small { color: #8b949e; font-size: 13px; padding: 12px; text-align: center; background: #0d1117; border: 1px dashed #30363d; border-radius: 6px; }
.repo-btn { display: inline-block; background: #21262d; color: #58a6ff; text-decoration: none; padding: 8px 16px; border-radius: 5px; border: 1px solid #30363d; font-weight: 500; margin-bottom: 16px; font-size: 13px; }
.repo-btn:hover { background: #30363d; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/projects" class="back-link">← К проектам</a>
{% endblock %}
<div class="header">
<div>
<h1>{{ project.title }}</h1>
<div class="badges">
<span class="badge status-{{ project.status }}">
{% if project.status == 'idea' %}Идея
{% elif project.status == 'in_progress' %}В работе
{% elif project.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %}
</span>
<span class="badge vis-{{ project.visibility }}">
{% if project.visibility == 'public' %}Публичный
{% elif project.visibility == 'internal' %}Внутренний
{% else %}Приватный{% endif %}
</span>
</div>
{% block content %}
<div class="breadcrumbs"><a href="/projects">Проекты</a> / {{ project.title }}</div>
<div class="ws-header">
<div class="ws-header-left">
<h1 class="ws-title">{{ project.title }}</h1>
<div class="badges">
<span class="badge status-{{ project.status }}">
{% if project.status == 'idea' %}Идея
{% elif project.status == 'in_progress' %}В работе
{% elif project.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %}
</span>
<span class="badge vis-{{ project.visibility }}">
{% if project.visibility == 'public' %}Публичный
{% elif project.visibility == 'internal' %}Внутренний
{% else %}Приватный{% endif %}
</span>
</div>
<div class="header-actions">
{% if perms.can_edit %}
<a href="/projects/{{ project.id }}/edit" class="btn btn-secondary">Редактировать</a>
</div>
<div class="ws-actions">
{% if perms.can_manage_members or perms.is_admin or perms.is_owner %}
<a href="/projects/{{ project.id }}/edit" class="btn btn-default">✎ Редактировать</a>
{% endif %}
{% if perms.can_delete %}
<form action="/projects/{{ project.id }}/delete" method="POST" onsubmit="return confirm('Удалить проект?');" style="display:inline;">
<button type="submit" class="btn btn-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
<div class="tabs">
<a href="/projects/{{ project.id }}" class="tab active">Обзор</a>
<a href="/projects/{{ project.id }}/board" class="tab">Доска {% if task_counts %}<span class="badge-count">{{ task_counts.values()|sum }}</span>{% endif %}</a>
<a href="#files" class="tab">Файлы <span class="badge-count">{{ files|length }}</span></a>
</div>
<div class="layout">
<div>
{% if project.tags %}
<div class="tags">{% for t in project.tags.split(',') %}<span class="tag">#{{ t.strip() }}</span>{% endfor %}</div>
{% endif %}
<div class="meta-grid">
<div class="meta-item"><div class="label">Начало</div><div class="value">{{ project.start_date|date_ru or '—' }}</div></div>
<div class="meta-item"><div class="label">Завершение</div><div class="value">{{ project.end_date|date_ru or '—' }}</div></div>
<div class="meta-item"><div class="label">Контакт</div><div class="value">{{ project.contact_person or '—' }}</div></div>
<div class="meta-item"><div class="label">Ваша роль</div><div class="value">{{ perms.project_role or '—' }}</div></div>
</div>
<div class="section"><h2>Описание</h2><div class="section-content">{{ project.description or 'Описание пока не добавлено.' }}</div></div>
{% if project.gitea_url %}
<a href="{{ project.gitea_url }}" target="_blank" class="repo-btn">📦 Репозиторий Gitea</a>
{% endif %}
<div class="section">
<h2>📢 Лента <span class="counter">{{ posts|length }}</span></h2>
{% if posts %}
{% for p in posts %}
<div class="post-card{% if p.is_pinned %} pinned{% endif %}">
<div class="post-head">
<div style="flex:1;min-width:0;">
<div class="post-title">{% if p.is_pinned %}<span class="pin-mark">📌</span>{% endif %}{{ p.title }}</div>
<div class="post-meta">
{% set au = authors_map.get(p.author_id) %}
{% if au %}{{ au.full_name or au.username }}{% endif %}
· {{ p.created_at|msk_strftime('%d.%m.%Y %H:%M') }}
</div>
</div>
{% if perms.can_edit_any_post or (perms.can_edit_own_posts and p.author_id == user_id) %}
<div class="post-actions">
<button type="button" onclick="toggleEdit({{ p.id }})">✎</button>
<form action="/projects/{{ project.id }}/posts/{{ p.id }}/delete" method="POST" onsubmit="return confirm('Удалить пост?');">
<button type="submit" class="del">✕</button>
</form>
</div>
{% endif %}
</div>
{% if p.body %}<div class="post-body">{{ p.body }}</div>{% endif %}
{% if attachments_by_post.get(p.id) %}
<div class="post-attachments">
{% for a in attachments_by_post.get(p.id) %}
{% set f = files_map.get(a.file_id) %}
{% if f %}
{% if f.original_name|is_image %}
<div class="post-image-wrap">
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
<div class="post-image-name">{{ f.original_name }} · {{ (f.size_bytes/1024)|round(1) }} КБ</div>
</div>
{% else %}
<div class="post-attachment">{{ f.original_name|file_icon }} <a href="/files/download/{{ f.id }}">{{ f.original_name }}</a> <span style="color:#6e7681;">({{ (f.size_bytes/1024)|round(1) }} КБ)</span></div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if perms.can_edit_any_post or (perms.can_edit_own_posts and p.author_id == user_id) %}
<form id="edit-post-{{ p.id }}" action="/projects/{{ project.id }}/posts/{{ p.id }}/edit" method="POST" style="display:none;margin-top:10px;">
<div class="post-form-row"><label>Заголовок</label><input type="text" name="title" value="{{ p.title }}" required></div>
<div class="post-form-row"><label>Текст</label><textarea name="body">{{ p.body or '' }}</textarea></div>
<div class="post-form-actions">
{% if perms.can_edit_any_post %}
<label class="checkbox-line"><input type="checkbox" name="is_pinned" {% if p.is_pinned %}checked{% endif %}> Закрепить</label>
{% endif %}
<button type="submit">Сохранить</button>
<button type="button" class="btn btn-default" onclick="toggleEdit({{ p.id }})">Отмена</button>
</div>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-small">Пока нет постов. Начните первым.</div>
{% endif %}
{% if perms.can_delete %}
<form action="/projects/{{ project.id }}/delete" method="POST" onsubmit="return confirm('Удалить проект?');" style="display:inline;">
<button type="submit" class="btn btn-danger">Удалить</button>
{% if perms.can_post_feed %}
<form action="/projects/{{ project.id }}/posts/new" method="POST" enctype="multipart/form-data" class="post-form-card">
<div class="post-form-row"><label>Заголовок *</label><input type="text" name="title" required maxlength="200"></div>
<div class="post-form-row"><label>Текст</label><textarea name="body"></textarea></div>
<div class="post-form-actions">
<input type="file" name="files" multiple>
{% if perms.can_edit_any_post %}
<label class="checkbox-line"><input type="checkbox" name="is_pinned"> Закрепить</label>
{% endif %}
<button type="submit">Опубликовать</button>
</div>
</form>
{% endif %}
</div>
</div>
<div class="tabs">
<a href="/projects/{{ project.id }}" class="tab active">Обзор</a>
<a href="/projects/{{ project.id }}/board" class="tab">
Доска
{% if task_counts %}
<span class="badge">{{ task_counts.values()|sum }}</span>
<div class="section">
<h2>Участники <span class="counter">{{ members|length }}</span></h2>
{% if perms.can_manage_members %}
<div class="add-form">
<form action="/projects/{{ project.id }}/members/add" method="POST">
<div class="form-group"><label>Логин</label><input type="text" name="username" placeholder="username" required></div>
<div class="form-group" style="flex:0.5;"><label>Роль</label>
<select name="role"><option value="viewer">Viewer</option><option value="editor" selected>Editor</option><option value="manager">Manager</option></select>
</div>
<button type="submit" class="btn btn-primary">+</button>
</form>
</div>
{% endif %}
</a>
<a href="#files" class="tab">Файлы <span class="badge">{{ files|length }}</span></a>
</div>
{% if project.tags %}
<div class="tags">
{% for t in project.tags.split(',') %}<span class="tag">#{{ t.strip() }}</span>{% endfor %}
</div>
{% endif %}
<div class="meta-grid">
<div class="meta-item">
<div class="label">Начало</div>
<div class="value">{{ project.start_date|date_ru or '—' }}</div>
</div>
<div class="meta-item">
<div class="label">Завершение</div>
<div class="value">{{ project.end_date|date_ru or '—' }}</div>
</div>
<div class="meta-item">
<div class="label">Контакт</div>
<div class="value">{{ project.contact_person or '—' }}</div>
</div>
<div class="meta-item">
<div class="label">Ваша роль</div>
<div class="value">
{% if perms.project_role == 'manager' %}Manager
{% elif perms.project_role == 'editor' %}Editor
{% elif perms.project_role == 'viewer' %}Viewer
{% else %}—{% endif %}
{% for member_obj, user in members %}
<div class="member-row">
<div class="member-head">
<div>
<span class="member-name">{{ user.full_name or user.username }} <span class="username">@{{ user.username }}</span></span>
{% if user.id == project.owner_id %}
<span class="role-pill role-owner">Владелец</span>
{% else %}
<span class="role-pill role-{{ member_obj.role }}">{{ member_obj.role }}</span>
{% endif %}
</div>
{% if perms.can_manage_members and user.id != project.owner_id %}
<form action="/projects/{{ project.id }}/members/{{ user.id }}/remove" method="POST" onsubmit="return confirm('Удалить участника?');">
<button type="submit" class="btn btn-danger btn-sm">✕</button>
</form>
{% endif %}
</div>
{% if perms.can_manage_members and user.id != project.owner_id %}
<form action="/projects/{{ project.id }}/members/{{ user.id }}/permissions" method="POST">
<div class="perm-grid">
<label class="perm-item"><input type="checkbox" name="can_view_files" {% if member_obj.can_view_files %}checked{% endif %}> 📁 Видеть файлы</label>
<label class="perm-item"><input type="checkbox" name="can_edit_files" {% if member_obj.can_edit_files %}checked{% endif %}> 📁✎ Загружать/удалять файлы</label>
<label class="perm-item"><input type="checkbox" name="can_view_repo" {% if member_obj.can_view_repo %}checked{% endif %}> 📦 Видеть репо</label>
<label class="perm-item"><input type="checkbox" name="can_post_feed" {% if member_obj.can_post_feed %}checked{% endif %}> 📢 Писать в ленту</label>
<label class="perm-item"><input type="checkbox" name="can_edit_own_posts" {% if member_obj.can_edit_own_posts %}checked{% endif %}> ✎ Редактировать свои посты</label>
<label class="perm-item"><input type="checkbox" name="can_edit_any_post" {% if member_obj.can_edit_any_post %}checked{% endif %}> ✎ Редактировать любые посты</label>
<label class="perm-item"><input type="checkbox" name="can_manage_members" {% if member_obj.can_manage_members %}checked{% endif %}> 👥 Управлять участниками</label>
</div>
<div class="perm-actions">
<button type="submit" class="btn btn-default btn-sm">Сохранить права</button>
</div>
</form>
{% endif %}
</div>
{% endfor %}
</div>
<div class="section" id="files">
<h2>Файлы <span class="counter">{{ files|length }}</span></h2>
{% if perms.can_upload %}
<div class="add-form">
<form action="/projects/{{ project.id }}/files/upload" method="POST" enctype="multipart/form-data">
<div class="form-group"><label>Загрузить файл (до 100 МБ)</label><input type="file" name="file" required></div>
<button type="submit" class="btn btn-primary">Загрузить</button>
</form>
</div>
{% endif %}
{% if files %}
<div class="files-list">
{% for file, uploader in files %}
<div class="file-row">
<div class="file-info">
<div class="file-name">{{ file.original_name|file_icon }} {{ file.original_name }}</div>
<div class="file-meta">{{ (file.size_bytes/1024/1024)|round(2) }} МБ · {{ uploader }} · {{ file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}</div>
</div>
<div class="file-actions">
<a href="/files/download/{{ file.id }}">Скачать</a>
{% if perms.can_edit_files or perms.is_admin or file.uploaded_by == user_id %}
<form action="/files/{{ file.id }}/delete" method="POST" onsubmit="return confirm('Удалить?');" style="display:inline;">
<input type="hidden" name="next" value="/projects/{{ project.id }}">
<button type="submit" class="btn btn-danger btn-sm">✕</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-small">Файлов нет.{% if perms.can_upload %} Загрузите первый.{% endif %}</div>
{% endif %}
</div>
</div>
<div class="desc">{{ project.description or 'Описание пока не добавлено.' }}</div>
{% if project.gitea_url %}
<a href="{{ project.gitea_url }}" target="_blank" class="repo-btn">📦 Перейти в репозиторий Gitea</a>
{% endif %}
<!-- Gitea stats block (lazy-loaded) -->
{% if project.gitea_url or project.gitea_repo %}
<div class="gitea-block" id="gitea-block">
<div class="gitea-loading">Загружаю статистику из Gitea...</div>
</div>
{% endif %}
<h2 class="section-title">Участники <span class="count">{{ members|length }}</span></h2>
{% if perms.can_manage %}
<div class="add-form">
<form action="/projects/{{ project.id }}/members/add" method="POST">
<div class="form-group">
<label>Логин пользователя</label>
<input type="text" name="username" placeholder="например, testuser" required>
</div>
<div class="form-group" style="flex: 0.5;">
<label>Роль</label>
<select name="role">
<option value="viewer">Viewer (только чтение)</option>
<option value="editor" selected>Editor (может редактировать)</option>
<option value="manager">Manager (полные права)</option>
</select>
</div>
<button type="submit" class="btn">Добавить</button>
</form>
</div>
{% endif %}
<div class="members-list">
{% for member_obj, user in members %}
<div class="member">
<div class="member-info">
<div class="member-name">{{ user.full_name or user.username }} <span style="color: #6e7681; font-weight: normal; font-size: .85rem;">@{{ user.username }}</span></div>
{% if user.id == project.owner_id %}
<span class="member-role-badge role-manager">Владелец</span>
<div>
{% if user_id %}
<div class="section">
<h2>Подписка</h2>
<form action="/projects/{{ project.id }}/subscribe" method="POST">
{% if my_sub %}
<button type="submit" class="sub-btn on">🔔 Вы подписаны</button>
{% else %}
<span class="member-role-badge role-{{ member_obj.role }}">
{% if member_obj.role == 'manager' %}Manager
{% elif member_obj.role == 'editor' %}Editor
{% else %}Viewer{% endif %}
</span>
<button type="submit" class="sub-btn off">🔔 Подписаться на новости</button>
{% endif %}
</div>
{% if perms.can_manage and user.id != project.owner_id %}
<div class="member-actions">
<form action="/projects/{{ project.id }}/members/{{ user.id }}/role" method="POST" style="display:flex; gap: 5px;">
<select name="role" class="role-select">
<option value="viewer" {% if member_obj.role == 'viewer' %}selected{% endif %}>Viewer</option>
<option value="editor" {% if member_obj.role == 'editor' %}selected{% endif %}>Editor</option>
<option value="manager" {% if member_obj.role == 'manager' %}selected{% endif %}>Manager</option>
</select>
<button type="submit" class="btn btn-sm btn-secondary">OK</button>
</form>
<form action="/projects/{{ project.id }}/members/{{ user.id }}/remove" method="POST" onsubmit="return confirm('Удалить участника?');">
<button type="submit" class="btn btn-sm btn-danger">✕</button>
</form>
</div>
{% endif %}
</form>
</div>
{% endfor %}
{% endif %}
</div>
<h2 class="section-title" id="files">Файлы проекта <span class="count">{{ files|length }}</span></h2>
{% if perms.can_upload %}
<div class="add-form">
<form action="/projects/{{ project.id }}/files/upload" method="POST" enctype="multipart/form-data">
<div class="form-group">
<label>Загрузить файл (макс 100 МБ)</label>
<input type="file" name="file" required>
</div>
<button type="submit" class="btn">Загрузить</button>
</form>
</div>
{% endif %}
{% if files %}
<div class="files-list">
{% for file, uploader in files %}
<div class="file-row">
<div class="file-info">
<div class="file-name">{{ file.original_name }}</div>
<div class="file-meta">{{ (file.size_bytes / 1024 / 1024) | round(2) }} МБ · {{ uploader }} · {{ file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}</div>
</div>
<div class="file-actions">
<a href="/files/download/{{ file.id }}">Скачать</a>
{% if perms.can_manage or file.uploaded_by == user_id %}
<form action="/files/{{ file.id }}/delete" method="POST" onsubmit="return confirm('Удалить файл?');" style="display:inline;">
<input type="hidden" name="next" value="/projects/{{ project.id }}">
<button type="submit" class="btn btn-sm btn-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="files-list">
<div class="empty-state">Файлов в проекте пока нет.{% if perms.can_upload %} Загрузите первый файл выше.{% endif %}</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
(function() {
const block = document.getElementById('gitea-block');
if (!block) return;
const PROJECT_ID = {{ project.id }};
async function loadGiteaStats() {
try {
const res = await fetch(`/projects/${PROJECT_ID}/gitea-stats`, { headers: { 'Accept': 'application/json' } });
const data = await res.json();
renderGitea(data);
} catch (e) {
block.innerHTML = '<div class="gitea-error">Ошибка загрузки статистики Gitea</div>';
}
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
const dd = String(d.getDate()).padStart(2,'0');
const mm = String(d.getMonth()+1).padStart(2,'0');
const yy = String(d.getFullYear()).slice(-2);
const hh = String(d.getHours()).padStart(2,'0');
const mn = String(d.getMinutes()).padStart(2,'0');
return `${dd}.${mm}.${yy} ${hh}:${mn}`;
}
function renderGitea(data) {
if (!data.ok) {
block.innerHTML = `<div class="gitea-error">${escapeHtml(data.message || 'Не удалось получить данные')}</div>`;
return;
}
const repo = data.repo;
let html = '';
html += '<div class="gitea-header">';
html += '<div class="gitea-title">📦 ';
if (repo.html_url) {
html += `<a href="${escapeHtml(repo.html_url)}" target="_blank">${escapeHtml(repo.full_name)}</a>`;
} else {
html += escapeHtml(repo.full_name);
}
if (repo.is_private) html += ' <span class="tag" style="font-size:.7rem;">private</span>';
html += '</div></div>';
html += '<div class="gitea-stats-row">';
html += `<div class="gitea-stat">🐛 Open issues: <strong>${data.open_issues}</strong></div>`;
html += `<div class="gitea-stat">🔀 Open PRs: <strong>${data.open_prs}</strong></div>`;
html += `<div class="gitea-stat">🌿 Branch: <strong>${escapeHtml(repo.default_branch)}</strong></div>`;
if (repo.stars) html += `<div class="gitea-stat">⭐ <strong>${repo.stars}</strong></div>`;
if (repo.forks) html += `<div class="gitea-stat">🍴 <strong>${repo.forks}</strong></div>`;
html += '</div>';
if (data.commits && data.commits.length) {
html += '<div style="color:#8b949e;font-size:.8rem;text-transform:uppercase;margin-bottom:8px;font-weight:600;">Последние коммиты</div>';
html += '<div class="commits-list">';
for (const c of data.commits) {
const shaLink = c.html_url ? `<a href="${escapeHtml(c.html_url)}" target="_blank" class="commit-sha">${escapeHtml(c.sha_short)}</a>` : `<span class="commit-sha">${escapeHtml(c.sha_short)}</span>`;
html += '<div class="commit-row">';
html += shaLink;
html += `<div class="commit-msg" title="${escapeHtml(c.message)}">${escapeHtml(c.message)}</div>`;
html += `<div class="commit-author">${escapeHtml(c.author)} · ${fmtDate(c.date)}</div>`;
html += '</div>';
}
html += '</div>';
}
block.innerHTML = html;
}
loadGiteaStats();
})();
function toggleEdit(postId) {
var el = document.getElementById('edit-post-' + postId);
if (el) el.style.display = (el.style.display === 'none' || !el.style.display) ? 'block' : 'none';
}
</script>
</body>
</html>
{% endblock %}

View File

@ -1,98 +1,82 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.container { max-width: 640px; margin: 0 auto; }
h2 { color: #58a6ff; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.9rem; }
input, select, textarea { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; font-size: 1rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { resize: vertical; min-height: 100px; }
.hint { color: #6e7681; font-size: .8rem; margin-top: 4px; }
button { padding: 12px 24px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 1rem; margin-top: 10px; }
button:hover:not(:disabled) { background: #2ea043; }
button:disabled { background: #30363d; color: #8b949e; cursor: not-allowed; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
</style>
</head>
<body>
<div class="container">
<a href="{{ '/projects/' + project.id|string if is_edit else '/projects' }}" class="back-link">← Назад</a>
<h2>{{ 'Редактирование проекта' if is_edit else 'Создание нового проекта' }}</h2>
<form method="POST" id="project-form">
{% extends "base.html" %}
{% block title %}{{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
{% block content %}
<a href="{{ '/projects/' + project.id|string if is_edit else '/projects' }}" class="back-link" style="display:block;margin-bottom:20px;color:#8b949e;text-decoration:none;">← Назад</a>
<h2 class="page-title">{{ 'Редактирование проекта' if is_edit else 'Создание нового проекта' }}</h2>
<form method="POST" id="project-form" class="form-card">
<div class="form-group">
<label>Название проекта <span class="req">*</span></label>
<input type="text" name="title" value="{{ project.title if is_edit else '' }}" required>
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ project.description if is_edit else '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Название проекта *</label>
<input type="text" name="title" value="{{ project.title if is_edit else '' }}" required>
<label>Статус</label>
<select name="status">
<option value="idea" {% if is_edit and project.status == 'idea' %}selected{% endif %}>Идея</option>
<option value="in_progress" {% if is_edit and project.status == 'in_progress' %}selected{% endif %}>В работе</option>
<option value="completed" {% if is_edit and project.status == 'completed' %}selected{% endif %}>Завершён</option>
<option value="frozen" {% if is_edit and project.status == 'frozen' %}selected{% endif %}>Заморожен</option>
</select>
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ project.description if is_edit else '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Статус</label>
<select name="status">
<option value="idea" {% if is_edit and project.status == 'idea' %}selected{% endif %}>Идея</option>
<option value="in_progress" {% if is_edit and project.status == 'in_progress' %}selected{% endif %}>В работе</option>
<option value="completed" {% if is_edit and project.status == 'completed' %}selected{% endif %}>Завершён</option>
<option value="frozen" {% if is_edit and project.status == 'frozen' %}selected{% endif %}>Заморожен</option>
</select>
</div>
<div class="form-group">
<label>Видимость</label>
<select name="visibility">
<option value="public" {% if is_edit and project.visibility == 'public' %}selected{% endif %}>Публичный (видят все)</option>
<option value="internal" {% if is_edit and project.visibility == 'internal' %}selected{% endif %}>Внутренний (только для своих)</option>
<option value="private" {% if is_edit and project.visibility == 'private' %}selected{% endif %}>Приватный (только участники)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Дата начала</label>
<input type="date" name="start_date" value="{{ project.start_date if is_edit and project.start_date else '' }}">
</div>
<div class="form-group">
<label>Дата завершения</label>
<input type="date" name="end_date" value="{{ project.end_date if is_edit and project.end_date else '' }}">
</div>
<label>Видимость</label>
<select name="visibility">
<option value="public" {% if is_edit and project.visibility == 'public' %}selected{% endif %}>Публичный (видят все)</option>
<option value="internal" {% if is_edit and project.visibility == 'internal' %}selected{% endif %}>Внутренний (только для своих)</option>
<option value="private" {% if is_edit and project.visibility == 'private' %}selected{% endif %}>Приватный (только участники)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Контактное лицо</label>
<input type="text" name="contact_person" placeholder="Имя, email или Telegram" value="{{ project.contact_person if is_edit and project.contact_person else '' }}">
<label>Дата начала</label>
<input type="date" name="start_date" value="{{ project.start_date if is_edit and project.start_date else '' }}">
</div>
<div class="form-group">
<label>Теги технологий</label>
<input type="text" name="tags" placeholder="python, flask, iot, embedded" value="{{ project.tags if is_edit and project.tags else '' }}">
<div class="hint">Через запятую. Сохраняются в нижнем регистре, без дублей.</div>
<label>Дата завершения</label>
<input type="date" name="end_date" value="{{ project.end_date if is_edit and project.end_date else '' }}">
</div>
</div>
<div class="form-group">
<label>Ссылка на репозиторий Gitea</label>
<input type="url" name="gitea_url" placeholder="https://git.morozovka.space/user/repo" value="{{ project.gitea_url if is_edit and project.gitea_url else '' }}">
</div>
<div class="form-group">
<label>Контактное лицо</label>
<input type="text" name="contact_person" placeholder="Имя, email или Telegram" value="{{ project.contact_person if is_edit and project.contact_person else '' }}">
</div>
<div class="form-group">
<label>Репозиторий для Gitea API (owner/repo)</label>
<input type="text" name="gitea_repo" placeholder="например, IvanBud123/morozovka-portal" value="{{ project.gitea_repo if is_edit and project.gitea_repo else '' }}">
<div class="hint">Можно оставить пустым — автоматически извлечётся из URL выше. Заполняйте вручную, если URL нестандартный.</div>
</div>
<div class="form-group">
<label>Теги технологий</label>
<input type="text" name="tags" placeholder="python, flask, iot, embedded" value="{{ project.tags if is_edit and project.tags else '' }}">
<div class="hint">Через запятую. Сохраняются в нижнем регистре, без дублей.</div>
</div>
<button type="submit" id="submit-btn">{{ 'Сохранить изменения' if is_edit else 'Создать проект' }}</button>
</form>
</div>
<div class="form-group">
<label>Ссылка на репозиторий Gitea</label>
<input type="url" name="gitea_url" placeholder="https://git.morozovka.space/user/repo" value="{{ project.gitea_url if is_edit and project.gitea_url else '' }}">
</div>
<div class="form-group">
<label>Репозиторий для Gitea API (owner/repo)</label>
<input type="text" name="gitea_repo" placeholder="например, IvanBud123/morozovka-portal" value="{{ project.gitea_repo if is_edit and project.gitea_repo else '' }}">
<div class="hint">Можно оставить пустым — автоматически извлечётся из URL выше. Заполняйте вручную, если URL нестандартный.</div>
</div>
<div class="form-actions">
<button type="submit" id="submit-btn" class="btn btn-primary">{{ 'Сохранить изменения' if is_edit else 'Создать проект' }}</button>
<a href="{{ '/projects/' + project.id|string if is_edit else '/projects' }}" class="btn btn-default">Отмена</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
(function() {
const form = document.getElementById('project-form');
@ -112,5 +96,4 @@
});
})();
</script>
</body>
</html>
{% endblock %}

View File

@ -1,30 +1,27 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Проекты — Morozovka Portal</title>
{% extends "base.html" %}
{% block title %}Проекты — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
{% block head %}
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.container { max-width: 1100px; margin: 0 auto; }
h1 { color: #58a6ff; margin-top: 0; }
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.btn-add { background: #238636; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; }
.btn-add:hover { background: #2ea043; }
.btn-add:hover { background: #2ea043; text-decoration: none; }
.back-link { display: block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
.filters { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 15px 20px; margin-bottom: 20px; display: flex; flex-wrap: wrap; gap: 20px; align-items: center; }
.filter-group { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.filter-group-label { color: #8b949e; font-size: .8rem; text-transform: uppercase; font-weight: 600; margin-right: 5px; }
.filter-chip { display: inline-block; padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; color: #8b949e; text-decoration: none; font-size: .85rem; transition: all .15s; }
.filter-chip:hover { border-color: #58a6ff; color: #e6edf3; }
.filter-chip:hover { border-color: #58a6ff; color: #e6edf3; text-decoration: none; }
.filter-chip.active { background: rgba(88,166,255,.15); border-color: #58a6ff; color: #58a6ff; }
.filter-chip.clear { background: transparent; border-color: #f85149; color: #f85149; }
.filter-chip.clear:hover { background: rgba(248,81,73,.15); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; text-decoration: none; color: inherit; transition: border-color 0.2s; }
.card:hover { border-color: #58a6ff; }
.card:hover { border-color: #58a6ff; text-decoration: none; }
.card h3 { margin: 0 0 10px 0; color: #e6edf3; font-size: 1.15rem; }
.card p { color: #8b949e; font-size: 0.9rem; flex-grow: 1; margin-bottom: 15px; line-height: 1.5; }
@ -45,75 +42,73 @@
.repo-link { color: #58a6ff; }
.empty { color: #8b949e; padding: 40px; text-align: center; background: #161b22; border-radius: 8px; border: 1px dashed #30363d; grid-column: 1 / -1; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<div class="header-actions">
<h1>Каталог проектов</h1>
{% if role in ['admin', 'professor', 'lead'] %}
<a href="/projects/new" class="btn-add">+ Добавить проект</a>
{% endif %}
{% endblock %}
{% block content %}
<a href="/" class="back-link">← На главную</a>
<div class="header-actions">
<h1>Каталог проектов</h1>
{% if role in ['admin', 'professor', 'lead'] %}
<a href="/projects/new" class="btn-add">+ Добавить проект</a>
{% endif %}
</div>
<div class="filters">
<div class="filter-group">
<span class="filter-group-label">Статус:</span>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=" class="filter-chip {% if not current_status %}active{% endif %}">Все</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=idea" class="filter-chip {% if current_status == 'idea' %}active{% endif %}">Идея</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=in_progress" class="filter-chip {% if current_status == 'in_progress' %}active{% endif %}">В работе</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=completed" class="filter-chip {% if current_status == 'completed' %}active{% endif %}">Завершён</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=frozen" class="filter-chip {% if current_status == 'frozen' %}active{% endif %}">Заморожен</a>
</div>
<div class="filters">
<div class="filter-group">
<span class="filter-group-label">Статус:</span>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=" class="filter-chip {% if not current_status %}active{% endif %}">Все</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=idea" class="filter-chip {% if current_status == 'idea' %}active{% endif %}">Идея</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=in_progress" class="filter-chip {% if current_status == 'in_progress' %}active{% endif %}">В работе</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=completed" class="filter-chip {% if current_status == 'completed' %}active{% endif %}">Завершён</a>
<a href="?{% if current_tag %}tag={{ current_tag }}&{% endif %}status=frozen" class="filter-chip {% if current_status == 'frozen' %}active{% endif %}">Заморожен</a>
</div>
{% if all_tags %}
<div class="filter-group">
<span class="filter-group-label">Теги:</span>
{% for t in all_tags %}
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}tag={{ t }}" class="filter-chip {% if current_tag == t %}active{% endif %}">#{{ t }}</a>
{% endfor %}
</div>
{% endif %}
{% if current_tag or current_status %}
<div class="filter-group">
<a href="/projects" class="filter-chip clear">✕ Сбросить</a>
</div>
{% endif %}
</div>
<div class="grid">
{% for proj in projects %}
<a href="/projects/{{ proj.id }}" class="card">
<div class="badges">
<span class="badge status-{{ proj.status }}">
{% if proj.status == 'idea' %}Идея
{% elif proj.status == 'in_progress' %}В работе
{% elif proj.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %}
</span>
<span class="badge vis-{{ proj.visibility }}">
{% if proj.visibility == 'public' %}Публичный
{% elif proj.visibility == 'internal' %}Внутренний
{% else %}Приватный{% endif %}
</span>
</div>
<h3>{{ proj.title }}</h3>
<p>{{ proj.description or 'Описание отсутствует.' }}</p>
{% if proj.tags %}
<div class="tags">
{% for t in proj.tags.split(',') %}<span class="tag">#{{ t.strip() }}</span>{% endfor %}
</div>
{% endif %}
<div class="card-footer">
<span>Подробнее →</span>
{% if proj.gitea_url %}<span class="repo-link">📦 Репозиторий</span>{% endif %}
</div>
</a>
{% else %}
<div class="empty">Проектов, соответствующих фильтрам, пока нет.</div>
{% if all_tags %}
<div class="filter-group">
<span class="filter-group-label">Теги:</span>
{% for t in all_tags %}
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}tag={{ t }}" class="filter-chip {% if current_tag == t %}active{% endif %}">#{{ t }}</a>
{% endfor %}
</div>
{% endif %}
{% if current_tag or current_status %}
<div class="filter-group">
<a href="/projects" class="filter-chip clear">✕ Сбросить</a>
</div>
{% endif %}
</div>
</body>
</html>
<div class="grid">
{% for proj in projects %}
<a href="/projects/{{ proj.id }}" class="card">
<div class="badges">
<span class="badge status-{{ proj.status }}">
{% if proj.status == 'idea' %}Идея
{% elif proj.status == 'in_progress' %}В работе
{% elif proj.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %}
</span>
<span class="badge vis-{{ proj.visibility }}">
{% if proj.visibility == 'public' %}Публичный
{% elif proj.visibility == 'internal' %}Внутренний
{% else %}Приватный{% endif %}
</span>
</div>
<h3>{{ proj.title }}</h3>
<p>{{ proj.description or 'Описание отсутствует.' }}</p>
{% if proj.tags %}
<div class="tags">
{% for t in proj.tags.split(',') %}<span class="tag">#{{ t.strip() }}</span>{% endfor %}
</div>
{% endif %}
<div class="card-footer">
<span>Подробнее →</span>
{% if proj.gitea_url %}<span class="repo-link">📦 Репозиторий</span>{% endif %}
</div>
</a>
{% else %}
<div class="empty">Проектов, соответствующих фильтрам, пока нет.</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -1,59 +1,44 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Регистрация — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 350px; }
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
input, select { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
button:hover { background: #2ea043; }
.error { color: #f85149; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
.links a { color: #58a6ff; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<h2>Регистрация</h2>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="POST">
<div class="form-group">
<label>ФИО</label>
<input type="text" name="full_name" required>
</div>
<div class="form-group">
<label>Организация</label>
<select name="organization">
<option value="ННГУ">ННГУ</option>
<option value="Неймарк">Неймарк</option>
<option value="Ядро">Ядро</option>
<option value="Внешний">Внешний</option>
</select>
</div>
<div class="form-group">
<label>Логин</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" name="password" required minlength="6">
</div>
<button type="submit">Зарегистрироваться</button>
</form>
<div class="links">
<a href="/login">Уже есть аккаунт? Войти</a>
{% extends "base.html" %}
{% block title %}Регистрация — Morozovka Portal{% endblock %}
{% block main_class %}auth-page{% endblock %}
{% block content %}
<div class="auth-card">
<h2>Регистрация</h2>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="POST">
<div class="form-group">
<label>ФИО</label>
<input type="text" name="full_name" required>
</div>
<div class="form-group">
<label>Организация</label>
<select name="organization">
<option value="ННГУ">ННГУ</option>
<option value="Неймарк">Неймарк</option>
<option value="Ядро">Ядро</option>
<option value="Внешний">Внешний</option>
</select>
</div>
<div class="form-group">
<label>Логин</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" name="password" required minlength="6">
</div>
<button type="submit">Зарегистрироваться</button>
</form>
<div class="links">
<a href="/login">Уже есть аккаунт? Войти</a>
<a href="/" style="color: #8b949e;">← На главную</a>
</div>
</body>
</html>
</div>
{% endblock %}

View File

@ -1,28 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Новый пароль — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
</style>
</head>
<body>
<div class="card">
<h2>Новый пароль</h2>
<form method="POST">
<div class="form-group">
<label>Введите новый пароль</label>
<input type="password" name="password" required minlength="6">
</div>
<button type="submit">Сохранить пароль</button>
</form>
</div>
</body>
</html>
{% extends "base.html" %}
{% block title %}Новый пароль — Morozovka Portal{% endblock %}
{% block main_class %}auth-page{% endblock %}
{% block content %}
<div class="auth-card">
<h2>Новый пароль</h2>
<form method="POST">
<div class="form-group">
<label>Введите новый пароль</label>
<input type="password" name="password" required minlength="6">
</div>
<button type="submit">Сохранить пароль</button>
</form>
</div>
{% endblock %}

View File

@ -1,13 +1,9 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание — Morozovka Portal</title>
{% extends "base.html" %}
{% block title %}Расписание — Morozovka Portal{% endblock %}
{% block main_class %}container container-wide{% endblock %}
{% block head %}
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; min-height: 100vh; }
.page { max-width: 1200px; margin: 0 auto; }
h1 { color: #58a6ff; margin: 0 0 15px; }
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
@ -89,7 +85,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.popup-close:hover { color: #f85149; }
#popup-meta { color: #8b949e; font-size: .9rem; line-height: 1.8; }
#popup-desc { margin-top: 15px; color: #c9d1d9; line-height: 1.5; white-space: pre-wrap; }
.subscribe-box { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px 25px; margin-top: 20px; }
.subscribe-box h3 { margin: 0 0 8px; color: #58a6ff; font-size: 1.1rem; }
.subscribe-box p { color: #8b949e; margin: 0 0 15px; font-size: .9rem; line-height: 1.5; }
@ -97,11 +92,10 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.sub-checkbox { display: flex; align-items: center; gap: 8px; color: #c9d1d9; font-size: .9rem; cursor: pointer; }
.sub-checkbox input { width: 16px; height: 16px; }
.btn-primary { background: #238636; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: .95rem; display: inline-block; text-decoration: none; }
.btn-primary:hover { background: #2ea043; }
.btn-primary:hover { background: #2ea043; text-decoration: none; }
.btn-danger { background: #da3633; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: .95rem; }
.btn-danger:hover { background: #f85149; }
.sub-active-badge { display: inline-block; padding: 4px 10px; background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; border-radius: 20px; font-size: .8rem; margin-bottom: 10px; }
.chat-fab { position: fixed; bottom: 25px; right: 25px; width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg,#58a6ff,#a371f7); color: #fff; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: 0 6px 20px rgba(88,166,255,.4); z-index: 999; transition: transform .2s; display: flex; align-items: center; justify-content: center; }
.chat-fab:hover { transform: scale(1.08); }
.chat-window { position: fixed; bottom: 95px; right: 25px; width: 360px; height: 500px; background: #161b22; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; z-index: 998; box-shadow: 0 20px 50px rgba(0,0,0,.6); overflow: hidden; }
@ -118,9 +112,7 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.chat-form input:focus { border-color: #58a6ff; }
.chat-form button { padding: 10px 16px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
.chat-form button:hover { background: #2ea043; }
@media (max-width: 768px) {
body { padding: 10px; }
.cal-title { font-size: 1rem; }
.view-switcher button { padding: 6px 10px; font-size: .8rem; }
.chat-window { width: calc(100vw - 20px); right: 10px; bottom: 90px; height: 60vh; }
@ -129,85 +121,83 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
.week-body { max-height: 400px; }
}
</style>
</head>
<body>
<div class="page">
<a href="/" class="back-link">← На главную</a>
<h1>Расписание</h1>
{% endblock %}
{% if msg %}<div class="banner banner-msg">{{ msg }}</div>{% endif %}
{% if error %}<div class="banner banner-error">{{ error }}</div>{% endif %}
{% block content %}
<a href="/" class="back-link">← На главную</a>
<h1>Расписание</h1>
{% if status %}
<div class="status-banner status-{{ status.status }}">
<div class="status-title">
{% if status.status == 'on_place' %}На месте
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
{% elif status.status == 'away' %}Вне вуза
{% elif status.status == 'cancelled' %}Отменено
{% else %}Свободен / Вне расписания{% endif %}
</div>
{% if status.message %}<div class="status-message">{{ status.message }}</div>{% endif %}
{% if msg %}<div class="banner banner-msg">{{ msg }}</div>{% endif %}
{% if error %}<div class="banner banner-error">{{ error }}</div>{% endif %}
{% if status %}
<div class="status-banner status-{{ status.status }}">
<div class="status-title">
{% if status.status == 'on_place' %}На месте
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
{% elif status.status == 'away' %}Вне вуза
{% elif status.status == 'cancelled' %}Отменено
{% else %}Свободен / Вне расписания{% endif %}
</div>
{% if status.message %}<div class="status-message">{{ status.message }}</div>{% endif %}
</div>
{% endif %}
<div class="calendar">
<div class="cal-toolbar">
<div class="cal-nav">
<button class="btn-icon" id="cal-prev">←</button>
<button class="btn-secondary" id="cal-today">Сегодня</button>
<button class="btn-icon" id="cal-next">→</button>
</div>
<h2 class="cal-title" id="cal-title">—</h2>
<div class="view-switcher">
<button data-view="day">День</button>
<button data-view="week" class="active">Неделя</button>
<button data-view="month">Месяц</button>
</div>
</div>
<div class="cal-filters" id="cal-filters">
<label class="filter-chip"><input type="checkbox" data-type="lecture" checked><span>📚 Лекции</span></label>
<label class="filter-chip"><input type="checkbox" data-type="workshop" checked><span>🛠 Мастер-классы</span></label>
<label class="filter-chip"><input type="checkbox" data-type="meeting" checked><span>👥 Встречи</span></label>
<label class="filter-chip"><input type="checkbox" data-type="personal" checked><span>🔒 Личное</span></label>
</div>
<div class="cal-body" id="cal-body"></div>
</div>
<div class="subscribe-box">
{% if not current_user %}
<h3>📬 Уведомления о расписании</h3>
<p>Войдите в аккаунт, чтобы подписаться на уведомления об изменениях статуса и событий.</p>
<a href="/login" class="btn-primary">Войти</a>
{% elif not current_user.email %}
<h3>📬 Уведомления о расписании</h3>
<p>В вашем профиле не указан email — укажите его, чтобы получать уведомления.</p>
<a href="/profile" class="btn-primary">Открыть профиль</a>
{% elif my_subscription and my_subscription.is_confirmed %}
<h3>📬 Вы подписаны на уведомления</h3>
<p>
<span class="sub-active-badge">✅ {{ current_user.email }}</span>
{% if my_subscription.notify_status %}<span style="margin-left: 8px;">✓ статус</span>{% endif %}
{% if my_subscription.notify_events %}<span style="margin-left: 8px;">✓ события</span>{% endif %}
</p>
<form method="POST" action="/subscribe/toggle">
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="next" value="/schedule">
<button type="submit" class="btn-danger">Отписаться</button>
</form>
{% else %}
<h3>📬 Подписаться на уведомления</h3>
<p>Получайте email, когда меняется статус или расписание. Email берётся из аккаунта — подтверждение не нужно.</p>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="next" value="/schedule">
<label class="sub-checkbox"><input type="checkbox" name="notify_status" checked> Изменения статуса</label>
<label class="sub-checkbox"><input type="checkbox" name="notify_events" checked> Изменения событий</label>
<div><button type="submit" class="btn-primary">Подписаться на {{ current_user.email }}</button></div>
</form>
{% endif %}
<div class="calendar">
<div class="cal-toolbar">
<div class="cal-nav">
<button class="btn-icon" id="cal-prev">←</button>
<button class="btn-secondary" id="cal-today">Сегодня</button>
<button class="btn-icon" id="cal-next">→</button>
</div>
<h2 class="cal-title" id="cal-title">—</h2>
<div class="view-switcher">
<button data-view="day">День</button>
<button data-view="week" class="active">Неделя</button>
<button data-view="month">Месяц</button>
</div>
</div>
<div class="cal-filters" id="cal-filters">
<label class="filter-chip"><input type="checkbox" data-type="lecture" checked><span>📚 Лекции</span></label>
<label class="filter-chip"><input type="checkbox" data-type="workshop" checked><span>🛠 Мастер-классы</span></label>
<label class="filter-chip"><input type="checkbox" data-type="meeting" checked><span>👥 Встречи</span></label>
<label class="filter-chip"><input type="checkbox" data-type="personal" checked><span>🔒 Личное</span></label>
</div>
<div class="cal-body" id="cal-body"></div>
</div>
<!-- Подписка на уведомления (аккаунт-центричная) -->
<div class="subscribe-box">
{% if not current_user %}
<h3>📬 Уведомления о расписании</h3>
<p>Войдите в аккаунт, чтобы подписаться на уведомления об изменениях статуса и событий.</p>
<a href="/login" class="btn-primary">Войти</a>
{% elif not current_user.email %}
<h3>📬 Уведомления о расписании</h3>
<p>В вашем профиле не указан email — укажите его, чтобы получать уведомления.</p>
<a href="/profile" class="btn-primary">Открыть профиль</a>
{% elif my_subscription and my_subscription.is_confirmed %}
<h3>📬 Вы подписаны на уведомления</h3>
<p>
<span class="sub-active-badge">✅ {{ current_user.email }}</span>
{% if my_subscription.notify_status %}<span style="margin-left: 8px;">✓ статус</span>{% endif %}
{% if my_subscription.notify_events %}<span style="margin-left: 8px;">✓ события</span>{% endif %}
</p>
<form method="POST" action="/subscribe/toggle">
<input type="hidden" name="action" value="unsubscribe">
<input type="hidden" name="next" value="/schedule">
<button type="submit" class="btn-danger">Отписаться</button>
</form>
{% else %}
<h3>📬 Подписаться на уведомления</h3>
<p>Получайте email, когда меняется статус или расписание. Email берётся из аккаунта — подтверждение не нужно.</p>
<form method="POST" action="/subscribe/toggle" class="sub-form">
<input type="hidden" name="action" value="subscribe">
<input type="hidden" name="next" value="/schedule">
<label class="sub-checkbox"><input type="checkbox" name="notify_status" checked> Изменения статуса</label>
<label class="sub-checkbox"><input type="checkbox" name="notify_events" checked> Изменения событий</label>
<div><button type="submit" class="btn-primary">Подписаться на {{ current_user.email }}</button></div>
</form>
{% endif %}
</div>
</div>
<div id="event-popup" class="event-popup hidden">
@ -231,7 +221,9 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
<button type="submit">→</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
const EVENTS = {{ events_json|tojson }};
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000;
@ -500,5 +492,4 @@ function addMsg(text, sender) {
}
render();
</script>
</body>
</html>
{% endblock %}

View File

@ -1,143 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 40px 20px; }
.container { max-width: 800px; margin: 0 auto; }
.status-banner { padding: 20px; border-radius: 10px; text-align: center; margin-bottom: 30px; }
.status-on_place { background: rgba(35, 134, 54, 0.2); border: 1px solid #238636; color: #3fb950; }
.status-delayed { background: rgba(210, 153, 34, 0.2); border: 1px solid #d29922; color: #d29922; }
.status-away, .status-cancelled { background: rgba(248, 81, 73, 0.2); border: 1px solid #f85149; color: #f85149; }
.status-free { background: rgba(139, 148, 158, 0.2); border: 1px solid #8b949e; color: #8b949e; }
.status-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 5px; }
.status-message { font-size: 1.1rem; }
.event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: flex; gap: 20px; }
.event-time { font-family: monospace; font-size: 1.1rem; color: #58a6ff; min-width: 120px; }
.event-details { flex: 1; }
.event-title { font-size: 1.2rem; font-weight: bold; margin: 0 0 5px 0; }
.event-meta { color: #8b949e; font-size: 0.9rem; margin-bottom: 5px; }
.event-type { display: inline-block; background: #21262d; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; text-transform: uppercase; }
.event-desc { font-size: 0.95rem; margin-top: 10px; }
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
/* Стили для чата */
.chat-container { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-top: 40px; display: flex; flex-direction: column; height: 400px; }
.chat-header { padding: 15px; border-bottom: 1px solid #30363d; background: #21262d; border-radius: 8px 8px 0 0; font-weight: bold; }
.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
.msg { max-width: 80%; padding: 10px 15px; border-radius: 8px; font-size: 0.95rem; line-height: 1.4; white-space: pre-wrap; }
.msg.bot { background: #21262d; align-self: flex-start; border: 1px solid #30363d; }
.msg.user { background: #1f6feb; color: white; align-self: flex-end; }
.chat-input-area { padding: 15px; border-top: 1px solid #30363d; display: flex; gap: 10px; }
.chat-input-area input { flex: 1; padding: 10px; border-radius: 5px; border: 1px solid #30363d; background: #0d1117; color: white; outline: none; }
.chat-input-area button { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
.chat-input-area button:hover { background: #2ea043; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<h1 style="margin-top: 0;">Расписание</h1>
{% if status %}
<div class="status-banner status-{{ status.status }}">
<div class="status-title">
{% if status.status == 'on_place' %}На месте
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
{% elif status.status == 'away' %}Вне вуза
{% elif status.status == 'cancelled' %}Отменено
{% else %}Свободен / Вне расписания{% endif %}
</div>
{% if status.message %}
<div class="status-message">{{ status.message }}</div>
{% endif %}
</div>
{% endif %}
<h2 style="color: #8b949e; font-size: 1.2rem; border-bottom: 1px solid #30363d; padding-bottom: 10px;">Ближайшие события</h2>
{% for event in events %}
<div class="event-card">
<div class="event-time">
{{ event.start_time.strftime('%d.%m %H:%M') }}<br>
<span style="color: #8b949e; font-size: 0.9rem;">до {{ event.end_time.strftime('%H:%M') }}</span>
</div>
<div class="event-details">
<h3 class="event-title">{{ event.title }}</h3>
<div class="event-meta">
{% if event.location %}📍 {{ event.location }}{% endif %}
</div>
<span class="event-type">{{ event.event_type }}</span>
{% if event.description %}
<div class="event-desc">{{ event.description }}</div>
{% endif %}
</div>
</div>
{% else %}
<p style="color: #8b949e;">Пока нет запланированных событий.</p>
{% endfor %}
<!-- Блок Чат-бота -->
<div class="chat-container">
<div class="chat-header">🤖 Ассистент расписания</div>
<div class="chat-messages" id="chat-messages">
<div class="msg bot">Привет! Я могу подсказать статус Никиты Сергеевича, его расписание или контакты. Спроси меня о чем-нибудь.</div>
</div>
<form class="chat-input-area" id="chat-form">
<input type="text" id="chat-input" placeholder="Например: Где сейчас? Что на неделе?" autocomplete="off">
<button type="submit">Отправить</button>
</form>
</div>
</div>
<script>
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatMessages = document.getElementById('chat-messages');
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const text = chatInput.value.trim();
if (!text) return;
// Добавляем сообщение пользователя
addMessage(text, 'user');
chatInput.value = '';
// Добавляем индикатор загрузки
const loadingMsg = addMessage('...', 'bot');
try {
const response = await fetch('/api/assistant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text })
});
const data = await response.json();
// Заменяем индикатор загрузки на ответ бота
loadingMsg.textContent = data.reply || 'Ошибка: пустой ответ';
} catch (err) {
loadingMsg.textContent = 'Ошибка соединения с сервером.';
}
});
function addMessage(text, sender) {
const div = document.createElement('div');
div.className = `msg ${sender}`;
div.textContent = text;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
return div;
}
</script>
</body>
</html>

View File

@ -1,51 +1,34 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Активные сессии — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 800px; margin: 0 auto; }
h2 { color: #58a6ff; }
table { width: 100%; border-collapse: collapse; background: #161b22; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; }
.current { background: rgba(35, 134, 54, 0.2); }
.btn-danger { background: #da3633; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 20px; }
.btn-danger:hover { background: #f85149; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/profile" class="back-link">← В профиль</a>
<h2>Управление устройствами</h2>
<p style="color: #8b949e;">Список всех мест, где выполнен вход в ваш аккаунт.</p>
{% extends "base.html" %}
{% block title %}Активные сессии — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
<table>
<thead>
<tr>
<th>Устройство (User-Agent)</th>
<th>IP-адрес</th>
<th>Дата входа</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr class="{% if s.session_token == current_token %}current{% endif %}">
<td><small>{{ s.user_agent or 'Неизвестно' }}</small></td>
<td>{{ s.ip_address }}</td>
<td>{{ s.created_at|msk_strftime('%d.%m.%Y %H:%M') }}</td>
<td>{% if s.session_token == current_token %}<b style="color: #3fb950;">Текущая</b>{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% block content %}
<a href="/profile" style="display:block;margin-bottom:20px;color:#8b949e;text-decoration:none;">← В профиль</a>
<h2 class="page-title">Управление устройствами</h2>
<p style="color: #8b949e;">Список всех мест, где выполнен вход в ваш аккаунт.</p>
<form action="/sessions/logout_all" method="POST" onsubmit="return confirm('Вы уверены, что хотите выйти на всех устройствах?');">
<button type="submit" class="btn-danger">Выйти везде</button>
</form>
</div>
</body>
</html>
<table style="width: 100%; border-collapse: collapse; background: #161b22; margin-top: 20px;">
<thead>
<tr>
<th style="padding:12px;text-align:left;border-bottom:1px solid #30363d;background:#21262d;color:#8b949e;">Устройство (User-Agent)</th>
<th style="padding:12px;text-align:left;border-bottom:1px solid #30363d;background:#21262d;color:#8b949e;">IP-адрес</th>
<th style="padding:12px;text-align:left;border-bottom:1px solid #30363d;background:#21262d;color:#8b949e;">Дата входа</th>
<th style="padding:12px;text-align:left;border-bottom:1px solid #30363d;background:#21262d;color:#8b949e;">Статус</th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr {% if s.session_token == current_token %}style="background: rgba(35,134,54,0.2);"{% endif %}>
<td style="padding:12px;border-bottom:1px solid #30363d;"><small>{{ s.user_agent or 'Неизвестно' }}</small></td>
<td style="padding:12px;border-bottom:1px solid #30363d;">{{ s.ip_address }}</td>
<td style="padding:12px;border-bottom:1px solid #30363d;">{{ s.created_at|msk_strftime('%d.%m.%Y %H:%M') }}</td>
<td style="padding:12px;border-bottom:1px solid #30363d;">{% if s.session_token == current_token %}<b style="color: #3fb950;">Текущая</b>{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="/sessions/logout_all" method="POST" onsubmit="return confirm('Вы уверены, что хотите выйти на всех устройствах?');" style="margin-top:20px;">
<button type="submit" style="background:#da3633;color:white;border:none;padding:10px 20px;border-radius:5px;cursor:pointer;font-weight:bold;">Выйти везде</button>
</form>
{% endblock %}

View File

@ -1,51 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Активные сессии — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 800px; margin: 0 auto; }
h2 { color: #58a6ff; }
table { width: 100%; border-collapse: collapse; background: #161b22; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; }
.current { background: rgba(35, 134, 54, 0.2); }
.btn-danger { background: #da3633; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 20px; }
.btn-danger:hover { background: #f85149; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/profile" class="back-link">← В профиль</a>
<h2>Управление устройствами</h2>
<p style="color: #8b949e;">Список всех мест, где выполнен вход в ваш аккаунт.</p>
<table>
<thead>
<tr>
<th>Устройство (User-Agent)</th>
<th>IP-адрес</th>
<th>Дата входа</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr class="{% if s.session_token == current_token %}current{% endif %}">
<td><small>{{ s.user_agent or 'Неизвестно' }}</small></td>
<td>{{ s.ip_address }}</td>
<td>{{ s.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td>{% if s.session_token == current_token %}<b style="color: #3fb950;">Текущая</b>{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="/sessions/logout_all" method="POST" onsubmit="return confirm('Вы уверены, что хотите выйти на всех устройствах?');">
<button type="submit" class="btn-danger">Выйти везде</button>
</form>
</div>
</body>
</html>

View File

@ -1,46 +1,37 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 40px 35px; max-width: 480px; width: 100%; text-align: center; }
.icon { font-size: 3.5rem; margin-bottom: 15px; }
.icon.ok { color: #3fb950; }
.icon.err { color: #f85149; }
h1 { margin: 0 0 15px; font-size: 1.5rem; }
h1.ok { color: #3fb950; }
h1.err { color: #f85149; }
p { color: #8b949e; line-height: 1.6; margin: 0 0 25px; }
.btn { display: inline-block; padding: 10px 20px; border-radius: 6px; text-decoration: none; font-weight: 600; margin: 5px; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; color: #fff; border: none; cursor: pointer; font-family: inherit; font-size: inherit; }
.btn-danger:hover { background: #f85149; }
.btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
form { display: inline; }
</style>
</head>
<body>
<div class="card">
<div class="icon {% if ok %}ok{% else %}err{% endif %}">{% if ok %}✓{% else %}⚠{% endif %}</div>
<h1 class="{% if ok %}ok{% else %}err{% endif %}">{{ title }}</h1>
<p>{{ message }}</p>
{% extends "base.html" %}
{% block title %}{{ title }} — Morozovka Portal{% endblock %}
{% block main_class %}auth-page{% endblock %}
{% if show_confirm_form %}
<form method="POST" action="{{ confirm_action }}">
<button type="submit" class="btn btn-danger">Да, отписаться</button>
<a href="/schedule" class="btn btn-secondary">Отмена</a>
</form>
{% else %}
<a href="/schedule" class="btn btn-primary">← К расписанию</a>
{% if unsubscribe_token %}
<a href="/unsubscribe/{{ unsubscribe_token }}" class="btn btn-secondary">Управление подпиской</a>
{% endif %}
{% endif %}
</div>
</body>
</html>
{% block head %}
<style>
.sub-card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 40px 35px; max-width: 480px; width: 100%; text-align: center; }
.icon { font-size: 3.5rem; margin-bottom: 15px; }
.icon.ok { color: #3fb950; }
.icon.err { color: #f85149; }
h1 { margin: 0 0 15px; font-size: 1.5rem; }
h1.ok { color: #3fb950; }
h1.err { color: #f85149; }
p { color: #8b949e; line-height: 1.6; margin: 0 0 25px; }
.sub-card form { display: inline; }
</style>
{% endblock %}
{% block content %}
<div class="sub-card">
<div class="icon {% if ok %}ok{% else %}err{% endif %}">{% if ok %}✓{% else %}⚠{% endif %}</div>
<h1 class="{% if ok %}ok{% else %}err{% endif %}">{{ title }}</h1>
<p>{{ message }}</p>
{% if show_confirm_form %}
<form method="POST" action="{{ confirm_action }}">
<button type="submit" class="btn btn-danger">Да, отписаться</button>
<a href="/schedule" class="btn btn-default">Отмена</a>
</form>
{% else %}
<a href="/schedule" class="btn btn-primary">← К расписанию</a>
{% if unsubscribe_token %}
<a href="/unsubscribe/{{ unsubscribe_token }}" class="btn btn-default">Управление подпиской</a>
{% endif %}
{% endif %}
</div>
{% endblock %}

View File

@ -1,80 +1,56 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Редактирование задачи — {{ project.title }}</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px 20px; }
.container { max-width: 600px; margin: 0 auto; }
h2 { color: #58a6ff; }
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 15px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
label { color: #8b949e; font-size: .85rem; font-weight: 600; }
input, select, textarea { padding: 10px; background: #0d1117; color: #fff; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; font-size: .95rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { resize: vertical; min-height: 100px; }
.actions { display: flex; gap: 10px; margin-top: 20px; }
.btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: 600; font-size: .9rem; text-decoration: none; display: inline-block; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
.btn-secondary:hover { background: #30363d; }
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
</style>
</head>
<body>
<div class="container">
<a href="/projects/{{ project.id }}/board" class="back-link">← К доске</a>
<h2>Редактирование задачи</h2>
<form method="POST">
{% extends "base.html" %}
{% block title %}Редактирование задачи — {{ project.title }}{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
{% block content %}
<a href="/projects/{{ project.id }}/board" style="display:inline-block;margin-bottom:20px;color:#8b949e;text-decoration:none;">← К доске</a>
<h2 class="page-title">Редактирование задачи</h2>
<form method="POST" class="form-card">
<div class="form-group">
<label>Название *</label>
<input type="text" name="title" value="{{ task.title }}" required maxlength="200">
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ task.description or '' }}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Название *</label>
<input type="text" name="title" value="{{ task.title }}" required maxlength="200">
<label>Статус</label>
<select name="status">
{% for s in TASK_STATUSES %}
<option value="{{ s }}" {% if task.status == s %}selected{% endif %}>{{ TASK_STATUS_LABELS[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ task.description or '' }}</textarea>
<label>Приоритет</label>
<select name="priority">
{% for p in TASK_PRIORITIES %}
<option value="{{ p }}" {% if task.priority == p %}selected{% endif %}>{{ p|capitalize }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Статус</label>
<select name="status">
{% for s in TASK_STATUSES %}
<option value="{{ s }}" {% if task.status == s %}selected{% endif %}>{{ TASK_STATUS_LABELS[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Приоритет</label>
<select name="priority">
{% for p in TASK_PRIORITIES %}
<option value="{{ p }}" {% if task.priority == p %}selected{% endif %}>{{ p|capitalize }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Дедлайн</label>
<input type="date" name="due_date" value="{{ task.due_date or '' }}">
</div>
<div class="form-row">
<div class="form-group">
<label>Дедлайн</label>
<input type="date" name="due_date" value="{{ task.due_date or '' }}">
</div>
<div class="form-group">
<label>Исполнитель</label>
<select name="assignee_id">
<option value="">— не назначено —</option>
{% for member_obj, user in members %}
<option value="{{ user.id }}" {% if task.assignee_id == user.id %}selected{% endif %}>{{ user.full_name or user.username }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Исполнитель</label>
<select name="assignee_id">
<option value="">— не назначено —</option>
{% for member_obj, user in members %}
<option value="{{ user.id }}" {% if task.assignee_id == user.id %}selected{% endif %}>{{ user.full_name or user.username }}</option>
{% endfor %}
</select>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/projects/{{ project.id }}/board" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
</body>
</html>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="/projects/{{ project.id }}/board" class="btn btn-default">Отмена</a>
</div>
</form>
{% endblock %}

View File

@ -1,34 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ workshop.title }} — Morozovka Portal</title>
{% extends "base.html" %}
{% block title %}{{ workshop.title }} — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
{% block head %}
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; }
.topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.topnav-actions { display: flex; gap: 12px; align-items: center; padding-right: 80px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; transition: all .15s; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-danger { background: #da3633; color: #fff; }
.btn-danger:hover { background: #f85149; }
.btn-default { background: #21262d; color: #c9d1d9; }
.btn-default:hover { background: #30363d; }
.container { max-width: 1000px; margin: 0 auto; padding: 24px 32px; }
.breadcrumbs { color: #8b949e; font-size: 13px; margin-bottom: 16px; }
.breadcrumbs a { color: #8b949e; }
.breadcrumbs a { color: #8b949e; text-decoration: none; }
.breadcrumbs a:hover { color: #58a6ff; }
.ws-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.ws-header-left { flex: 1; }
.ws-title { font-size: 24px; font-weight: 600; margin: 0 0 12px; color: #e6edf3; }
@ -46,22 +24,50 @@ a:hover { text-decoration: underline; }
.badge-difficulty-beginner { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.badge-difficulty-intermediate { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.badge-difficulty-advanced { background: rgba(248,81,73,0.15); color: #f85149; border-color: rgba(248,81,73,0.4); }
.badge-recurrence { background: rgba(163,113,247,0.15); color: #a371f7; border-color: rgba(163,113,247,0.4); }
.ws-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.layout { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } .container { padding: 16px; } .topnav { padding: 12px 16px; } .topnav-links { display: none; } .topnav-actions { padding-right: 60px; } }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
.section { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-bottom: 16px; }
.section h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; color: #e6edf3; padding-bottom: 8px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.section h2 .counter { color: #8b949e; font-size: 13px; font-weight: normal; }
.section-content { color: #c9d1d9; white-space: pre-wrap; line-height: 1.6; }
.post-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 14px 16px; margin-bottom: 12px; position: relative; }
.post-card:last-child { margin-bottom: 0; }
.post-card.pinned { border-color: #d29922; 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: #e6edf3; margin: 0 0 4px; }
.post-title .pin-mark { color: #d29922; margin-right: 4px; font-size: 12px; }
.post-meta { color: #8b949e; font-size: 12px; }
.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 #21262d; }
.post-attachment { display: flex; align-items: center; gap: 8px; padding: 4px 8px; background: #161b22; border-radius: 4px; font-size: 12px; }
.post-attachment a { color: #58a6ff; }
.post-image-wrap { margin: 6px 0; }
.post-image-wrap img { max-width: 100%; max-height: 400px; border-radius: 6px; border: 1px solid #30363d; display: block; cursor: zoom-in; }
.post-image-name { color: #6e7681; 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: #8b949e; cursor: pointer; padding: 2px 6px; font-size: 12px; border-radius: 3px; }
.post-actions button:hover { background: #30363d; color: #e6edf3; }
.post-actions .del:hover { color: #f85149; }
.post-form-card { background: #0d1117; border: 1px dashed #30363d; border-radius: 6px; padding: 14px 16px; margin-top: 12px; }
.post-form-row { display: grid; grid-template-columns: 1fr; gap: 8px; margin-bottom: 8px; }
.post-form-row label { color: #8b949e; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.post-form-row input, .post-form-row textarea { padding: 5px 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 13px; font-family: inherit; width: 100%; }
.post-form-row textarea { min-height: 80px; resize: vertical; }
.post-form-row input:focus, .post-form-row textarea:focus { outline: none; border-color: #58a6ff; }
.post-form-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.post-form-actions input[type=file] { font-size: 12px; color: #8b949e; }
.post-form-actions button { padding: 5px 14px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 13px; }
.post-form-actions button:hover { background: #2ea043; }
.checkbox-line { display: flex; align-items: center; gap: 6px; color: #8b949e; font-size: 12px; }
.session-row { display: flex; gap: 12px; align-items: center; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 8px; }
.session-row:last-child { margin-bottom: 0; }
.session-date { color: #58a6ff; font-weight: 600; font-family: monospace; flex-shrink: 0; }
.session-info { flex: 1; color: #c9d1d9; font-size: 13px; }
.session-note { color: #8b949e; font-size: 12px; margin-top: 2px; }
.material-row { display: flex; gap: 12px; align-items: center; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 8px; }
.material-row:last-child { margin-bottom: 0; }
.material-icon { font-size: 20px; flex-shrink: 0; }
@ -69,254 +75,340 @@ a:hover { text-decoration: underline; }
.material-title { color: #e6edf3; font-weight: 500; font-size: 13px; }
.material-desc { color: #8b949e; font-size: 12px; margin-top: 2px; }
.material-row a { color: #58a6ff; font-size: 13px; }
.add-form { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; padding: 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; margin-top: 12px; }
.add-form .form-group { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 140px; }
.add-form label { color: #8b949e; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.add-form input, .add-form select { padding: 5px 10px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 13px; font-family: inherit; }
.add-form input:focus, .add-form select:focus { outline: none; border-color: #58a6ff; }
.sidebar-item { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #30363d; }
.sidebar-item { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #30363d; }
.sidebar-item:last-child { border-bottom: none; padding-bottom: 0; margin-bottom: 0; }
.sidebar-label { color: #8b949e; font-size: 12px; text-transform: uppercase; font-weight: 600; }
.sidebar-value { color: #e6edf3; font-size: 14px; }
.sidebar-value .badge { font-size: 12px; }
.reg-info { padding: 12px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; }
.reg-info.registered { background: rgba(63,185,80,0.1); border: 1px solid rgba(63,185,80,0.4); color: #3fb950; }
.reg-info.full { background: rgba(210,153,34,0.1); border: 1px solid rgba(210,153,34,0.4); color: #d29922; }
.reg-info.none { background: #0d1117; border: 1px solid #30363d; color: #8b949e; }
.empty-small { color: #8b949e; font-size: 13px; padding: 12px; text-align: center; background: #0d1117; border: 1px dashed #30363d; border-radius: 6px; }
.participant-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; font-size: 13px; border-bottom: 1px solid #21262d; }
.participant-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 13px; border-bottom: 1px solid #21262d; gap: 10px; flex-wrap: wrap; }
.participant-row:last-child { border-bottom: none; }
.participant-left { flex: 1; min-width: 150px; }
.participant-status { font-size: 11px; padding: 2px 6px; border-radius: 10px; }
.status-registered { background: rgba(63,185,80,0.15); color: #3fb950; }
.status-attended { background: rgba(88,166,255,0.15); color: #58a6ff; }
.status-cancelled { background: rgba(139,148,158,0.15); color: #8b949e; }
.status-no_show { background: rgba(248,81,73,0.15); color: #f85149; }
.attendance-actions { display: flex; gap: 4px; }
.attendance-actions form { display: inline; }
.attendance-actions button { padding: 3px 8px; font-size: 11px; border-radius: 4px; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; font-weight: 600; }
.attendance-actions button:hover { background: #30363d; }
.attendance-actions .yes:hover { background: rgba(35,134,54,0.3); border-color: #238636; color: #3fb950; }
.attendance-actions .no:hover { background: rgba(218,54,51,0.2); border-color: #da3633; color: #f85149; }
.sub-btn { display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%; padding: 8px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 14px; border: 1px solid; }
.sub-btn.on { background: rgba(88,166,255,0.15); border-color: #58a6ff; color: #58a6ff; }
.sub-btn.on:hover { background: rgba(88,166,255,0.25); }
.sub-btn.off { background: #21262d; border-color: #30363d; color: #c9d1d9; }
.sub-btn.off:hover { background: #30363d; }
.team-member { display: flex; justify-content: space-between; align-items: center; gap: 6px; padding: 6px 0; font-size: 13px; color: #e6edf3; }
.team-member .role-tag { font-size: 10px; padding: 1px 6px; border-radius: 10px; background: #30363d; color: #c9d1d9; text-transform: uppercase; font-weight: 600; }
.team-member .role-tag.lead { background: #a371f7; color: #fff; }
.team-member .role-tag.assist { background: #58a6ff; color: #fff; }
.assist-add-form { display: flex; gap: 6px; margin-top: 8px; }
.assist-add-form input { flex: 1; padding: 5px 8px; background: #0d1117; border: 1px solid #30363d; border-radius: 4px; color: #e6edf3; font-size: 12px; }
.assist-add-form input:focus { outline: none; border-color: #58a6ff; }
.assist-add-form button { padding: 5px 10px; background: #238636; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 12px; }
.assist-add-form button:hover { background: #2ea043; }
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops" style="color:#58a6ff;">Мастер-классы</a>
<a href="/files">Файлы</a>
</div>
<div class="topnav-actions">
{% if session.get('username') %}<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>{% endif %}
</div>
</nav>
{% endblock %}
<div class="container">
<div class="breadcrumbs"><a href="/workshops">Мастер-классы</a> / {{ workshop.title }}</div>
{% block content %}
<div class="breadcrumbs"><a href="/workshops">Мастер-классы</a> / {{ workshop.title }}</div>
<div class="ws-header">
<div class="ws-header-left">
<h1 class="ws-title">{{ workshop.title }}</h1>
<div class="badges">
<span class="badge badge-cat-{{ workshop.category }}">{{ WORKSHOP_CATEGORY_LABELS[workshop.category] }}</span>
<span class="badge badge-difficulty-{{ workshop.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[workshop.difficulty] }}</span>
<span class="badge badge-status-{{ workshop.status }}">{{ WORKSHOP_STATUS_LABELS[workshop.status] }}</span>
<span class="badge badge-vis-{{ workshop.visibility }}">{{ WORKSHOP_VISIBILITY_LABELS[workshop.visibility] }}</span>
</div>
<div class="ws-header">
<div class="ws-header-left">
<h1 class="ws-title">{{ workshop.title }}</h1>
<div class="badges">
<span class="badge badge-cat-{{ workshop.category }}">{{ WORKSHOP_CATEGORY_LABELS[workshop.category] }}</span>
<span class="badge badge-difficulty-{{ workshop.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[workshop.difficulty] }}</span>
<span class="badge badge-status-{{ workshop.status }}">{{ WORKSHOP_STATUS_LABELS[workshop.status] }}</span>
<span class="badge badge-vis-{{ workshop.visibility }}">{{ WORKSHOP_VISIBILITY_LABELS[workshop.visibility] }}</span>
{% if workshop.recurrence and RECURRENCE_LABELS.get(workshop.recurrence) %}<span class="badge badge-recurrence">🔁 {{ RECURRENCE_LABELS[workshop.recurrence] }}</span>{% endif %}
</div>
<div class="ws-actions">
{% if perms.can_manage %}
<a href="/workshops/{{ workshop.id }}/edit" class="btn btn-default">✎ Редактировать</a>
</div>
<div class="ws-actions">
{% if perms.can_manage %}
<a href="/workshops/{{ workshop.id }}/edit" class="btn btn-default">✎ Редактировать</a>
{% endif %}
{% if perms.can_delete_workshop %}
<form action="/workshops/{{ workshop.id }}/delete" method="POST" onsubmit="return confirm('Удалить мастер-класс?');" style="display:inline;">
<button type="submit" class="btn btn-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
<div class="layout">
<div>
{% if workshop.description %}<div class="section"><h2>Описание</h2><div class="section-content">{{ workshop.description }}</div></div>{% endif %}
{% if workshop.program %}<div class="section"><h2>Программа</h2><div class="section-content">{{ workshop.program }}</div></div>{% endif %}
{% if workshop.materials_required %}<div class="section"><h2>Требуемые материалы</h2><div class="section-content">{{ workshop.materials_required }}</div></div>{% endif %}
{% if workshop.outcome %}<div class="section"><h2>Что получит участник</h2><div class="section-content">{{ workshop.outcome }}</div></div>{% endif %}
<div class="section">
<h2>📢 Новости <span class="counter">{{ posts|length }}</span></h2>
{% if posts %}
{% for p in posts %}
<div class="post-card{% if p.is_pinned %} pinned{% endif %}">
<div class="post-head">
<div style="flex:1;min-width:0;">
<div class="post-title">{% if p.is_pinned %}<span class="pin-mark">📌</span>{% endif %}{{ p.title }}</div>
<div class="post-meta">
{% set au = authors.get(p.author_id) %}{% if au %}{{ au.full_name or au.username }}{% endif %}
· {{ p.created_at|msk_strftime('%d.%m.%Y %H:%M') }}
</div>
</div>
{% if perms.can_manage %}
<div class="post-actions">
<button type="button" onclick="toggleEdit({{ p.id }})">✎</button>
<form action="/workshops/{{ workshop.id }}/posts/{{ p.id }}/delete" method="POST" onsubmit="return confirm('Удалить пост?');">
<button type="submit" class="del">✕</button>
</form>
</div>
{% endif %}
</div>
{% if p.body %}<div class="post-body">{{ p.body }}</div>{% endif %}
{% if atts.get(p.id) %}
<div class="post-attachments">
{% for a in atts.get(p.id) %}
{% set f = posts_files.get(a.file_id) %}
{% if f %}
{% if f.original_name|is_image %}
<div class="post-image-wrap">
<a href="/files/preview/{{ f.id }}" target="_blank">
<img src="/files/preview/{{ f.id }}" alt="{{ f.original_name }}" loading="lazy">
</a>
<div class="post-image-name">{{ f.original_name }} · {{ (f.size_bytes/1024)|round(1) }} КБ</div>
</div>
{% else %}
<div class="post-attachment">{{ f.original_name|file_icon }} <a href="/files/download/{{ f.id }}">{{ f.original_name }}</a> <span style="color:#6e7681;">({{ (f.size_bytes/1024)|round(1) }} КБ)</span></div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if perms.can_manage %}
<form id="edit-post-{{ p.id }}" action="/workshops/{{ workshop.id }}/posts/{{ p.id }}/edit" method="POST" style="display:none;margin-top:10px;">
<div class="post-form-row"><label>Заголовок</label><input type="text" name="title" value="{{ p.title }}" required></div>
<div class="post-form-row"><label>Текст</label><textarea name="body">{{ p.body or '' }}</textarea></div>
<div class="post-form-actions">
<label class="checkbox-line"><input type="checkbox" name="is_pinned" {% if p.is_pinned %}checked{% endif %}> Закрепить</label>
<button type="submit">Сохранить</button>
<button type="button" class="btn btn-default" onclick="toggleEdit({{ p.id }})">Отмена</button>
</div>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-small">Пока нет новостей.</div>
{% endif %}
{% if perms.can_delete %}
<form action="/workshops/{{ workshop.id }}/delete" method="POST" onsubmit="return confirm('Удалить мастер-класс?');" style="display:inline;">
<button type="submit" class="btn btn-danger">Удалить</button>
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/posts/new" method="POST" enctype="multipart/form-data" class="post-form-card">
<div class="post-form-row"><label>Заголовок *</label><input type="text" name="title" required maxlength="200"></div>
<div class="post-form-row"><label>Текст</label><textarea name="body"></textarea></div>
<div class="post-form-actions">
<input type="file" name="files" multiple>
<label class="checkbox-line"><input type="checkbox" name="is_pinned"> Закрепить</label>
<button type="submit">Опубликовать</button>
</div>
</form>
{% endif %}
</div>
<div class="section">
<h2>Сессии <span class="counter">{{ sessions|length }}</span></h2>
{% for s in sessions %}
<div class="session-row">
<div class="session-date">
{{ s.start_time|msk_strftime('%d.%m.%Y') }}<br>
<span style="color:#8b949e;font-weight:normal;font-size:12px;">{{ s.start_time|msk_strftime('%H:%M') }}–{{ s.end_time|msk_strftime('%H:%M') }}</span>
</div>
<div class="session-info">
{% if s.location_override %}📍 {{ s.location_override }}{% elif workshop.location %}📍 {{ workshop.location }}{% endif %}
{% if s.note %}<div class="session-note">{{ s.note }}</div>{% endif %}
</div>
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/sessions/{{ s.id }}/delete" method="POST" onsubmit="return confirm('Удалить?');">
<button type="submit" class="btn btn-default btn-sm">✕</button>
</form>
{% endif %}
</div>
{% else %}
<div class="empty-small">Нет сессий.</div>
{% endfor %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/sessions/add" method="POST" class="add-form">
<div class="form-group"><label>Начало</label><input type="datetime-local" name="start_time" required></div>
<div class="form-group"><label>Конец</label><input type="datetime-local" name="end_time" required></div>
<div class="form-group"><label>Место</label><input type="text" name="location_override"></div>
<div class="form-group"><label>Заметка</label><input type="text" name="note"></div>
<button type="submit" class="btn btn-primary">+ Добавить</button>
</form>
{% endif %}
</div>
<div class="section">
<h2>Материалы <span class="counter">{{ materials|length }}</span></h2>
{% for m in materials %}
<div class="material-row">
<div class="material-icon">{% if m.external_url %}🔗{% elif m.file_id and material_files_map.get(m.file_id) %}{{ material_files_map[m.file_id].original_name|file_icon }}{% else %}📎{% endif %}</div>
<div class="material-info">
<div class="material-title">{{ m.title }}</div>
{% if m.description %}<div class="material-desc">{{ m.description }}</div>{% endif %}
</div>
{% if m.external_url %}<a href="{{ m.external_url }}" target="_blank">Открыть →</a>
{% elif m.file_id and material_files_map.get(m.file_id) %}<a href="/files/download/{{ m.file_id }}">Скачать →</a>{% endif %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/materials/{{ m.id }}/delete" method="POST" onsubmit="return confirm('Удалить?');">
<button type="submit" class="btn btn-default btn-sm">✕</button>
</form>
{% endif %}
</div>
{% else %}
<div class="empty-small">{% if is_registered or perms.can_manage %}Материалы появятся после публикации.{% else %}Доступны после записи.{% endif %}</div>
{% endfor %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/materials/add" method="POST" class="add-form">
<div class="form-group"><label>Название *</label><input type="text" name="title" required></div>
<div class="form-group"><label>URL</label><input type="url" name="external_url"></div>
<div class="form-group"><label>Кому видно</label>
<select name="visible_to"><option value="registered">Записавшимся</option><option value="all">Всем</option></select>
</div>
<button type="submit" class="btn btn-primary">+</button>
</form>
{% endif %}
</div>
{% if perms.can_manage %}
<div class="section">
<h2>Участники <span class="counter">{{ participants|length }}</span></h2>
{% if participants %}
{% for reg, usr in participants %}
<div class="participant-row">
<div class="participant-left">
<b>{{ usr.full_name or usr.username }}</b> <span style="color:#8b949e;">@{{ usr.username }}</span>
</div>
<span class="participant-status status-{{ reg.status }}">
{% if reg.status == 'registered' %}Записан
{% elif reg.status == 'attended' %}Посетил
{% elif reg.status == 'cancelled' %}Отменил
{% elif reg.status == 'no_show' %}Не пришёл
{% else %}{{ reg.status }}{% endif %}
</span>
<div class="attendance-actions">
<form action="/workshops/{{ workshop.id }}/attendance/{{ reg.id }}" method="POST">
<input type="hidden" name="status" value="attended">
<button type="submit" class="yes" title="Отметить как посетил">✅</button>
</form>
<form action="/workshops/{{ workshop.id }}/attendance/{{ reg.id }}" method="POST">
<input type="hidden" name="status" value="no_show">
<button type="submit" class="no" title="Не пришёл">🚫</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-small">Пока никто не записался.</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="layout">
<div>
{% if workshop.description %}
<div class="section"><h2>Описание</h2><div class="section-content">{{ workshop.description }}</div></div>
{% endif %}
{% if workshop.program %}
<div class="section"><h2>Программа</h2><div class="section-content">{{ workshop.program }}</div></div>
{% endif %}
{% if workshop.materials_required %}
<div class="section"><h2>Требуемые материалы</h2><div class="section-content">{{ workshop.materials_required }}</div></div>
{% endif %}
{% if workshop.outcome %}
<div class="section"><h2>Что получит участник</h2><div class="section-content">{{ workshop.outcome }}</div></div>
{% endif %}
<div class="section">
<h2>Сессии <span class="counter">{{ sessions|length }}</span></h2>
{% if sessions %}
{% for s in sessions %}
<div class="session-row">
<div class="session-date">
{{ s.start_time|msk_strftime('%d.%m.%Y') }}<br>
<span style="color:#8b949e;font-weight:normal;font-size:12px;">{{ s.start_time|msk_strftime('%H:%M') }}–{{ s.end_time|msk_strftime('%H:%M') }}</span>
</div>
<div class="session-info">
{% if s.location_override %}📍 {{ s.location_override }}{% elif workshop.location %}📍 {{ workshop.location }}{% endif %}
{% if s.note %}<div class="session-note">{{ s.note }}</div>{% endif %}
</div>
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/sessions/{{ s.id }}/delete" method="POST" onsubmit="return confirm('Удалить сессию?');">
<button type="submit" class="btn btn-default" style="padding:2px 8px;font-size:12px;">✕</button>
<div>
<div class="section">
<h2>Команда МК</h2>
<div class="team-member">
<div>
{% if lead %}{{ lead.full_name or lead.username }}{% else %}—{% endif %}
<div style="color:#8b949e;font-size:11px;">@{{ lead.username if lead else '' }}</div>
</div>
<span class="role-tag lead">Ведущий</span>
</div>
{% for a_obj, a_user in assistants %}
<div class="team-member">
<div>
{{ a_user.full_name or a_user.username }}
<div style="color:#8b949e;font-size:11px;">@{{ a_user.username }}</div>
</div>
<div style="display:flex;align-items:center;gap:6px;">
<span class="role-tag assist">Помощник</span>
{% if perms.can_manage_assistants %}
<form action="/workshops/{{ workshop.id }}/assistants/{{ a_user.id }}/remove" method="POST" onsubmit="return confirm('Убрать?');" style="display:inline;">
<button type="submit" class="btn btn-danger btn-sm" style="padding:0 6px;font-size:11px;">✕</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-small">Пока нет запланированных сессий.</div>
{% endif %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/sessions/add" method="POST" class="add-form">
<div class="form-group"><label>Начало</label><input type="datetime-local" name="start_time" required></div>
<div class="form-group"><label>Конец</label><input type="datetime-local" name="end_time" required></div>
<div class="form-group"><label>Место (опц.)</label><input type="text" name="location_override"></div>
<div class="form-group"><label>Заметка</label><input type="text" name="note"></div>
<button type="submit" class="btn btn-primary">+ Добавить</button>
</form>
{% endif %}
</div>
<div class="section">
<h2>Материалы <span class="counter">{{ materials|length }}</span></h2>
{% if materials %}
{% for m in materials %}
<div class="material-row">
<div class="material-icon">{% if m.external_url %}🔗{% else %}📎{% endif %}</div>
<div class="material-info">
<div class="material-title">{{ m.title }}</div>
{% if m.description %}<div class="material-desc">{{ m.description }}</div>{% endif %}
</div>
{% if m.external_url %}<a href="{{ m.external_url }}" target="_blank">Открыть →</a>
{% elif m.file_id and material_files_map.get(m.file_id) %}<a href="/files/download/{{ m.file_id }}">Скачать →</a>{% endif %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/materials/{{ m.id }}/delete" method="POST" onsubmit="return confirm('Удалить материал?');">
<button type="submit" class="btn btn-default" style="padding:2px 8px;font-size:12px;">✕</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-small">{% if is_registered or perms.can_manage %}Материалы появятся после публикации.{% else %}Материалы доступны после записи.{% endif %}</div>
{% endif %}
{% if perms.can_manage %}
<form action="/workshops/{{ workshop.id }}/materials/add" method="POST" class="add-form">
<div class="form-group"><label>Название *</label><input type="text" name="title" required></div>
<div class="form-group"><label>Ссылка (URL)</label><input type="url" name="external_url"></div>
<div class="form-group"><label>Описание</label><input type="text" name="description"></div>
<div class="form-group"><label>Кому видно</label>
<select name="visible_to"><option value="registered">Записавшимся</option><option value="all">Всем</option></select>
</div>
<button type="submit" class="btn btn-primary">+ Добавить</button>
</form>
{% endif %}
</div>
{% if perms.can_manage %}
<div class="section">
<h2>Участники <span class="counter">{{ participants|length }}</span></h2>
{% if participants %}
{% for reg, usr in participants %}
<div class="participant-row">
<div>{{ usr.full_name or usr.username }} <span style="color:#8b949e;">@{{ usr.username }}</span></div>
<span class="participant-status status-{{ reg.status }}">
{% if reg.status == 'registered' %}Записан
{% elif reg.status == 'attended' %}Посетил
{% elif reg.status == 'cancelled' %}Отменил
{% elif reg.status == 'no_show' %}Не пришёл
{% else %}{{ reg.status }}{% endif %}
</span>
</div>
{% endfor %}
{% else %}
<div class="empty-small">Пока никто не записался.</div>
{% endif %}
</div>
{% endfor %}
{% if perms.can_manage_assistants %}
<form action="/workshops/{{ workshop.id }}/assistants/add" method="POST" class="assist-add-form">
<input type="text" name="username" placeholder="username" required>
<button type="submit">+</button>
</form>
{% endif %}
</div>
<div>
<div class="section">
<h2>Детали</h2>
<div class="sidebar-item">
<span class="sidebar-label">Ведущий</span>
<span class="sidebar-value">{% if lead %}{{ lead.full_name or lead.username }}{% else %}—{% endif %}</span>
</div>
{% if workshop.partner %}
<div class="sidebar-item">
<span class="sidebar-label">Партнёр</span>
<span class="sidebar-value">{{ workshop.partner }}</span>
</div>
{% endif %}
{% if workshop.location %}
<div class="sidebar-item">
<span class="sidebar-label">Место</span>
<span class="sidebar-value">📍 {{ workshop.location }}</span>
</div>
{% endif %}
<div class="sidebar-item">
<span class="sidebar-label">Уровень</span>
<span class="sidebar-value"><span class="badge badge-difficulty-{{ workshop.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[workshop.difficulty] }}</span></span>
</div>
{% if workshop.has_certificate %}
<div class="sidebar-item">
<span class="sidebar-label">По окончании</span>
<span class="sidebar-value">🏅 Сертификат</span>
</div>
{% endif %}
<div class="sidebar-item">
<span class="sidebar-label">Участники</span>
<span class="sidebar-value">{{ registered_count }}{% if workshop.max_participants %} / {{ workshop.max_participants }}{% endif %}</span>
</div>
{% if workshop.tags %}
<div class="sidebar-item">
<span class="sidebar-label">Теги</span>
<span class="sidebar-value" style="font-size:13px;">{% for t in workshop.tags.split(',') %}#{{ t.strip() }} {% endfor %}</span>
</div>
{% endif %}
</div>
<div class="section">
<h2>Детали</h2>
{% if workshop.partner %}<div class="sidebar-item"><span class="sidebar-label">Партнёр</span><span class="sidebar-value">{{ workshop.partner }}</span></div>{% endif %}
{% if workshop.location %}<div class="sidebar-item"><span class="sidebar-label">Место</span><span class="sidebar-value">📍 {{ workshop.location }}</span></div>{% endif %}
<div class="sidebar-item"><span class="sidebar-label">Уровень</span><span class="sidebar-value"><span class="badge badge-difficulty-{{ workshop.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[workshop.difficulty] }}</span></span></div>
{% if workshop.recurrence and RECURRENCE_LABELS.get(workshop.recurrence) %}<div class="sidebar-item"><span class="sidebar-label">Регулярность</span><span class="sidebar-value">{{ RECURRENCE_LABELS[workshop.recurrence] }}</span></div>{% endif %}
{% if workshop.has_certificate %}<div class="sidebar-item"><span class="sidebar-label">По окончании</span><span class="sidebar-value">🏅 Сертификат</span></div>{% endif %}
<div class="sidebar-item"><span class="sidebar-label">Участники</span><span class="sidebar-value">{{ registered_count }}{% if workshop.max_participants %} / {{ workshop.max_participants }}{% endif %}</span></div>
{% if workshop.tags %}<div class="sidebar-item"><span class="sidebar-label">Теги</span><span class="sidebar-value" style="font-size:13px;">{% for t in workshop.tags.split(',') %}#{{ t.strip() }} {% endfor %}</span></div>{% endif %}
</div>
{% if workshop.status == 'published' %}
<div class="section">
<h2>Регистрация</h2>
{% if not user_id %}
<div class="reg-info none">Войдите, чтобы записаться.</div>
<a href="/login" class="btn btn-primary" style="width:100%;justify-content:center;">Войти</a>
{% elif is_registered %}
<div class="reg-info registered">✅ Вы записаны на этот мастер-класс.</div>
<form action="/workshops/{{ workshop.id }}/unregister" method="POST">
<button type="submit" class="btn btn-default" style="width:100%;justify-content:center;">Отменить запись</button>
</form>
{% elif is_full %}
<div class="reg-info full">⚠ Мест больше нет ({{ registered_count }}/{{ workshop.max_participants }}).</div>
{% else %}
<div class="reg-info none">Свободно мест: {% if workshop.max_participants %}{{ workshop.max_participants - registered_count }}{% else %}не ограничено{% endif %}.</div>
<form action="/workshops/{{ workshop.id }}/register" method="POST">
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;">Записаться</button>
</form>
{% endif %}
</div>
{% if user_id %}
<div class="section">
<h2>Подписка</h2>
<form action="/workshops/{{ workshop.id }}/subscribe" method="POST">
{% if my_sub %}<button type="submit" class="sub-btn on">🔔 Вы подписаны</button>
{% else %}<button type="submit" class="sub-btn off">🔔 Подписаться на новости</button>{% endif %}
</form>
</div>
{% endif %}
{% if workshop.status == 'published' %}
<div class="section">
<h2>Регистрация</h2>
{% if not user_id %}
<div class="reg-info none">Войдите, чтобы записаться.</div>
<a href="/login" class="btn btn-primary" style="width:100%;justify-content:center;">Войти</a>
{% elif is_registered %}
<div class="reg-info registered">✅ Вы записаны.</div>
<form action="/workshops/{{ workshop.id }}/unregister" method="POST">
<button type="submit" class="btn btn-default" style="width:100%;justify-content:center;">Отменить запись</button>
</form>
{% elif is_full %}
<div class="reg-info full">⚠ Мест нет.</div>
{% else %}
<div class="reg-info none">Свободно: {% if workshop.max_participants %}{{ workshop.max_participants - registered_count }}{% else %}не ограничено{% endif %}.</div>
<form action="/workshops/{{ workshop.id }}/register" method="POST">
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;">Записаться</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<script src="/static/notif_bell.js"></script>
</body>
</html>
{% endblock %}
{% block scripts %}
<script>
function toggleEdit(postId) {
var el = document.getElementById('edit-post-' + postId);
if (el) el.style.display = (el.style.display === 'none' || !el.style.display) ? 'block' : 'none';
}
</script>
{% endblock %}

View File

@ -1,181 +1,135 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ 'Редактирование' if is_edit else 'Новый мастер-класс' }} — Morozovka Portal</title>
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; }
.topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.topnav-actions { display: flex; gap: 12px; align-items: center; padding-right: 80px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; line-height: 20px; border: 1px solid rgba(240,246,252,0.1); cursor: pointer; text-decoration: none; transition: all .15s; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-default { background: #21262d; color: #c9d1d9; }
.btn-default:hover { background: #30363d; }
{% extends "base.html" %}
{% block title %}{{ 'Редактирование' if is_edit else 'Новый мастер-класс' }} — Morozovka Portal{% endblock %}
{% block main_class %}container container-narrow{% endblock %}
.container { max-width: 800px; margin: 0 auto; padding: 24px 32px; }
.page-title { font-size: 24px; font-weight: 600; margin: 0 0 24px; color: #e6edf3; }
.form-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; 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: #e6edf3; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
label .req { color: #f85149; }
input, select, textarea { padding: 5px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-size: 14px; font-family: inherit; line-height: 20px; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #58a6ff; box-shadow: 0 0 0 3px rgba(88,166,255,0.15); }
textarea { min-height: 100px; resize: vertical; }
.hint { color: #8b949e; font-size: 12px; margin-top: 4px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.checkbox-row input { width: 16px; height: 16px; }
.checkbox-row label { font-weight: 500; margin: 0; cursor: pointer; }
.form-actions { display: flex; gap: 8px; margin-top: 24px; padding-top: 16px; border-top: 1px solid #30363d; }
@media (max-width: 768px) {
.topnav { padding: 12px 16px; gap: 12px; }
.topnav-links { display: none; }
.topnav-actions { padding-right: 60px; }
.container { padding: 16px; }
.form-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops">Мастер-классы</a>
<a href="/files">Файлы</a>
{% block content %}
<h1 class="page-title">{{ 'Редактирование мастер-класса' if is_edit else 'Новый мастер-класс' }}</h1>
<form method="POST" class="form-card">
<div class="form-group">
<label>Название <span class="req">*</span></label>
<input type="text" name="title" value="{{ workshop.title if is_edit else '' }}" required maxlength="200">
</div>
<div class="topnav-actions">
{% if session.get('username') %}
<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>
{% endif %}
<div class="form-group">
<label>Краткое описание</label>
<textarea name="description">{{ workshop.description if is_edit else '' }}</textarea>
<span class="hint">2–3 предложения — появится в каталоге.</span>
</div>
</nav>
<div class="container">
<h1 class="page-title">{{ 'Редактирование мастер-класса' if is_edit else 'Новый мастер-класс' }}</h1>
<div class="form-group">
<label>Программа</label>
<textarea name="program" style="min-height: 150px;">{{ workshop.program if is_edit else '' }}</textarea>
<span class="hint">Подробный план занятия.</span>
</div>
<form method="POST" class="form-card">
{% if not is_edit and session.get('role') == 'admin' and users %}
<div class="form-group">
<label>Ведущий</label>
<select name="lead_id">
{% for u in users %}
<option value="{{ u.id }}" {% if u.id == session.get('user_id') %}selected{% endif %}>
{{ u.full_name or u.username }} (@{{ u.username }})
</option>
{% endfor %}
</select>
<span class="hint">По умолчанию вы. Можно выбрать другого пользователя.</span>
</div>
{% endif %}
<div class="form-row">
<div class="form-group">
<label>Название <span class="req">*</span></label>
<input type="text" name="title" value="{{ workshop.title if is_edit else '' }}" required maxlength="200">
<label>Категория</label>
<select name="category">
{% for cat in WORKSHOP_CATEGORIES %}
<option value="{{ cat }}" {% if is_edit and workshop.category == cat %}selected{% endif %}>{{ WORKSHOP_CATEGORY_LABELS[cat] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Краткое описание</label>
<textarea name="description">{{ workshop.description if is_edit else '' }}</textarea>
<span class="hint">2–3 предложения — появится в каталоге.</span>
<label>Уровень сложности</label>
<select name="difficulty">
{% for d in WORKSHOP_DIFFICULTIES %}
<option value="{{ d }}" {% if is_edit and workshop.difficulty == d %}selected{% endif %}>{{ WORKSHOP_DIFFICULTY_LABELS[d] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Программа</label>
<textarea name="program" style="min-height: 150px;">{{ workshop.program if is_edit else '' }}</textarea>
<span class="hint">Подробный план занятия.</span>
<label>Партнёр / организатор</label>
<input type="text" name="partner" value="{{ workshop.partner if is_edit and workshop.partner else '' }}" placeholder="ННГУ, Неймарк, Ядро, ...">
</div>
<div class="form-row">
<div class="form-group">
<label>Категория</label>
<select name="category">
{% for cat in WORKSHOP_CATEGORIES %}
<option value="{{ cat }}" {% if is_edit and workshop.category == cat %}selected{% endif %}>{{ WORKSHOP_CATEGORY_LABELS[cat] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Уровень сложности</label>
<select name="difficulty">
{% for d in WORKSHOP_DIFFICULTIES %}
<option value="{{ d }}" {% if is_edit and workshop.difficulty == d %}selected{% endif %}>{{ WORKSHOP_DIFFICULTY_LABELS[d] }}</option>
{% endfor %}
</select>
<span class="hint">Новичок — без опыта, Продвинутый — нужна база.</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Партнёр / организатор</label>
<input type="text" name="partner" value="{{ workshop.partner if is_edit and workshop.partner else '' }}" placeholder="ННГУ, Неймарк, Ядро, ...">
<span class="hint">Опционально. Покажется плашкой на карточке.</span>
</div>
<div class="form-group">
<label>Место</label>
<input type="text" name="location" value="{{ workshop.location if is_edit and workshop.location else '' }}" placeholder="Ауд. 405">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Статус</label>
<select name="status">
{% for s in WORKSHOP_STATUSES %}
<option value="{{ s }}" {% if is_edit and workshop.status == s %}selected{% endif %}>{{ WORKSHOP_STATUS_LABELS[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Видимость</label>
<select name="visibility">
{% for v in WORKSHOP_VISIBILITIES %}
<option value="{{ v }}" {% if is_edit and workshop.visibility == v %}selected{% endif %}>{{ WORKSHOP_VISIBILITY_LABELS[v] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Максимум участников</label>
<input type="number" name="max_participants" value="{{ workshop.max_participants if is_edit and workshop.max_participants else '' }}" placeholder="Без ограничения">
<span class="hint">Пусто — без лимита</span>
</div>
<div class="form-group">
<label>Ссылка на обложку</label>
<input type="url" name="cover_url" value="{{ workshop.cover_url if is_edit and workshop.cover_url else '' }}" placeholder="https://...">
</div>
</div>
<div class="form-group">
<label>Требуемые материалы</label>
<textarea name="materials_required" style="min-height: 80px;">{{ workshop.materials_required if is_edit else '' }}</textarea>
<span class="hint">Что принести с собой или что предоставим.</span>
<label>Место</label>
<input type="text" name="location" value="{{ workshop.location if is_edit and workshop.location else '' }}" placeholder="Ауд. 405">
</div>
</div>
<div class="form-group">
<label>Регулярность</label>
<select name="recurrence">
<option value="">— не указано —</option>
{% for key, label in RECURRENCE_PRESETS %}
<option value="{{ key }}" {% if is_edit and workshop.recurrence == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<span class="hint">«По объявлению» — без фиксированного расписания. «Разово» — один раз.</span>
</div>
<div class="form-row">
<div class="form-group">
<label>Что получит участник</label>
<textarea name="outcome" style="min-height: 80px;">{{ workshop.outcome if is_edit else '' }}</textarea>
<span class="hint">Например: «Собранный светодиодный индикатор и базовые навыки пайки».</span>
<div class="checkbox-row">
<input type="checkbox" name="has_certificate" id="has_certificate" {% if is_edit and workshop.has_certificate %}checked{% endif %}>
<label for="has_certificate">🏅 Выдаётся сертификат</label>
</div>
<label>Статус</label>
<select name="status">
{% for s in WORKSHOP_STATUSES %}
<option value="{{ s }}" {% if is_edit and workshop.status == s %}selected{% endif %}>{{ WORKSHOP_STATUS_LABELS[s] }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Теги</label>
<input type="text" name="tags" value="{{ workshop.tags if is_edit and workshop.tags else '' }}" placeholder="через, запятую">
<label>Видимость</label>
<select name="visibility">
{% for v in WORKSHOP_VISIBILITIES %}
<option value="{{ v }}" {% if is_edit and workshop.visibility == v %}selected{% endif %}>{{ WORKSHOP_VISIBILITY_LABELS[v] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Сохранить' if is_edit else 'Создать' }}</button>
<a href="{{ '/workshops/' + workshop.id|string if is_edit else '/workshops' }}" class="btn btn-default">Отмена</a>
<div class="form-row">
<div class="form-group">
<label>Максимум участников</label>
<input type="number" name="max_participants" value="{{ workshop.max_participants if is_edit and workshop.max_participants else '' }}" placeholder="Без ограничения">
</div>
</form>
</div>
<script src="/static/notif_bell.js"></script>
</body>
</html>
<div class="form-group">
<label>Ссылка на обложку</label>
<input type="url" name="cover_url" value="{{ workshop.cover_url if is_edit and workshop.cover_url else '' }}" placeholder="https://...">
</div>
</div>
<div class="form-group">
<label>Требуемые материалы</label>
<textarea name="materials_required" style="min-height: 80px;">{{ workshop.materials_required if is_edit else '' }}</textarea>
</div>
<div class="form-group">
<label>Что получит участник</label>
<textarea name="outcome" style="min-height: 80px;">{{ workshop.outcome if is_edit else '' }}</textarea>
<div class="checkbox-row">
<input type="checkbox" name="has_certificate" id="has_certificate" {% if is_edit and workshop.has_certificate %}checked{% endif %}>
<label for="has_certificate">🏅 Выдаётся сертификат</label>
</div>
</div>
<div class="form-group">
<label>Теги</label>
<input type="text" name="tags" value="{{ workshop.tags if is_edit and workshop.tags else '' }}" placeholder="через, запятую">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Сохранить' if is_edit else 'Создать' }}</button>
<a href="{{ '/workshops/' + workshop.id|string if is_edit else '/workshops' }}" class="btn btn-default">Отмена</a>
</div>
</form>
{% endblock %}

View File

@ -1,55 +1,31 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Мастер-классы — Morozovka Portal</title>
{% extends "base.html" %}
{% block title %}Мастер-классы — Morozovka Portal{% endblock %}
{% block main_class %}container{% endblock %}
{% block head %}
<style>
* { box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; font-size: 14px; line-height: 1.5; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.topnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 32px; display: flex; align-items: center; gap: 24px; }
.topnav-logo { display: flex; align-items: center; gap: 8px; color: #e6edf3; font-weight: 600; font-size: 15px; }
.topnav-logo svg { color: #58a6ff; }
.topnav-links { display: flex; gap: 20px; flex: 1; }
.topnav-links a { color: #c9d1d9; font-weight: 500; }
.topnav-links a:hover { color: #58a6ff; text-decoration: none; }
.topnav-actions { display: flex; gap: 12px; align-items: center; padding-right: 80px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 16px; border-radius: 6px; 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; }
.btn:hover { text-decoration: none; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-default { background: #21262d; color: #c9d1d9; }
.btn-default:hover { background: #30363d; }
.container { max-width: 1100px; margin: 0 auto; padding: 24px 32px; }
.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: #e6edf3; }
.page-subtitle { color: #8b949e; font-size: 14px; margin: 0; }
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; padding: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
.filter-chip { display: inline-flex; align-items: center; padding: 4px 10px; font-size: 12px; font-weight: 500; border-radius: 2em; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; }
.filter-chip { display: inline-flex; align-items: center; padding: 4px 10px; font-size: 12px; font-weight: 500; border-radius: 2em; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; text-decoration: none; }
.filter-chip:hover { background: #30363d; text-decoration: none; color: #e6edf3; }
.filter-chip.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.filter-chip.active:hover { background: #388bfd; color: #fff; }
.filter-chip-clear { border-color: #f85149; color: #f85149; background: transparent; }
.filter-chip-clear:hover { background: rgba(248,81,73,0.1); color: #f85149; }
.filter-label { color: #8b949e; font-size: 12px; font-weight: 600; text-transform: uppercase; align-self: center; margin-right: 6px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.ws-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; display: flex; flex-direction: column; transition: border-color .15s; }
.ws-card:hover { border-color: #58a6ff; }
.ws-card-cover { height: 120px; background: linear-gradient(135deg, #1f6feb 0%, #a371f7 100%); display: flex; align-items: center; justify-content: center; font-size: 2.5rem; position: relative; }
.ws-card-cover-partner { position: absolute; top: 8px; right: 10px; background: rgba(0,0,0,0.5); color: #fff; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.ws-card-cover-sub { position: absolute; top: 8px; left: 10px; background: rgba(88,166,255,0.9); color: #fff; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.ws-card-body { padding: 16px; flex: 1; display: flex; flex-direction: column; }
.ws-card h3 { color: #e6edf3; font-size: 16px; font-weight: 600; margin: 0 0 8px; line-height: 1.4; }
.ws-card h3 a { color: inherit; }
.ws-card h3 a:hover { color: #58a6ff; text-decoration: none; }
.ws-card h3 a { color: inherit; text-decoration: none; }
.ws-card h3 a:hover { color: #58a6ff; }
.ws-card-desc { color: #8b949e; font-size: 13px; line-height: 1.5; margin: 0 0 12px; flex: 1; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.badges { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.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: #58a6ff; border-color: rgba(88,166,255,0.4); }
@ -64,122 +40,91 @@ a:hover { text-decoration: underline; }
.badge-difficulty-beginner { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.4); }
.badge-difficulty-intermediate { background: rgba(210,153,34,0.15); color: #d29922; border-color: rgba(210,153,34,0.4); }
.badge-difficulty-advanced { background: rgba(248,81,73,0.15); color: #f85149; border-color: rgba(248,81,73,0.4); }
.badge-recurrence { background: rgba(163,113,247,0.15); color: #a371f7; border-color: rgba(163,113,247,0.4); }
.ws-meta { display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; color: #8b949e; padding-top: 12px; border-top: 1px solid #30363d; margin-top: auto; }
.ws-meta-item { display: inline-flex; align-items: center; gap: 4px; }
.empty-state { padding: 64px 24px; text-align: center; color: #8b949e; background: #161b22; border: 1px dashed #30363d; border-radius: 6px; }
.empty-state h3 { color: #e6edf3; margin-bottom: 8px; font-size: 16px; }
.empty-state p { margin-bottom: 16px; }
@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; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav class="topnav">
<a href="/" class="topnav-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm0-8h-2V7h2v2zm4 8h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
Morozovka Platform
{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1 class="page-title">Мастер-классы</h1>
<p class="page-subtitle">Практические занятия: пайка, embedded, Linux, творчество</p>
</div>
{% if can_create %}
<a href="/workshops/new" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 2a.75.75 0 0 1 .75.75v4.5h4.5a.75.75 0 0 1 0 1.5h-4.5v4.5a.75.75 0 0 1-1.5 0v-4.5h-4.5a.75.75 0 0 1 0-1.5h4.5v-4.5A.75.75 0 0 1 8 2Z"/></svg>
Создать мастер-класс
</a>
<div class="topnav-links">
<a href="/schedule">Расписание</a>
<a href="/projects">Проекты</a>
<a href="/workshops" style="color:#58a6ff;">Мастер-классы</a>
<a href="/files">Файлы</a>
</div>
<div class="topnav-actions">
{% if session.get('username') %}
<a href="/profile" class="btn btn-default">{{ session.get('username') }}</a>
{% else %}
<a href="/login" class="btn btn-default">Войти</a>
{% endif %}
</div>
</nav>
<div class="container">
<div class="page-header">
<div>
<h1 class="page-title">Мастер-классы</h1>
<p class="page-subtitle">Практические занятия: пайка, embedded, Linux, творчество</p>
</div>
{% if can_create %}
<a href="/workshops/new" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 2a.75.75 0 0 1 .75.75v4.5h4.5a.75.75 0 0 1 0 1.5h-4.5v4.5a.75.75 0 0 1-1.5 0v-4.5h-4.5a.75.75 0 0 1 0-1.5h4.5v-4.5A.75.75 0 0 1 8 2Z"/></svg>
Создать мастер-класс
</a>
{% endif %}
</div>
<div class="filters">
<span class="filter-label">Категория:</span>
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}category=" class="filter-chip {% if not current_cat %}active{% endif %}">Все</a>
{% for cat in WORKSHOP_CATEGORIES %}
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}category={{ cat }}" class="filter-chip {% if current_cat == cat %}active{% endif %}">{{ WORKSHOP_CATEGORY_LABELS[cat] }}</a>
{% endfor %}
<span style="width:16px;"></span>
<span class="filter-label">Статус:</span>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=" class="filter-chip {% if not current_status %}active{% endif %}">Все</a>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=published" class="filter-chip {% if current_status == 'published' %}active{% endif %}">Опубликован</a>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=archived" class="filter-chip {% if current_status == 'archived' %}active{% endif %}">В архиве</a>
{% if session.get('role') in ['admin', 'professor', 'lead'] %}
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=draft" class="filter-chip {% if current_status == 'draft' %}active{% endif %}">Черновик</a>
{% endif %}
{% if current_cat or current_status %}
<a href="/workshops" class="filter-chip filter-chip-clear">✕ Сбросить</a>
{% endif %}
</div>
{% if workshops %}
<div class="grid">
{% for ws in workshops %}
<div class="ws-card">
<div class="ws-card-cover">
{% if ws.category == 'soldering' %}🔥
{% elif ws.category == 'embroidery' %}🎨
{% elif ws.category == 'embedded' %}⚙️
{% elif ws.category == 'linux' %}🐧
{% else %}🎓{% endif %}
{% if ws.partner %}<span class="ws-card-cover-partner">{{ ws.partner }}</span>{% endif %}
</div>
<div class="ws-card-body">
<div class="badges">
<span class="badge badge-cat-{{ ws.category }}">{{ WORKSHOP_CATEGORY_LABELS[ws.category] }}</span>
<span class="badge badge-difficulty-{{ ws.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[ws.difficulty] }}</span>
<span class="badge badge-status-{{ ws.status }}">{{ WORKSHOP_STATUS_LABELS[ws.status] }}</span>
{% if ws.visibility == 'internal' %}<span class="badge badge-vis-internal">Внутренний</span>{% endif %}
</div>
<h3><a href="/workshops/{{ ws.id }}">{{ ws.title }}</a></h3>
<p class="ws-card-desc">{{ ws.description or 'Без описания' }}</p>
<div class="ws-meta">
{% set lead = leads_map.get(ws.lead_id) %}
{% if lead %}<span class="ws-meta-item">👤 {{ lead.full_name or lead.username }}</span>{% endif %}
{% set sess = next_sessions.get(ws.id) %}
{% if sess %}
<span class="ws-meta-item">📅 {{ sess.start_time|msk_strftime('%d.%m %H:%M') }}–{{ sess.end_time|msk_strftime('%H:%M') }}</span>
{% endif %}
<span class="ws-meta-item">🎫 {{ reg_counts.get(ws.id, 0) }}{% if ws.max_participants %}/{{ ws.max_participants }}{% endif %}</span>
{% if ws.location %}<span class="ws-meta-item">📍 {{ ws.location }}</span>{% endif %}
{% if ws.has_certificate %}<span class="ws-meta-item">🏅 Сертификат</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h3>Пока нет мастер-классов</h3>
<p>{% if can_create %}Создайте первый — он появится здесь.{% else %}Загляните позже.{% endif %}</p>
{% if can_create %}<a href="/workshops/new" class="btn btn-primary">Создать мастер-класс</a>{% endif %}
</div>
{% endif %}
</div>
<script src="/static/notif_bell.js"></script>
</body>
</html>
<div class="filters">
<span class="filter-label">Категория:</span>
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}category=" class="filter-chip {% if not current_cat %}active{% endif %}">Все</a>
{% for cat in WORKSHOP_CATEGORIES %}
<a href="?{% if current_status %}status={{ current_status }}&{% endif %}category={{ cat }}" class="filter-chip {% if current_cat == cat %}active{% endif %}">{{ WORKSHOP_CATEGORY_LABELS[cat] }}</a>
{% endfor %}
<span style="width:16px;"></span>
<span class="filter-label">Статус:</span>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=" class="filter-chip {% if not current_status %}active{% endif %}">Все</a>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=published" class="filter-chip {% if current_status == 'published' %}active{% endif %}">Опубликован</a>
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=archived" class="filter-chip {% if current_status == 'archived' %}active{% endif %}">В архиве</a>
{% if session.get('role') == 'admin' %}
<a href="?{% if current_cat %}category={{ current_cat }}&{% endif %}status=draft" class="filter-chip {% if current_status == 'draft' %}active{% endif %}">Черновик</a>
{% endif %}
{% if current_cat or current_status %}
<a href="/workshops" class="filter-chip filter-chip-clear">✕ Сбросить</a>
{% endif %}
</div>
{% if workshops %}
<div class="grid">
{% for ws in workshops %}
<div class="ws-card">
<div class="ws-card-cover">
{% if ws.category == 'soldering' %}🔥
{% elif ws.category == 'embroidery' %}🎨
{% elif ws.category == 'embedded' %}⚙️
{% elif ws.category == 'linux' %}🐧
{% else %}🎓{% endif %}
{% if ws.partner %}<span class="ws-card-cover-partner">{{ ws.partner }}</span>{% endif %}
{% if my_subs.get(ws.id) %}<span class="ws-card-cover-sub">🔔</span>{% endif %}
</div>
<div class="ws-card-body">
<div class="badges">
<span class="badge badge-cat-{{ ws.category }}">{{ WORKSHOP_CATEGORY_LABELS[ws.category] }}</span>
<span class="badge badge-difficulty-{{ ws.difficulty }}">{{ WORKSHOP_DIFFICULTY_LABELS[ws.difficulty] }}</span>
<span class="badge badge-status-{{ ws.status }}">{{ WORKSHOP_STATUS_LABELS[ws.status] }}</span>
{% if ws.visibility == 'internal' %}<span class="badge badge-vis-internal">Внутренний</span>{% endif %}
{% if ws.recurrence and RECURRENCE_LABELS.get(ws.recurrence) %}<span class="badge badge-recurrence">🔁 {{ RECURRENCE_LABELS[ws.recurrence] }}</span>{% endif %}
</div>
<h3><a href="/workshops/{{ ws.id }}">{{ ws.title }}</a></h3>
<p class="ws-card-desc">{{ ws.description or 'Без описания' }}</p>
<div class="ws-meta">
{% set lead = leads_map.get(ws.lead_id) %}
{% if lead %}<span class="ws-meta-item">👤 {{ lead.full_name or lead.username }}</span>{% endif %}
{% set sess = next_sessions.get(ws.id) %}
{% if sess %}
<span class="ws-meta-item">📅 {{ sess.start_time|msk_strftime('%d.%m %H:%M') }}–{{ sess.end_time|msk_strftime('%H:%M') }}</span>
{% endif %}
<span class="ws-meta-item">🎫 {{ reg_counts.get(ws.id, 0) }}{% if ws.max_participants %}/{{ ws.max_participants }}{% endif %}</span>
{% if ws.location %}<span class="ws-meta-item">📍 {{ ws.location }}</span>{% endif %}
{% if ws.has_certificate %}<span class="ws-meta-item">🏅 Сертификат</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h3>Пока нет мастер-классов</h3>
<p>{% if can_create %}Создайте первый — он появится здесь.{% else %}Загляните позже.{% endif %}</p>
{% if can_create %}<a href="/workshops/new" class="btn btn-primary">Создать мастер-класс</a>{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -43,3 +43,5 @@ tzdata==2026.5
uritemplate==4.2.0
urllib3==2.8.0
Werkzeug==3.1.9
python-dotenv==1.0.1
alembic==1.13.3

124
tests/last_run.log Normal file
View File

@ -0,0 +1,124 @@
Start: 2026-10-21T07:00:13+03:00
Base: http://127.0.0.1:8000
User: smoke_test_1792555213
--- 0. Health ---
PASS: GET /healthz → 200
PASS: healthz → status:ok
PASS: healthz → db:ok
--- 1. Публичные роуты ---
PASS: GET / → 200
PASS: GET /schedule → 200
PASS: GET /feed → 200
PASS: GET /projects → 200
PASS: GET /workshops → 200
PASS: GET /login → 200
PASS: GET /register → 200
PASS: GET /forgot_password → 200
PASS: GET /files без cookie → 302
PASS: GET /dashboard без cookie → 302
PASS: GET /onboarding без cookie → 302
--- 2. Регистрация пользователя ---
PASS: POST /register → 200
PASS: user создан в БД
INFO: user_id = 5
PASS: роль admin установлена
--- 3. Логин ---
PASS: POST /login → 302
PASS: GET /dashboard после логина → 200
PASS: dashboard содержит 'Ближайшие события'
--- 4. Онбординг ---
PASS: GET /onboarding → 200
PASS: POST /onboarding/mark → 200
PASS: POST /onboarding/complete → 302
--- 5. Уведомления ---
PASS: GET /api/notifications → 200
PASS: notifications JSON содержит 'items'
PASS: POST /api/notifications/read-all → 200
--- 6. Расписание + ассистент ---
PASS: GET /schedule → 200
PASS: /schedule содержит 'cal-body'
PASS: /schedule содержит 'chat-toggle'
PASS: assistant 'Где сейчас?' → 200
PASS: ответ содержит 'reply'
PASS: assistant 'Когда следующая пара?' → 200
PASS: ответ содержит 'reply'
PASS: assistant 'Что сегодня?' → 200
PASS: ответ содержит 'reply'
PASS: assistant 'Он опаздывает?' → 200
PASS: ответ содержит 'reply'
PASS: assistant 'Как связаться?' → 200
PASS: ответ содержит 'reply'
PASS: GET /schedule/export.ics → 200
PASS: ICS содержит 'BEGIN:VCALENDAR'
--- 7. Проекты ---
PASS: POST /projects/new → 302
PASS: проект создан
INFO: project_id = 8
PASS: GET /projects/<id> → 200
PASS: GET /projects/<id>/board → 200
PASS: POST /tasks/new → 302
PASS: задача создана
--- 8. Мастер-классы ---
PASS: POST /workshops/new → 302
PASS: МК создан
INFO: workshop_id = 3
PASS: GET /workshops/<id> → 200
PASS: POST /workshops/<id>/register → 302
PASS: регистрация в БД
--- 9. Файлы v2.0 — пространство/папка/файл/версии/ACL/move/delete ---
PASS: POST /files/space/new → 302
PASS: пространство создано
INFO: space_id = 3
PASS: GET /files/space/<id> → 200
PASS: POST /files/folder/new → 302
PASS: папка создана
INFO: folder_id = 3
PASS: GET /files/folder/<id> → 200
PASS: POST /files/upload → 302
PASS: файл создан
INFO: file_id = 11
PASS: file.version = 1
PASS: current_version_id задан
PASS: fileversion count = 1
PASS: GET /files/download/<id> → 200
PASS: скачано v1
PASS: GET /files/<id>/history → 200
PASS: история содержит 'v1'
PASS: POST /files/<id>/new_version → 302
PASS: version = 2
PASS: fileversion count = 2
PASS: GET /files/<id>/version/<v1>/download → 200
PASS: скачано содержимое v1
PASS: GET /files/download/<id> (v2) → 200
PASS: скачано v2
PASS: POST /files/space/<id>/acl/add → 302
PASS: GET /files/space/<id>/acl → 200
PASS: POST /files/<id>/move → NULL → 302
PASS: space_id обнулён
PASS: version сброшен в 1
PASS: current_version_id = NULL
PASS: fileversion удалены
PASS: POST /files/<id>/move → обратно в space → 302
PASS: space_id восстановлен
PASS: fileversion count = 1 (миграция)
PASS: POST /files/<id>/delete → 302
PASS: файл удалён из БД
PASS: POST /files/folder/<id>/delete → 302
PASS: папка удалена из БД
PASS: POST /files/space/<id>/delete → 302
PASS: пространство удалено из БД
--- 10. Вебхук Gitea ---
PASS: POST /webhooks/gitea (плохая подпись) → 401
PASS: POST /webhooks/gitea (валидная подпись) → 200
--- 11. Логаут ---
PASS: GET /logout → 302
PASS: GET /dashboard после logout → 302
=== ИТОГ ===
PASS: 94
FAIL: 0
TOTAL: 94
DURATION: 4s
End: 2026-10-21T07:00:17+03:00
INFO: Cleanup тестовых данных…
INFO: Cleanup завершён

446
tests/run_smoke.sh Executable file
View File

@ -0,0 +1,446 @@
#!/bin/bash
# ══════════════════════════════════════════════════════════════════════
# Smoke-тесты Morozovka Portal — без GUI, через curl + psql
# Запуск: sudo bash tests/run_smoke.sh
# Вывод: цветной PASS/FAIL + tests/last_run.log
# ══════════════════════════════════════════════════════════════════════
set -uo pipefail
# ── Настройки ─────────────────────────────────────────────────────────
BASE="http://127.0.0.1:8000"
PG_HOST="127.0.0.1"
PG_USER="morozovka"
PG_PASS="${PG_PASS:-rf-slf-1945}"
PG_DB="morozovka_portal"
LOG_FILE="$(dirname "$0")/last_run.log"
COOKIE="/tmp/smoke_cookies_$$.txt"
TMP="/tmp/smoke_$$"
TS="$(date +%s)"
TEST_USER="smoke_test_${TS}"
TEST_PASS="SmokePass_${TS}!"
TEST_EMAIL="${TEST_USER}@test.local"
mkdir -p "$TMP"
: > "$LOG_FILE"
PASS=0
FAIL=0
TOTAL=0
START_TIME=$(date +%s)
# ── Цвета ─────────────────────────────────────────────────────────────
if [ -t 1 ]; then
C_GREEN='\033[0;32m'; C_RED='\033[0;31m'; C_YELLOW='\033[1;33m'
C_CYAN='\033[0;36m'; C_BOLD='\033[1m'; C_RESET='\033[0m'
else
C_GREEN=''; C_RED=''; C_YELLOW=''; C_CYAN=''; C_BOLD=''; C_RESET=''
fi
# ── Хелперы печати ────────────────────────────────────────────────────
section() {
local msg="$1"
echo
echo -e "${C_BOLD}═══ $msg ═══${C_RESET}"
echo "--- $msg ---" >> "$LOG_FILE"
}
info() {
echo -e "${C_CYAN} → $1${C_RESET}"
echo " INFO: $1" >> "$LOG_FILE"
}
pass() {
PASS=$((PASS+1)); TOTAL=$((TOTAL+1))
echo -e " ${C_GREEN}✓${C_RESET} $1"
echo " PASS: $1" >> "$LOG_FILE"
}
fail() {
FAIL=$((FAIL+1)); TOTAL=$((TOTAL+1))
echo -e " ${C_RED}✗${C_RESET} $1"
echo " ожидалось: $2"
echo " получено: $3"
echo " FAIL: $1" >> "$LOG_FILE"
echo " expected: $2" >> "$LOG_FILE"
echo " actual: $3" >> "$LOG_FILE"
}
# ── Хелперы проверок ──────────────────────────────────────────────────
check_status() {
local name="$1" expected="$2" actual="$3"
if [ "$actual" = "$expected" ]; then pass "$name"; else fail "$name" "$expected" "$actual"; fi
}
check_contains() {
local name="$1" pattern="$2" file="$3"
if grep -qF "$pattern" "$file" 2>/dev/null; then pass "$name"; else fail "$name" "contains '$pattern'" "не найдено в $file"; fi
}
check_not_empty() {
local name="$1" val="$2"
if [ -n "$val" ] && [ "$val" != "0" ]; then pass "$name"; else fail "$name" "not empty" "пусто"; fi
}
check_equal() {
local name="$1" expected="$2" actual="$3"
if [ "$actual" = "$expected" ]; then pass "$name"; else fail "$name" "$expected" "$actual"; fi
}
# ── psql ──────────────────────────────────────────────────────────────
psql_run() {
PGPASSWORD="$PG_PASS" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -tAc "$1" 2>/dev/null
}
psql_val() {
psql_run "$1" | tr -d ' \n'
}
# ── curl обёртки ──────────────────────────────────────────────────────
http_code_get() {
curl -sS -o "$2" -w "%{http_code}" -b "$COOKIE" "$1" 2>/dev/null
}
http_code_post_form() {
curl -sS -o "$2" -w "%{http_code}" -b "$COOKIE" -c "$COOKIE" -X POST "$1" -d "$3" 2>/dev/null
}
http_code_post_json() {
curl -sS -o "$2" -w "%{http_code}" -b "$COOKIE" -c "$COOKIE" -X POST "$1" \
-H "Content-Type: application/json" -d "$3" 2>/dev/null
}
# ── Cleanup ───────────────────────────────────────────────────────────
cleanup() {
info "Cleanup тестовых данных…"
local USER_ID="${TEST_USER_ID:-}"
if [ -n "$USER_ID" ]; then
# Удаляем строго по ID, ничего чужого не заденем
psql_run "DELETE FROM portalfile WHERE uploaded_by = $USER_ID" >/dev/null
psql_run "DELETE FROM fileversion WHERE uploaded_by = $USER_ID" >/dev/null
psql_run "DELETE FROM filefolderacl WHERE user_id = $USER_ID" >/dev/null
psql_run "DELETE FROM filespaceacl WHERE user_id = $USER_ID" >/dev/null
psql_run "DELETE FROM filefolder WHERE created_by = $USER_ID" >/dev/null
psql_run "DELETE FROM filespace WHERE owner_id = $USER_ID" >/dev/null
psql_run "DELETE FROM project WHERE owner_id = $USER_ID" >/dev/null
psql_run "DELETE FROM workshop WHERE lead_id = $USER_ID" >/dev/null
psql_run "DELETE FROM projecttask WHERE created_by = $USER_ID" >/dev/null
psql_run "DELETE FROM projectpost WHERE author_id = $USER_ID" >/dev/null
psql_run "DELETE FROM workshoppost WHERE author_id = $USER_ID" >/dev/null
psql_run "DELETE FROM useronboarding WHERE user_id = $USER_ID" >/dev/null
psql_run "DELETE FROM eventsubscriber WHERE user_id = $USER_ID" >/dev/null
psql_run "DELETE FROM usersession WHERE user_id = $USER_ID" >/dev/null
psql_run "DELETE FROM notification WHERE user_id = $USER_ID" >/dev/null
psql_run "DELETE FROM \"user\" WHERE id = $USER_ID" >/dev/null
fi
rm -f "$COOKIE"
rm -rf "$TMP"
info "Cleanup завершён"
}
trap cleanup EXIT
# ══════════════════════════════════════════════════════════════════════
# НАЧАЛО
# ══════════════════════════════════════════════════════════════════════
echo -e "${C_BOLD}╔══════════════════════════════════════════════════════╗${C_RESET}"
echo -e "${C_BOLD}║ Morozovka Portal — Smoke Tests (no GUI) ║${C_RESET}"
echo -e "${C_BOLD}╚══════════════════════════════════════════════════════╝${C_RESET}"
echo "Start: $(date -Is)"
echo "Base: $BASE"
echo "User: $TEST_USER"
echo "Log: $LOG_FILE"
echo
echo "Start: $(date -Is)" >> "$LOG_FILE"
echo "Base: $BASE" >> "$LOG_FILE"
echo "User: $TEST_USER" >> "$LOG_FILE"
# ── 0. Проверка, что сервис жив ───────────────────────────────────────
section "0. Health"
CODE=$(http_code_get "$BASE/healthz" "$TMP/healthz.json")
check_status "GET /healthz → 200" "200" "$CODE"
check_contains "healthz → status:ok" '"status":"ok"' "$TMP/healthz.json"
check_contains "healthz → db:ok" '"db":"ok"' "$TMP/healthz.json"
# ── 1. Публичные роуты ────────────────────────────────────────────────
section "1. Публичные роуты"
check_status "GET / → 200" "200" "$(http_code_get "$BASE/" "$TMP/index.html")"
check_status "GET /schedule → 200" "200" "$(http_code_get "$BASE/schedule" "$TMP/schedule.html")"
check_status "GET /feed → 200" "200" "$(http_code_get "$BASE/feed" "$TMP/feed.html")"
check_status "GET /projects → 200" "200" "$(http_code_get "$BASE/projects" "$TMP/projects.html")"
check_status "GET /workshops → 200" "200" "$(http_code_get "$BASE/workshops" "$TMP/workshops.html")"
check_status "GET /login → 200" "200" "$(http_code_get "$BASE/login" "$TMP/login.html")"
check_status "GET /register → 200" "200" "$(http_code_get "$BASE/register" "$TMP/register.html")"
check_status "GET /forgot_password → 200" "200" "$(http_code_get "$BASE/forgot_password" "$TMP/forgot.html")"
# Защищённые без cookie → 302 на login
rm -f "$COOKIE"
check_status "GET /files без cookie → 302" "302" "$(http_code_get "$BASE/files" "$TMP/files_noauth.html")"
check_status "GET /dashboard без cookie → 302" "302" "$(http_code_get "$BASE/dashboard" "$TMP/dash_noauth.html")"
check_status "GET /onboarding без cookie → 302" "302" "$(http_code_get "$BASE/onboarding" "$TMP/onb_noauth.html")"
# ── 2. Регистрация тестового пользователя ────────────────────────────
section "2. Регистрация пользователя"
CODE=$(http_code_post_form "$BASE/register" "$TMP/reg_resp.html" \
"username=${TEST_USER}&email=${TEST_EMAIL}&password=${TEST_PASS}&full_name=Smoke+Test&organization=%D0%9D%D0%9D%D0%93%D0%A3")
check_status "POST /register → 200" "200" "$CODE"
TEST_USER_ID=$(psql_val "SELECT id FROM \"user\" WHERE username = '$TEST_USER'")
check_not_empty "user создан в БД" "$TEST_USER_ID"
if [ -z "$TEST_USER_ID" ] || [ "$TEST_USER_ID" = "0" ]; then
echo -e "${C_RED}КРИТИЧНО: тестовый юзер не создан. Останавливаюсь.${C_RESET}"
exit 1
fi
info "user_id = $TEST_USER_ID"
# Промоут до admin (иначе require_role заблокирует создание проектов/МК)
psql_run "UPDATE \"user\" SET role = 'admin', is_verified = true WHERE id = $TEST_USER_ID" >/dev/null
check_equal "роль admin установлена" "admin" "$(psql_val "SELECT role FROM \"user\" WHERE id = $TEST_USER_ID")"
# ── 3. Логин ──────────────────────────────────────────────────────────
section "3. Логин"
rm -f "$COOKIE"
CODE=$(curl -sS -o "$TMP/login_resp.html" -w "%{http_code}" -c "$COOKIE" -X POST "$BASE/login" \
-d "username=${TEST_USER}&password=${TEST_PASS}" 2>/dev/null)
check_status "POST /login → 302" "302" "$CODE"
check_status "GET /dashboard после логина → 200" "200" "$(http_code_get "$BASE/dashboard" "$TMP/dashboard.html")"
check_contains "dashboard содержит 'Ближайшие события'" "Ближайшие события" "$TMP/dashboard.html"
# ── 4. Онбординг ──────────────────────────────────────────────────────
section "4. Онбординг"
check_status "GET /onboarding → 200" "200" "$(http_code_get "$BASE/onboarding" "$TMP/onb.html")"
CODE=$(http_code_post_json "$BASE/onboarding/mark" "$TMP/onb_mark.json" \
'{"key":"visited_schedule","value":true}')
check_status "POST /onboarding/mark → 200" "200" "$CODE"
CODE=$(http_code_post_form "$BASE/onboarding/complete" "$TMP/onb_complete.html" "")
check_status "POST /onboarding/complete → 302" "302" "$CODE"
# ── 5. Уведомления ────────────────────────────────────────────────────
section "5. Уведомления"
check_status "GET /api/notifications → 200" "200" "$(http_code_get "$BASE/api/notifications" "$TMP/notif.json")"
check_contains "notifications JSON содержит 'items'" '"items"' "$TMP/notif.json"
CODE=$(http_code_post_json "$BASE/api/notifications/read-all" "$TMP/notif_read.json" "{}")
check_status "POST /api/notifications/read-all → 200" "200" "$CODE"
# ── 6. Расписание + ассистент ────────────────────────────────────────
section "6. Расписание + ассистент"
check_status "GET /schedule → 200" "200" "$(http_code_get "$BASE/schedule" "$TMP/sched.html")"
check_contains "/schedule содержит 'cal-body'" 'cal-body' "$TMP/sched.html"
check_contains "/schedule содержит 'chat-toggle'" 'chat-toggle' "$TMP/sched.html"
# Ассистент — 5 интентов
for M in "Где сейчас?" "Когда следующая пара?" "Что сегодня?" "Он опаздывает?" "Как связаться?"; do
CODE=$(http_code_post_json "$BASE/api/assistant" "$TMP/assist.json" "{\"message\":\"$M\"}")
check_status "assistant '$M' → 200" "200" "$CODE"
check_contains " ответ содержит 'reply'" '"reply"' "$TMP/assist.json"
done
# ICS export
CODE=$(http_code_get "$BASE/schedule/export.ics" "$TMP/sched.ics")
check_status "GET /schedule/export.ics → 200" "200" "$CODE"
check_contains "ICS содержит 'BEGIN:VCALENDAR'" "BEGIN:VCALENDAR" "$TMP/sched.ics"
# ── 7. Проекты ────────────────────────────────────────────────────────
section "7. Проекты"
PROJ_NAME="SmokeTest Project $TS"
CODE=$(http_code_post_form "$BASE/projects/new" "$TMP/proj_new.html" \
"title=$(printf '%s' "$PROJ_NAME" | sed 's/ /+/g')&description=auto&status=in_progress&visibility=internal")
check_status "POST /projects/new → 302" "302" "$CODE"
PROJ_ID=$(psql_val "SELECT id FROM project WHERE title = '$PROJ_NAME' ORDER BY id DESC LIMIT 1")
check_not_empty "проект создан" "$PROJ_ID"
info "project_id = $PROJ_ID"
check_status "GET /projects/<id> → 200" "200" "$(http_code_get "$BASE/projects/$PROJ_ID" "$TMP/proj.html")"
check_status "GET /projects/<id>/board → 200" "200" "$(http_code_get "$BASE/projects/$PROJ_ID/board" "$TMP/board.html")"
# Задача
CODE=$(http_code_post_form "$BASE/projects/$PROJ_ID/tasks/new" "$TMP/task.html" \
"title=Smoke+Task&priority=medium&status=backlog")
check_status "POST /tasks/new → 302" "302" "$CODE"
TASK_ID=$(psql_val "SELECT id FROM projecttask WHERE project_id = $PROJ_ID ORDER BY id DESC LIMIT 1")
check_not_empty "задача создана" "$TASK_ID"
# ── 8. Мастер-классы ─────────────────────────────────────────────────
section "8. Мастер-классы"
WS_NAME="SmokeTest WS $TS"
CODE=$(http_code_post_form "$BASE/workshops/new" "$TMP/ws_new.html" \
"title=$(printf '%s' "$WS_NAME" | sed 's/ /+/g')&description=auto&category=other&difficulty=beginner&status=published&visibility=public&lead_id=$TEST_USER_ID")
check_status "POST /workshops/new → 302" "302" "$CODE"
WS_ID=$(psql_val "SELECT id FROM workshop WHERE title = '$WS_NAME' ORDER BY id DESC LIMIT 1")
check_not_empty "МК создан" "$WS_ID"
info "workshop_id = $WS_ID"
check_status "GET /workshops/<id> → 200" "200" "$(http_code_get "$BASE/workshops/$WS_ID" "$TMP/ws.html")"
# Регистрация на МК
CODE=$(http_code_post_form "$BASE/workshops/$WS_ID/register" "$TMP/ws_reg.html" "")
check_status "POST /workshops/<id>/register → 302" "302" "$CODE"
REG_COUNT=$(psql_val "SELECT COUNT(*) FROM workshopregistration WHERE workshop_id = $WS_ID AND user_id = $TEST_USER_ID")
check_equal "регистрация в БД" "1" "$REG_COUNT"
# ── 9. Файлы v2.0 ────────────────────────────────────────────────────
section "9. Файлы v2.0 — пространство/папка/файл/версии/ACL/move/delete"
SPACE_NAME="SmokeTest Space $TS"
CODE=$(http_code_post_form "$BASE/files/space/new" "$TMP/sp_new.html" \
"name=$(printf '%s' "$SPACE_NAME" | sed 's/ /+/g')&description=auto&visibility=internal")
check_status "POST /files/space/new → 302" "302" "$CODE"
SPACE_ID=$(psql_val "SELECT id FROM filespace WHERE name = '$SPACE_NAME' ORDER BY id DESC LIMIT 1")
check_not_empty "пространство создано" "$SPACE_ID"
info "space_id = $SPACE_ID"
check_status "GET /files/space/<id> → 200" "200" "$(http_code_get "$BASE/files/space/$SPACE_ID" "$TMP/space.html")"
# Папка
FOLDER_NAME="TestFolder_$TS"
CODE=$(http_code_post_form "$BASE/files/folder/new" "$TMP/folder_new.html" \
"space_id=$SPACE_ID&parent_id=&name=$FOLDER_NAME&description=auto")
check_status "POST /files/folder/new → 302" "302" "$CODE"
FOLDER_ID=$(psql_val "SELECT id FROM filefolder WHERE name = '$FOLDER_NAME' AND space_id = $SPACE_ID")
check_not_empty "папка создана" "$FOLDER_ID"
info "folder_id = $FOLDER_ID"
check_status "GET /files/folder/<id> → 200" "200" "$(http_code_get "$BASE/files/folder/$FOLDER_ID" "$TMP/folder.html")"
# Файл
echo "test content v1 — $(date -Is)" > "$TMP/upload.txt"
CODE=$(curl -sS -o "$TMP/up_resp.html" -w "%{http_code}" -b "$COOKIE" -c "$COOKIE" \
-X POST "$BASE/files/upload" \
-F "file=@$TMP/upload.txt;filename=smoke_test_$TS.txt" \
-F "space_id=$SPACE_ID" -F "folder_id=$FOLDER_ID" 2>/dev/null)
check_status "POST /files/upload → 302" "302" "$CODE"
FILE_ID=$(psql_val "SELECT id FROM portalfile WHERE space_id = $SPACE_ID AND folder_id = $FOLDER_ID ORDER BY id DESC LIMIT 1")
check_not_empty "файл создан" "$FILE_ID"
info "file_id = $FILE_ID"
# Проверка FileVersion(1)
FILE_VERSION=$(psql_val "SELECT version FROM portalfile WHERE id = $FILE_ID")
check_equal "file.version = 1" "1" "$FILE_VERSION"
CUR_VER_ID=$(psql_val "SELECT current_version_id FROM portalfile WHERE id = $FILE_ID")
check_not_empty "current_version_id задан" "$CUR_VER_ID"
VER_COUNT=$(psql_val "SELECT COUNT(*) FROM fileversion WHERE file_id = $FILE_ID")
check_equal "fileversion count = 1" "1" "$VER_COUNT"
# Скачать текущую
check_status "GET /files/download/<id> → 200" "200" "$(http_code_get "$BASE/files/download/$FILE_ID" "$TMP/dl1.txt")"
check_contains "скачано v1" "test content v1" "$TMP/dl1.txt"
# История
check_status "GET /files/<id>/history → 200" "200" "$(http_code_get "$BASE/files/$FILE_ID/history" "$TMP/history.html")"
check_contains "история содержит 'v1'" "v1" "$TMP/history.html"
# Новая версия
echo "test content v2 — $(date -Is)" > "$TMP/upload_v2.txt"
CODE=$(curl -sS -o "$TMP/newv.html" -w "%{http_code}" -b "$COOKIE" -c "$COOKIE" \
-X POST "$BASE/files/$FILE_ID/new_version" \
-F "file=@$TMP/upload_v2.txt;filename=smoke_test_$TS.txt" \
-F "comment=Auto v2" 2>/dev/null)
check_status "POST /files/<id>/new_version → 302" "302" "$CODE"
check_equal "version = 2" "2" "$(psql_val "SELECT version FROM portalfile WHERE id = $FILE_ID")"
check_equal "fileversion count = 2" "2" "$(psql_val "SELECT COUNT(*) FROM fileversion WHERE file_id = $FILE_ID")"
# Скачать v1
V1_ID=$(psql_val "SELECT id FROM fileversion WHERE file_id = $FILE_ID AND version_num = 1")
check_status "GET /files/<id>/version/<v1>/download → 200" "200" \
"$(http_code_get "$BASE/files/$FILE_ID/version/$V1_ID/download" "$TMP/dl_v1.txt")"
check_contains "скачано содержимое v1" "test content v1" "$TMP/dl_v1.txt"
# Скачать текущую (v2)
check_status "GET /files/download/<id> (v2) → 200" "200" "$(http_code_get "$BASE/files/download/$FILE_ID" "$TMP/dl_v2.txt")"
check_contains "скачано v2" "test content v2" "$TMP/dl_v2.txt"
# ACL пространства
CODE=$(http_code_post_form "$BASE/files/space/$SPACE_ID/acl/add" "$TMP/acl.html" \
"username=$TEST_USER&role=editor")
check_status "POST /files/space/<id>/acl/add → 302" "302" "$CODE"
# (нельзя добавить себя-владельца, роут вернёт redirect без изменений — это OK, проверяем что не 500)
ACL_PAGE_CODE=$(http_code_get "$BASE/files/space/$SPACE_ID/acl" "$TMP/acl_page.html")
check_status "GET /files/space/<id>/acl → 200" "200" "$ACL_PAGE_CODE"
# Move: файл из пространства в NULL
CODE=$(http_code_post_form "$BASE/files/$FILE_ID/move" "$TMP/move1.html" \
"space_id=&folder_id=")
check_status "POST /files/<id>/move → NULL → 302" "302" "$CODE"
check_equal "space_id обнулён" "" "$(psql_val "SELECT space_id FROM portalfile WHERE id = $FILE_ID AND space_id IS NOT NULL")"
check_equal "version сброшен в 1" "1" "$(psql_val "SELECT version FROM portalfile WHERE id = $FILE_ID")"
check_equal "current_version_id = NULL" "" "$(psql_val "SELECT current_version_id FROM portalfile WHERE id = $FILE_ID AND current_version_id IS NOT NULL")"
check_equal "fileversion удалены" "0" "$(psql_val "SELECT COUNT(*) FROM fileversion WHERE file_id = $FILE_ID")"
# Move обратно в пространство
CODE=$(http_code_post_form "$BASE/files/$FILE_ID/move" "$TMP/move2.html" \
"space_id=$SPACE_ID&folder_id=$FOLDER_ID")
check_status "POST /files/<id>/move → обратно в space → 302" "302" "$CODE"
check_equal "space_id восстановлен" "$SPACE_ID" "$(psql_val "SELECT space_id FROM portalfile WHERE id = $FILE_ID")"
check_equal "fileversion count = 1 (миграция)" "1" "$(psql_val "SELECT COUNT(*) FROM fileversion WHERE file_id = $FILE_ID")"
# Удаление файла
CODE=$(http_code_post_form "$BASE/files/$FILE_ID/delete" "$TMP/fdel.html" "next=/files/space/$SPACE_ID")
check_status "POST /files/<id>/delete → 302" "302" "$CODE"
FILE_EXISTS=$(psql_val "SELECT COUNT(*) FROM portalfile WHERE id = $FILE_ID")
check_equal "файл удалён из БД" "0" "$FILE_EXISTS"
# Удаление ПАПКИ (главная проверка!)
CODE=$(http_code_post_form "$BASE/files/folder/$FOLDER_ID/delete" "$TMP/folder_del.html" "")
check_status "POST /files/folder/<id>/delete → 302" "302" "$CODE"
FOLDER_EXISTS=$(psql_val "SELECT COUNT(*) FROM filefolder WHERE id = $FOLDER_ID")
check_equal "папка удалена из БД" "0" "$FOLDER_EXISTS"
# Удаление ПРОСТРАНСТВА
CODE=$(http_code_post_form "$BASE/files/space/$SPACE_ID/delete" "$TMP/space_del.html" "")
check_status "POST /files/space/<id>/delete → 302" "302" "$CODE"
SPACE_EXISTS=$(psql_val "SELECT COUNT(*) FROM filespace WHERE id = $SPACE_ID")
check_equal "пространство удалено из БД" "0" "$SPACE_EXISTS"
# ── 10. Вебхук ───────────────────────────────────────────────────────
section "10. Вебхук Gitea"
CODE=$(curl -sS -o "$TMP/wh_bad.json" -w "%{http_code}" -X POST "$BASE/webhooks/gitea" \
-H "X-Gitea-Event: push" -H "X-Gitea-Signature: DEADBEEF" \
-H "Content-Type: application/json" -d '{}' 2>/dev/null)
check_status "POST /webhooks/gitea (плохая подпись) → 401" "401" "$CODE"
# Валидная подпись
SECRET=$(grep '^GITEA_WEBHOOK_SECRET=' /opt/morozovka-portal/.env 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")
if [ -n "$SECRET" ]; then
BODY='{"repository":{"full_name":"test/nonexistent"},"pusher":{"username":"smoke"},"ref":"refs/heads/main","commits":[]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" 2>/dev/null | awk '{print $NF}')
CODE=$(curl -sS -o "$TMP/wh_ok.json" -w "%{http_code}" -X POST "$BASE/webhooks/gitea" \
-H "X-Gitea-Event: push" -H "X-Gitea-Signature: $SIG" \
-H "Content-Type: application/json" -d "$BODY" 2>/dev/null)
check_status "POST /webhooks/gitea (валидная подпись) → 200" "200" "$CODE"
else
info "GITEA_WEBHOOK_SECRET не найден в .env — пропускаю валидную проверку"
fi
# ── 11. Логаут ────────────────────────────────────────────────────────
section "11. Логаут"
check_status "GET /logout → 302" "302" "$(http_code_get "$BASE/logout" "$TMP/logout.html")"
rm -f "$COOKIE"
check_status "GET /dashboard после logout → 302" "302" "$(http_code_get "$BASE/dashboard" "$TMP/dash_after.html")"
# ══════════════════════════════════════════════════════════════════════
# ИТОГ
# ══════════════════════════════════════════════════════════════════════
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo
echo -e "${C_BOLD}╔══════════════════════════════════════════════════════╗${C_RESET}"
if [ "$FAIL" -eq 0 ]; then
echo -e "${C_BOLD}║${C_RESET} ${C_GREEN}${C_BOLD}ВСЕ ТЕСТЫ ПРОШЛИ${C_RESET} ${C_BOLD}║${C_RESET}"
else
echo -e "${C_BOLD}║${C_RESET} ${C_RED}${C_BOLD}ЕСТЬ ПРОВАЛЫ: $FAIL${C_RESET} ${C_BOLD}║${C_RESET}"
fi
echo -e "${C_BOLD}╚══════════════════════════════════════════════════════╝${C_RESET}"
echo -e " ${C_GREEN}PASS:${C_RESET} $PASS"
echo -e " ${C_RED}FAIL:${C_RESET} $FAIL"
echo -e " Всего: $TOTAL"
echo -e " Длительность: ${DURATION}s"
echo -e " Лог: ${C_CYAN}$LOG_FILE${C_RESET}"
echo "" >> "$LOG_FILE"
echo "=== ИТОГ ===" >> "$LOG_FILE"
echo "PASS: $PASS" >> "$LOG_FILE"
echo "FAIL: $FAIL" >> "$LOG_FILE"
echo "TOTAL: $TOTAL" >> "$LOG_FILE"
echo "DURATION: ${DURATION}s" >> "$LOG_FILE"
echo "End: $(date -Is)" >> "$LOG_FILE"
if [ "$FAIL" -eq 0 ]; then exit 0; else exit 1; fi