add some things.....
This commit is contained in:
parent
31c03a698a
commit
97f53d4c4c
26
.env
Normal file
26
.env
Normal 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
110
README.md
Normal 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
117
alembic.ini
Normal 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
1
alembic/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration.
|
||||
116
alembic/env.py
Normal file
116
alembic/env.py
Normal 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
26
alembic/script.py.mako
Normal 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"}
|
||||
147
alembic/versions/0aab74aaa3fc_v2_0_filespace_filefolder_acls_.py
Normal file
147
alembic/versions/0aab74aaa3fc_v2_0_filespace_filefolder_acls_.py
Normal 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')
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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 "Извините, я пока не понимаю этот вопрос. Попробуйте спросить: 'Где сейчас?', 'Когда следующая пара?', 'Что сегодня?' или 'Как связаться?'."
|
||||
|
|
@ -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
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
628
app/helpers.py
Normal 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
|
||||
1991
app/main.py
1991
app/main.py
File diff suppressed because it is too large
Load Diff
679
app/main.py.bak
679
app/main.py.bak
|
|
@ -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"))
|
||||
175
app/models.py
175
app/models.py
|
|
@ -2,6 +2,7 @@ from sqlmodel import SQLModel, Field
|
|||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
gitea_id: Optional[int] = Field(default=None, unique=True, index=True)
|
||||
|
|
@ -15,6 +16,7 @@ class User(SQLModel, table=True):
|
|||
is_verified: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class UserSession(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
session_token: str = Field(unique=True, index=True)
|
||||
|
|
@ -23,6 +25,7 @@ class UserSession(SQLModel, table=True):
|
|||
user_agent: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Event(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
title: str
|
||||
|
|
@ -37,6 +40,7 @@ class Event(SQLModel, table=True):
|
|||
manually_edited: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class GlobalStatus(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
status: str = Field(default="free")
|
||||
|
|
@ -44,6 +48,7 @@ class GlobalStatus(SQLModel, table=True):
|
|||
message: Optional[str] = None
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Project(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
title: str
|
||||
|
|
@ -59,13 +64,22 @@ class Project(SQLModel, table=True):
|
|||
contact_person: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ProjectMember(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
project_id: int = Field(foreign_key="project.id")
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
role: str = Field(default="viewer")
|
||||
can_view_files: bool = Field(default=True)
|
||||
can_edit_files: bool = Field(default=False)
|
||||
can_view_repo: bool = Field(default=True)
|
||||
can_post_feed: bool = Field(default=False)
|
||||
can_edit_own_posts: bool = Field(default=True)
|
||||
can_edit_any_post: bool = Field(default=False)
|
||||
can_manage_members: bool = Field(default=False)
|
||||
joined_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ProjectTask(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
project_id: int = Field(foreign_key="project.id")
|
||||
|
|
@ -80,14 +94,49 @@ class ProjectTask(SQLModel, table=True):
|
|||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ProjectPost(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
project_id: int = Field(foreign_key="project.id")
|
||||
author_id: int = Field(foreign_key="user.id")
|
||||
title: str
|
||||
body: Optional[str] = None
|
||||
is_pinned: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ProjectPostAttachment(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
post_id: int = Field(foreign_key="projectpost.id")
|
||||
file_id: int = Field(foreign_key="portalfile.id")
|
||||
|
||||
|
||||
class ProjectSubscription(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
project_id: int = Field(foreign_key="project.id")
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
notify_email: bool = Field(default=True)
|
||||
notify_inapp: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class PortalFile(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
original_name: str
|
||||
stored_name: str
|
||||
stored_name: str
|
||||
size_bytes: int
|
||||
uploaded_by: int = Field(foreign_key="user.id")
|
||||
project_id: Optional[int] = Field(default=None, foreign_key="project.id")
|
||||
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
# ── v2.0 ──────────────────────────────────────────────────────────
|
||||
space_id: Optional[int] = Field(default=None, foreign_key="filespace.id")
|
||||
folder_id: Optional[int] = Field(default=None, foreign_key="filefolder.id")
|
||||
description: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
version: int = Field(default=1)
|
||||
current_version_id: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
class EventSubscriber(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -100,9 +149,6 @@ class EventSubscriber(SQLModel, table=True):
|
|||
notify_events: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# МАСТЕР-КЛАССЫ
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class Workshop(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -111,19 +157,28 @@ class Workshop(SQLModel, table=True):
|
|||
program: Optional[str] = None
|
||||
lead_id: int = Field(foreign_key="user.id")
|
||||
category: str = Field(default="other")
|
||||
difficulty: str = Field(default="beginner") # beginner, intermediate, advanced
|
||||
partner: Optional[str] = None # ННГУ, Неймарк, Ядро, ...
|
||||
status: str = Field(default="draft") # draft, published, archived
|
||||
visibility: str = Field(default="public") # public, internal
|
||||
difficulty: str = Field(default="beginner")
|
||||
partner: Optional[str] = None
|
||||
status: str = Field(default="draft")
|
||||
visibility: str = Field(default="public")
|
||||
location: Optional[str] = None
|
||||
materials_required: Optional[str] = None
|
||||
outcome: Optional[str] = None # что получит участник
|
||||
outcome: Optional[str] = None
|
||||
has_certificate: bool = Field(default=False)
|
||||
recurrence: Optional[str] = None
|
||||
max_participants: Optional[int] = None
|
||||
cover_url: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkshopAssistant(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
workshop_id: int = Field(foreign_key="workshop.id")
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkshopSession(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
workshop_id: int = Field(foreign_key="workshop.id")
|
||||
|
|
@ -132,6 +187,7 @@ class WorkshopSession(SQLModel, table=True):
|
|||
location_override: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class WorkshopRegistration(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
workshop_id: int = Field(foreign_key="workshop.id")
|
||||
|
|
@ -140,6 +196,7 @@ class WorkshopRegistration(SQLModel, table=True):
|
|||
notes: Optional[str] = None
|
||||
registered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkshopMaterial(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
workshop_id: int = Field(foreign_key="workshop.id")
|
||||
|
|
@ -150,6 +207,33 @@ class WorkshopMaterial(SQLModel, table=True):
|
|||
visible_to: str = Field(default="registered")
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkshopPost(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
workshop_id: int = Field(foreign_key="workshop.id")
|
||||
author_id: int = Field(foreign_key="user.id")
|
||||
title: str
|
||||
body: Optional[str] = None
|
||||
is_pinned: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkshopPostAttachment(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
post_id: int = Field(foreign_key="workshoppost.id")
|
||||
file_id: int = Field(foreign_key="portalfile.id")
|
||||
|
||||
|
||||
class WorkshopSubscription(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
workshop_id: int = Field(foreign_key="workshop.id")
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
notify_email: bool = Field(default=True)
|
||||
notify_inapp: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkshopFeedback(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
workshop_id: int = Field(foreign_key="workshop.id")
|
||||
|
|
@ -158,9 +242,6 @@ class WorkshopFeedback(SQLModel, table=True):
|
|||
comment: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# УВЕДОМЛЕНИЯ
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class Notification(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
@ -168,6 +249,74 @@ class Notification(SQLModel, table=True):
|
|||
title: str
|
||||
body: Optional[str] = None
|
||||
link_url: Optional[str] = None
|
||||
kind: str = Field(default="info") # info, success, warning, error
|
||||
kind: str = Field(default="info")
|
||||
is_read: bool = Field(default=False, index=True)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class UserOnboarding(SQLModel, table=True):
|
||||
"""Прогресс прохождения обучающего тура по платформе."""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", unique=True, index=True)
|
||||
visited_schedule: bool = Field(default=False)
|
||||
visited_projects: bool = Field(default=False)
|
||||
visited_workshops: bool = Field(default=False)
|
||||
visited_files: bool = Field(default=False)
|
||||
visited_feed: bool = Field(default=False)
|
||||
visited_profile: bool = Field(default=False)
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class FileSpace(SQLModel, table=True):
|
||||
"""Пространство файлов: по проекту, по направлению, личное."""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str
|
||||
slug: str = Field(unique=True, index=True)
|
||||
description: Optional[str] = None
|
||||
owner_id: int = Field(foreign_key="user.id")
|
||||
project_id: Optional[int] = Field(default=None, foreign_key="project.id")
|
||||
visibility: str = Field(default="internal") # public | internal | private
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class FileFolder(SQLModel, table=True):
|
||||
"""Папка внутри пространства. parent_id=null — корень пространства."""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
space_id: int = Field(foreign_key="filespace.id")
|
||||
parent_id: Optional[int] = Field(default=None, foreign_key="filefolder.id")
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
created_by: int = Field(foreign_key="user.id")
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class FileSpaceACL(SQLModel, table=True):
|
||||
"""Права пользователя на пространство."""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
space_id: int = Field(foreign_key="filespace.id")
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
role: str = Field(default="viewer") # viewer | editor | manager
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class FileFolderACL(SQLModel, table=True):
|
||||
"""Права пользователя на папку (перекрывают права пространства)."""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
folder_id: int = Field(foreign_key="filefolder.id")
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
can_view: bool = Field(default=True)
|
||||
can_upload: bool = Field(default=False)
|
||||
can_delete: bool = Field(default=False)
|
||||
can_manage: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class FileVersion(SQLModel, table=True):
|
||||
"""Версия файла — каждая имеет свой stored_name."""
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
file_id: int = Field(foreign_key="portalfile.id")
|
||||
version_num: int = Field(default=1)
|
||||
stored_name: str
|
||||
size_bytes: int
|
||||
uploaded_by: int = Field(foreign_key="user.id")
|
||||
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
comment: Optional[str] = None
|
||||
|
|
|
|||
1
app/modules/__init__.py
Normal file
1
app/modules/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Модули роутов Morozovka Portal."""
|
||||
104
app/modules/admin_routes.py
Normal file
104
app/modules/admin_routes.py
Normal 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
287
app/modules/auth_routes.py
Normal 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))
|
||||
121
app/modules/dashboard_routes.py
Normal file
121
app/modules/dashboard_routes.py
Normal 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
157
app/modules/feed_routes.py
Normal 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
1063
app/modules/files_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
83
app/modules/gitea_routes.py
Normal file
83
app/modules/gitea_routes.py
Normal 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,
|
||||
})
|
||||
63
app/modules/notifications_routes.py
Normal file
63
app/modules/notifications_routes.py
Normal 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)
|
||||
107
app/modules/onboarding_routes.py
Normal file
107
app/modules/onboarding_routes.py
Normal 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"))
|
||||
586
app/modules/projects_routes.py
Normal file
586
app/modules/projects_routes.py
Normal 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})
|
||||
208
app/modules/schedule_routes.py
Normal file
208
app/modules/schedule_routes.py
Normal 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")
|
||||
486
app/modules/workshops_routes.py
Normal file
486
app/modules/workshops_routes.py
Normal 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
1160
app/static/main.css
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
58
app/templates/base.html
Normal 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>
|
||||
459
app/templates/dashboard.html
Normal file
459
app/templates/dashboard.html
Normal 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
75
app/templates/feed.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
197
app/templates/files_acl.html
Normal file
197
app/templates/files_acl.html
Normal 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 %}
|
||||
225
app/templates/files_edit.html
Normal file
225
app/templates/files_edit.html
Normal 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 %}
|
||||
119
app/templates/files_history.html
Normal file
119
app/templates/files_history.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
106
app/templates/onboarding.html
Normal file
106
app/templates/onboarding.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
124
tests/last_run.log
Normal 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
446
tests/run_smoke.sh
Executable 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
|
||||
Loading…
Reference in New Issue
Block a user