add user? new page? new web

This commit is contained in:
2026-03-23 21:28:10 +05:00
parent 04bfff3d3f
commit 9cd6d83428
26 changed files with 917 additions and 65 deletions

View File

@@ -1,3 +1,14 @@
FROM nginx:alpine FROM python:3.11-slim
COPY . /usr/share/nginx/html
EXPOSE 80 WORKDIR /app
# Устанавливаем flet (uvicorn уже входит в зависимости flet)
RUN pip install --no-cache-dir flet "uvicorn[standard]" httpx
# Копируем исходники веб-приложения
COPY . .
EXPOSE 80
# Запускаем Flet как ASGI-приложение через uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]

306
SECURITY.md Normal file
View File

@@ -0,0 +1,306 @@
# Безопасность проекта — объяснение для чайников
Этот документ объясняет простым языком, **что и зачем** было сделано для защиты API.
---
## Оглавление
1. [Что такое «небезопасный» API и почему это плохо](#1-что-такое-небезопасный-api)
2. [Что было сделано — краткий список](#2-что-было-сделано)
3. [Аутентификация — кто ты такой?](#3-аутентификация)
4. [JWT-токены — цифровой пропуск](#4-jwt-токены)
5. [Хеширование паролей](#5-хеширование-паролей)
6. [Защита маршрутов (роутов)](#6-защита-маршрутов)
7. [Admin Key — ключ администратора](#7-admin-key)
8. [Защита документации `/docs`](#8-защита-документации-docs)
9. [CORS — кто может обращаться к API из браузера](#9-cors)
10. [Переменные окружения — секреты не в коде](#10-переменные-окружения)
11. [Как всё настроить — пошаговая инструкция](#11-как-настроить)
12. [Как использовать API — примеры](#12-примеры-использования)
---
## 1. Что такое «небезопасный» API
Представь, что у тебя есть склад. **Небезопасный API** — это когда дверь склада открыта для всех: любой может зайти, взять что хочет или сломать что хочет.
Конкретно, без защиты:
- любой человек в интернете мог удалить любого пользователя
- любой мог посмотреть чужие данные
- пароли хранились бы в открытом виде — украли базу = украли все пароли
- документация (Swagger) была видна всем, давая полную карту API для взломщика
---
## 2. Что было сделано
| Что | Зачем |
|-----|-------|
| JWT-токены | Пользователь должен войти, чтобы делать что-то важное |
| Хеширование паролей (bcrypt) | Даже если украдут БД — пароли не читаемы |
| Проверка владельца при PUT/DELETE | Ты можешь менять только свои данные |
| Admin Key | Опасные операции (инициализация БД) — только для администратора |
| HTTP Basic Auth на `/docs` | Документация API скрыта за паролем |
| CORS | Браузерные запросы принимаются только с разрешённых сайтов |
| Секреты в переменных окружения | Пароли и ключи не хранятся в коде |
---
## 3. Аутентификация
**Аутентификация** = подтверждение того, кто ты есть.
Как это работает в нашем API:
```
1. Ты отправляешь логин и пароль на POST /auth/login
2. Сервер проверяет: правильный ли пароль?
3. Если да — сервер выдаёт тебе "токен" (длинную строку-пропуск)
4. При каждом следующем запросе ты добавляешь этот токен в заголовок
5. Сервер смотрит на токен и понимает, кто ты
```
Аналогия: как билет в кино. Купил один раз — показываешь при входе, не нужно снова платить.
**Регистрация**`POST /auth/register`:
```json
{
"username": "ivan",
"password": ойпароль123",
"first_name": "Иван",
"last_name": "Иванов"
}
```
**Вход**`POST /auth/login` (формат form-data):
```
username=ivan
password=мойпароль123
```
Ответ:
```json
{
"access_token": "eyJhbGci...(длинная строка)...",
"token_type": "bearer"
}
```
---
## 4. JWT-токены
**JWT** (JSON Web Token) — это как паспорт, только цифровой.
Токен состоит из трёх частей, разделённых точкой:
```
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiIxMjMifQ . SflKxwRJSMeKKF2QT4
заголовок данные подпись
```
- **Заголовок**: тип токена и алгоритм шифрования
- **Данные (payload)**: ID пользователя, время истечения токена
- **Подпись**: криптографическая подпись — без секретного ключа сервера её нельзя подделать
Аналогия: токен — это как печать на паспорте. Печать нельзя нарисовать самому (без настоящей печати), поэтому её нельзя подделать.
**Как использовать токен в запросах:**
В заголовке HTTP:
```
Authorization: Bearer eyJhbGci...(твой токен)...
```
**Срок жизни токена** задаётся переменной `ACCESS_TOKEN_EXPIRE_MINUTES` (по умолчанию 60 минут).
---
## 5. Хеширование паролей
Пароли **никогда** не хранятся в открытом виде. Вместо этого хранится их хеш.
```
Пароль: "мойпароль123"
↓ bcrypt
Хеш: "$2b$12$eImiTXuWVxfM37uY4JANjQ..."
```
**Почему это безопасно:**
- Хеш нельзя "расшифровать" обратно в пароль
- Даже если база данных будет украдена — злоумышленник увидит только хеши
- При проверке он не расшифровывает, а снова хеширует введённый пароль и сравнивает хеши
Алгоритм **bcrypt** специально медленный — это делает перебор паролей (brute force) очень долгим.
---
## 6. Защита маршрутов
Некоторые действия требуют авторизации, другие открыты для всех.
| Маршрут | Открыт? | Объяснение |
|---------|---------|-----------|
| `POST /auth/register` | ✅ Да | Регистрация открыта всем |
| `POST /auth/login` | ✅ Да | Вход открыт всем |
| `GET /groups/` | ✅ Да | Просмотр групп — публичное |
| `POST /groups/` | 🔒 Нет | Создать группу — нужен токен |
| `PUT /users/{id}` | 🔒 Нет | Изменить профиль — только свой |
| `DELETE /users/{id}` | 🔒 Нет | Удалить — только свой аккаунт |
| `POST /init_database` | 🔒 Admin | Только с Admin Key |
**Проверка владельца**: при попытке изменить или удалить чужие данные сервер вернёт ошибку `403 Forbidden`.
---
## 7. Admin Key
Некоторые операции очень опасны (например, инициализация базы данных). Для них нужен специальный ключ администратора.
**Как использовать:**
```
Заголовок запроса: X-Admin-Key: твой_секретный_admin_ключ
```
Если `ADMIN_KEY` не задан в переменных окружения — такие операции полностью отключены (сервер вернёт `503`).
---
## 8. Защита документации `/docs`
Swagger UI (`/docs`) — это интерактивная документация API. Она показывает все маршруты, параметры, позволяет делать запросы прямо из браузера. Это очень удобно для разработчика, но и очень удобно для взломщика.
**Решение:** HTTP Basic Auth — браузер сам показывает окошко с запросом пароля.
```
Открываешь http://твой-сервер/docs
Браузер показывает окошко: "Введите логин и пароль"
Вводишь DOCS_USERNAME и DOCS_PASSWORD
Видишь документацию
```
То же самое работает для `/redoc` и `/openapi.json`.
---
## 9. CORS
**CORS** (Cross-Origin Resource Sharing) — механизм браузера, который контролирует, с каких сайтов можно делать запросы к API.
Аналогия: CORS — это как охранник на входе, который проверяет твой бейдж (адрес сайта). Если бейдж не из списка — не пропустит.
Разрешённые источники в нашем проекте:
```
http://localhost:3000
http://127.0.0.1:3000
```
Это значит, что только фронтенд, запущенный локально на порту 3000, может обращаться к API из браузера.
> ⚠️ **Важно для production**: если у тебя есть публичный домен, добавь его в список `allow_origins` в `main.py`.
---
## 10. Переменные окружения
**Секреты никогда не должны быть в коде.** Если код попадёт на GitHub — все пароли станут публичными.
Все секреты хранятся в переменных окружения (в `docker-compose.yml` или в `.env` файле).
| Переменная | Что это | Как создать |
|-----------|---------|------------|
| `SECRET_KEY` | Ключ для подписи JWT-токенов | `python -c "import secrets; print(secrets.token_hex(32))"` |
| `ADMIN_KEY` | Пароль для admin-операций | Придумай длинную случайную строку |
| `DOCS_USERNAME` | Логин для доступа к `/docs` | Например: `admin` |
| `DOCS_PASSWORD` | Пароль для доступа к `/docs` | Придумай надёжный пароль |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Сколько минут живёт токен | По умолчанию 60 |
---
## 11. Как настроить
### Шаг 1. Сгенерируй SECRET_KEY
Открой терминал и запусти:
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
Скопируй результат — это твой `SECRET_KEY`.
Сделай то же самое для `ADMIN_KEY`.
### Шаг 2. Задай пароль для документации
Придумай любой логин и пароль для `DOCS_USERNAME` и `DOCS_PASSWORD`.
### Шаг 3. Обнови docker-compose.yml
Найди блок `environment` в `docker-compose.yml` и замени все `REPLACE_ME_...` на реальные значения:
```yaml
environment:
- SECRET_KEY=abcdef1234... # результат из шага 1
- ADMIN_KEY=supersecret456 # результат из шага 1
- DOCS_USERNAME=admin
- DOCS_PASSWORD=мойпарольдокументации
```
### Шаг 4. Запусти проект
```bash
docker-compose up --build
```
### Шаг 5. Проверь
Открой в браузере `http://localhost:8000/docs` — должно появиться окошко с запросом пароля.
---
## 12. Примеры использования
### Зарегистрироваться
```bash
curl -X POST http://localhost:8000/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"ivan","password":"пароль123","first_name":"Иван","last_name":"Иванов"}'
```
### Войти и получить токен
```bash
curl -X POST http://localhost:8000/auth/login \
-d "username=ivan&password=пароль123"
```
### Использовать токен в запросе
```bash
TOKEN="eyJhbGci..." # твой токен из предыдущего шага
curl -X GET http://localhost:8000/users/me \
-H "Authorization: Bearer $TOKEN"
```
### Создать группу (нужен токен)
```bash
curl -X POST http://localhost:8000/groups/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name_group":"Группа 1","user_id":"uuid-пользователя"}'
```
### Открыть документацию в браузере
Просто зайди на `http://localhost:8000/docs` — браузер спросит логин/пароль.
---
## Что ещё можно улучшить (на будущее)
- **Rate limiting** — ограничение количества запросов с одного IP (защита от брутфорса)
- **Refresh tokens** — долгоживущие токены для обновления access token без повторного входа
- **HTTPS** — шифрование соединения (критично в production, настраивается на уровне nginx/proxy)
- **Логирование** — запись подозрительных действий (много неудачных попыток входа)
- **Email-верификация** — подтверждение email при регистрации

View File

@@ -9,6 +9,8 @@ class User(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
first_name = Column(String(150), nullable=False) first_name = Column(String(150), nullable=False)
last_name = Column(String(150), nullable=False) last_name = Column(String(150), nullable=False)
username = Column(String(150), nullable=False, unique=True)
hashed_password = Column(String(256), nullable=False)
__all__ = ["Base", "User"] __all__ = ["Base", "User"]

View File

@@ -19,6 +19,13 @@ services:
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_DB=0 - REDIS_DB=0
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn - REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
# JWT — замените на длинную случайную строку (python -c "import secrets; print(secrets.token_hex(32))")
- SECRET_KEY=e8fe23297b4664a78ba8e8ebe34bac158398ac4d7dc6b664fd8d52b0094ee67b
# Ключ для admin-операций
- ADMIN_KEY=b00ac081344a99bf50dffeb942e7f4f1
# Логин и пароль для доступа к /docs и /redoc
- DOCS_USERNAME=admin
- DOCS_PASSWORD=2cbae90b3d9f6710cf51354f
volumes: volumes:
- ./data:/app/data - ./data:/app/data
web: web:
@@ -26,8 +33,8 @@ services:
context: ./web context: ./web
dockerfile: ../Dockerfile.web dockerfile: ../Dockerfile.web
ports: ports:
- "3000:80" - "80:80"
environment:
- API_URL=http://api:8000
depends_on: depends_on:
- api - api
volumes:
- ./web:/usr/share/nginx/html:ro

58
main.py
View File

@@ -1,14 +1,17 @@
import os import os
import secrets
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud
from route import auth
import uvicorn import uvicorn
# Читаем версию из pyproject.toml # Читаем версию из pyproject.toml
try: try:
# Получаем абсолютный путь к pyproject.toml
pyproject_path = Path(__file__).parent / "pyproject.toml" pyproject_path = Path(__file__).parent / "pyproject.toml"
with open(pyproject_path, "rb") as f: with open(pyproject_path, "rb") as f:
pyproject = tomllib.load(f) pyproject = tomllib.load(f)
@@ -17,18 +20,45 @@ except Exception as e:
print(f"Warning: Could not read version from pyproject.toml: {e}") print(f"Warning: Could not read version from pyproject.toml: {e}")
VERSION = "0.1.0" VERSION = "0.1.0"
# Читаем переменные окружения
APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp") APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp")
app = FastAPI(title="api", version=VERSION) # ---------------------------------------------------------------------------
# Защита документации HTTP Basic Auth
# ---------------------------------------------------------------------------
_basic = HTTPBasic()
def _verify_docs(credentials: HTTPBasicCredentials = Depends(_basic)):
"""Проверяет логин/пароль для доступа к /docs и /redoc."""
docs_user = os.getenv("DOCS_USERNAME", "")
docs_pass = os.getenv("DOCS_PASSWORD", "")
if not docs_user or not docs_pass:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="API docs are disabled: DOCS_USERNAME / DOCS_PASSWORD not configured",
)
# secrets.compare_digest защищает от timing-атак (перебора по времени ответа)
ok_user = secrets.compare_digest(credentials.username.encode(), docs_user.encode())
ok_pass = secrets.compare_digest(credentials.password.encode(), docs_pass.encode())
if not (ok_user and ok_pass):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
# docs_url/openapi_url/redoc_url отключены — добавляем защищённые маршруты вручную
app = FastAPI(title="api", version=VERSION, docs_url=None, openapi_url=None, redoc_url=None)
# Разрешаем CORS для фронтенда (dev). При необходимости сузить список origins. # Разрешаем CORS для фронтенда (dev). При необходимости сузить список origins.
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], allow_origins=["http://localhost", "http://127.0.0.1"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(auth.router)
app.include_router(base.router) app.include_router(base.router)
app.include_router(init_data_base.router) app.include_router(init_data_base.router)
app.include_router(groups_crud.router) app.include_router(groups_crud.router)
@@ -41,8 +71,24 @@ app.include_router(poll_crud.router)
app.include_router(holland_crud.router) app.include_router(holland_crud.router)
# ---------------------------------------------------------------------------
# Защищённые маршруты документации
# ---------------------------------------------------------------------------
@app.get("/docs", include_in_schema=False)
def get_docs(_: None = Depends(_verify_docs)):
return get_swagger_ui_html(openapi_url="/openapi.json", title="API Docs")
@app.get("/redoc", include_in_schema=False)
def get_redoc(_: None = Depends(_verify_docs)):
return get_redoc_html(openapi_url="/openapi.json", title="API Docs")
@app.get("/openapi.json", include_in_schema=False)
def get_openapi_schema(_: None = Depends(_verify_docs)):
return app.openapi()
# Доступна переменная APP_PATH для использования в приложении
print(f"Application path: {APP_PATH}") print(f"Application path: {APP_PATH}")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -20,6 +20,9 @@ dependencies = [
"sqlalchemy>=2.0.47", "sqlalchemy>=2.0.47",
"psycopg>=3.3.3", "psycopg>=3.3.3",
"flet>=0.82.2", "flet>=0.82.2",
"jose>=1.0.0",
"passlib>=1.7.4",
"python-jose>=3.5.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -8,10 +8,14 @@ idna==3.11
pydantic==2.12.5 pydantic==2.12.5
pydantic_core==2.41.5 pydantic_core==2.41.5
python-dotenv==1.2.1 python-dotenv==1.2.1
python-multipart==0.0.20
redis==5.0.0 redis==5.0.0
starlette==0.50.0 starlette==0.50.0
typing-inspection==0.4.2 typing-inspection==0.4.2
typing_extensions==4.15.0 typing_extensions==4.15.0
uvicorn==0.40.0 uvicorn==0.40.0
sqlalchemy==2.0.32 sqlalchemy==2.0.32
pg8000==1.29.0 pg8000==1.29.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1

74
route/auth.py Normal file
View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, field_validator
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd.tables.users import User
from route.auth_utils import hash_password, verify_password, create_access_token
router = APIRouter(tags=["auth"], prefix="/auth")
def _get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
class RegisterIn(BaseModel):
username: str
password: str
first_name: str
last_name: str
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
@field_validator("username")
@classmethod
def username_clean(cls, v: str) -> str:
v = v.strip()
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
return v
@router.post("/register", status_code=201)
def register(payload: RegisterIn):
Session = _get_session()
with Session() as session:
existing = session.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already taken")
user = User(
first_name=payload.first_name.strip(),
last_name=payload.last_name.strip(),
username=payload.username,
hashed_password=hash_password(payload.password),
)
session.add(user)
session.commit()
session.refresh(user)
return {"id": str(user.id), "username": user.username}
@router.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends()):
Session = _get_session()
with Session() as session:
user = session.query(User).filter(User.username == form.username).first()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token({"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer"}

99
route/auth_utils.py Normal file
View File

@@ -0,0 +1,99 @@
import hashlib
import os
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from bd import Settings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
SECRET_KEY: str = os.getenv("SECRET_KEY", "")
if not SECRET_KEY:
import secrets
SECRET_KEY = secrets.token_hex(32)
print("WARNING: SECRET_KEY not set in environment. Using a random key — tokens will be invalidated on restart!")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _prehash(password: str) -> str:
"""SHA-256 пред-хеш чтобы обойти 72-байтовый лимит bcrypt."""
return hashlib.sha256(password.encode()).hexdigest()
def hash_password(password: str) -> str:
return pwd_context.hash(_prehash(password))
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(_prehash(plain), hashed)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode["exp"] = expire
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def _get_session():
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
return sessionmaker(bind=engine, future=True)
def get_current_user(token: str = Depends(oauth2_scheme)):
"""FastAPI dependency: валидирует Bearer-токен и возвращает объект User."""
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exc
except JWTError:
raise credentials_exc
from bd.tables.users import User
try:
uid = uuid.UUID(user_id)
except Exception:
raise credentials_exc
Session = _get_session()
with Session() as session:
user = session.get(User, uid)
if user is None:
raise credentials_exc
# detach from session so object can be used outside
session.expunge(user)
return user
def require_admin_key(x_admin_key: Optional[str] = Header(default=None)):
"""FastAPI dependency: проверяет секретный ключ администратора (заголовок X-Admin-Key)."""
admin_key = os.getenv("ADMIN_KEY", "")
if not admin_key:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Admin access is disabled: ADMIN_KEY not configured",
)
if x_admin_key != admin_key:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid admin key",
)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
@@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
from bd.tables.choice import Choice from bd.tables.choice import Choice
from bd.tables.question import Question from bd.tables.question import Question
from route.auth_utils import get_current_user
router = APIRouter(tags=["choices"], prefix="/choices") router = APIRouter(tags=["choices"], prefix="/choices")
@@ -31,7 +32,7 @@ def get_session():
@router.post("/", status_code=201) @router.post("/", status_code=201)
def create_choice(payload: ChoiceCreate): def create_choice(payload: ChoiceCreate, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
qid = uuid.UUID(payload.question_id) qid = uuid.UUID(payload.question_id)
@@ -49,7 +50,7 @@ def create_choice(payload: ChoiceCreate):
@router.put("/{choice_id}") @router.put("/{choice_id}")
def update_choice(choice_id: str, payload: ChoiceUpdate): def update_choice(choice_id: str, payload: ChoiceUpdate, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
cid = uuid.UUID(choice_id) cid = uuid.UUID(choice_id)
@@ -70,7 +71,7 @@ def update_choice(choice_id: str, payload: ChoiceUpdate):
@router.delete("/{choice_id}", status_code=204) @router.delete("/{choice_id}", status_code=204)
def delete_choice(choice_id: str): def delete_choice(choice_id: str, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
cid = uuid.UUID(choice_id) cid = uuid.UUID(choice_id)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
@@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
from bd.tables.group import Group from bd.tables.group import Group
from bd.tables.users import User from bd.tables.users import User
from route.auth_utils import get_current_user
router = APIRouter(tags=["groups"], prefix="/groups") router = APIRouter(tags=["groups"], prefix="/groups")
@@ -30,7 +31,7 @@ def get_session():
@router.post("/", status_code=201) @router.post("/", status_code=201)
def create_group(payload: GroupCreate): def create_group(payload: GroupCreate, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
uid = uuid.UUID(payload.user_id) uid = uuid.UUID(payload.user_id)
@@ -70,7 +71,7 @@ def get_group(group_id: str):
@router.put("/{group_id}") @router.put("/{group_id}")
def update_group(group_id: str, payload: GroupUpdate): def update_group(group_id: str, payload: GroupUpdate, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
gid = uuid.UUID(group_id) gid = uuid.UUID(group_id)
@@ -98,7 +99,7 @@ def update_group(group_id: str, payload: GroupUpdate):
@router.delete("/{group_id}", status_code=204) @router.delete("/{group_id}", status_code=204)
def delete_group(group_id: str): def delete_group(group_id: str, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
gid = uuid.UUID(group_id) gid = uuid.UUID(group_id)

View File

@@ -1,14 +1,16 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
import pkgutil import pkgutil
import importlib import importlib
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from bd import Settings from bd import Settings
from sqlalchemy import create_engine from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from pydantic import BaseModel from pydantic import BaseModel
from route.auth_utils import require_admin_key
router = APIRouter(tags=["db"]) router = APIRouter(tags=["db"])
@@ -40,7 +42,7 @@ def collect_metadatas():
return metadatas return metadatas
@router.post("/db/create-tables") @router.post("/db/create-tables", dependencies=[Depends(require_admin_key)])
async def create_tables(): async def create_tables():
"""Создаёт все таблицы, описанные в модулях `db.tables`. """Создаёт все таблицы, описанные в модулях `db.tables`.
@@ -70,11 +72,29 @@ async def create_tables():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)])
async def migrate_users():
"""Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют."""
settings = Settings()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
try:
with engine.connect() as conn:
conn.execute(text("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS username VARCHAR(150),
ADD COLUMN IF NOT EXISTS hashed_password VARCHAR(256)
"""))
conn.commit()
return {"status": "ok", "detail": "Columns username and hashed_password ensured in users table"}
except SQLAlchemyError as e:
raise HTTPException(status_code=500, detail=str(e))
class ClearDBIn(BaseModel): class ClearDBIn(BaseModel):
confirm: bool confirm: bool
@router.post("/db/clear") @router.post("/db/clear", dependencies=[Depends(require_admin_key)])
async def clear_tables(payload: ClearDBIn): async def clear_tables(payload: ClearDBIn):
"""Полная очистка всех таблиц, описанных в `bd.tables`. """Полная очистка всех таблиц, описанных в `bd.tables`.

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
@@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
from bd.tables.organization import Organization from bd.tables.organization import Organization
from bd.tables.group import Group from bd.tables.group import Group
from route.auth_utils import get_current_user
router = APIRouter(tags=["organizations"], prefix="/organizations") router = APIRouter(tags=["organizations"], prefix="/organizations")
@@ -30,7 +31,7 @@ def get_session():
@router.post("/", status_code=201) @router.post("/", status_code=201)
def create_organization(payload: OrganizationCreate): def create_organization(payload: OrganizationCreate, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
gid = uuid.UUID(payload.group_id) gid = uuid.UUID(payload.group_id)
@@ -70,7 +71,7 @@ def get_organization(org_id: str):
@router.put("/{org_id}") @router.put("/{org_id}")
def update_organization(org_id: str, payload: OrganizationUpdate): def update_organization(org_id: str, payload: OrganizationUpdate, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
oid = uuid.UUID(org_id) oid = uuid.UUID(org_id)
@@ -98,7 +99,7 @@ def update_organization(org_id: str, payload: OrganizationUpdate):
@router.delete("/{org_id}", status_code=204) @router.delete("/{org_id}", status_code=204)
def delete_organization(org_id: str): def delete_organization(org_id: str, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
oid = uuid.UUID(org_id) oid = uuid.UUID(org_id)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import uuid import uuid
@@ -10,6 +10,7 @@ from sqlalchemy.orm import sessionmaker
from bd.tables.poll import Poll from bd.tables.poll import Poll
from bd.tables.question import Question from bd.tables.question import Question
from bd.tables.choice import Choice from bd.tables.choice import Choice
from route.auth_utils import get_current_user
router = APIRouter(tags=["polls"], prefix="/polls") router = APIRouter(tags=["polls"], prefix="/polls")
@@ -39,7 +40,7 @@ def get_session():
@router.post("/", status_code=201) @router.post("/", status_code=201)
def create_poll(payload: PollIn): def create_poll(payload: PollIn, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
with Session() as session: with Session() as session:
poll = Poll(title=payload.title, description=payload.description) poll = Poll(title=payload.title, description=payload.description)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
@@ -9,6 +9,7 @@ from sqlalchemy.orm import sessionmaker
from bd.tables.question import Question from bd.tables.question import Question
from bd.tables.poll import Poll from bd.tables.poll import Poll
from route.auth_utils import get_current_user
router = APIRouter(tags=["questions"], prefix="/questions") router = APIRouter(tags=["questions"], prefix="/questions")
@@ -33,7 +34,7 @@ def get_session():
@router.post("/", status_code=201) @router.post("/", status_code=201)
def create_question(payload: QuestionCreate): def create_question(payload: QuestionCreate, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
pid = uuid.UUID(payload.poll_id) pid = uuid.UUID(payload.poll_id)
@@ -51,7 +52,7 @@ def create_question(payload: QuestionCreate):
@router.put("/{question_id}") @router.put("/{question_id}")
def update_question(question_id: str, payload: QuestionUpdate): def update_question(question_id: str, payload: QuestionUpdate, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
qid = uuid.UUID(question_id) qid = uuid.UUID(question_id)
@@ -74,7 +75,7 @@ def update_question(question_id: str, payload: QuestionUpdate):
@router.delete("/{question_id}", status_code=204) @router.delete("/{question_id}", status_code=204)
def delete_question(question_id: str): def delete_question(question_id: str, _: object = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
qid = uuid.UUID(question_id) qid = uuid.UUID(question_id)

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from typing import Optional from typing import Optional
import uuid import uuid
@@ -8,18 +8,36 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from bd.tables.users import User from bd.tables.users import User
from route.auth_utils import get_current_user, require_admin_key, hash_password
router = APIRouter(tags=["users"], prefix="/users") router = APIRouter(tags=["users"], prefix="/users")
class UserCreate(BaseModel): class UserCreate(BaseModel):
username: str
password: str
first_name: str first_name: str
last_name: str last_name: str
@field_validator("username")
@classmethod
def username_clean(cls, v: str) -> str:
v = v.strip()
if len(v) < 3:
raise ValueError("Username must be at least 3 characters")
return v
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
first_name: Optional[str] first_name: Optional[str] = None
last_name: Optional[str] last_name: Optional[str] = None
def get_session(): def get_session():
@@ -28,24 +46,34 @@ def get_session():
return sessionmaker(bind=engine, future=True) return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_user(payload: UserCreate): def create_user(payload: UserCreate):
Session = get_session() Session = get_session()
with Session() as session: with Session() as session:
user = User(first_name=payload.first_name, last_name=payload.last_name) existing = session.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already taken")
user = User(
first_name=payload.first_name.strip(),
last_name=payload.last_name.strip(),
username=payload.username,
hashed_password=hash_password(payload.password),
)
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name} return {"id": str(user.id), "username": user.username}
@router.put("/{user_id}") @router.put("/{user_id}")
def update_user(user_id: str, payload: UserUpdate): def update_user(user_id: str, payload: UserUpdate, current_user: User = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
uid = uuid.UUID(user_id) uid = uuid.UUID(user_id)
except Exception: except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID") raise HTTPException(status_code=400, detail="Invalid UUID")
if uid != current_user.id:
raise HTTPException(status_code=403, detail="You can only update your own profile")
with Session() as session: with Session() as session:
user = session.get(User, uid) user = session.get(User, uid)
if not user: if not user:
@@ -61,12 +89,14 @@ def update_user(user_id: str, payload: UserUpdate):
@router.delete("/{user_id}", status_code=204) @router.delete("/{user_id}", status_code=204)
def delete_user(user_id: str): def delete_user(user_id: str, current_user: User = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
uid = uuid.UUID(user_id) uid = uuid.UUID(user_id)
except Exception: except Exception:
raise HTTPException(status_code=400, detail="Invalid UUID") raise HTTPException(status_code=400, detail="Invalid UUID")
if uid != current_user.id:
raise HTTPException(status_code=403, detail="You can only delete your own account")
with Session() as session: with Session() as session:
user = session.get(User, uid) user = session.get(User, uid)
if not user: if not user:
@@ -76,16 +106,13 @@ def delete_user(user_id: str):
return {} return {}
@router.get("/") @router.get("/me")
def list_users(): def get_me(current_user: User = Depends(get_current_user)):
Session = get_session() return {"id": str(current_user.id), "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name}
with Session() as session:
rows = session.query(User).all()
return [{"id": str(u.id), "first_name": u.first_name, "last_name": u.last_name} for u in rows]
@router.get("/{user_id}") @router.get("/{user_id}")
def get_user(user_id: str): def get_user(user_id: str, current_user: User = Depends(get_current_user)):
Session = get_session() Session = get_session()
try: try:
uid = uuid.UUID(user_id) uid = uuid.UUID(user_id)

68
uv.lock generated
View File

@@ -39,9 +39,12 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "flet" }, { name = "flet" },
{ name = "jose" },
{ name = "passlib" },
{ name = "psycopg" }, { name = "psycopg" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-jose" },
{ name = "redis" }, { name = "redis" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@@ -60,11 +63,14 @@ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
{ name = "fastapi", specifier = ">=0.128.0" }, { name = "fastapi", specifier = ">=0.128.0" },
{ name = "flet", specifier = ">=0.82.2" }, { name = "flet", specifier = ">=0.82.2" },
{ name = "jose", specifier = ">=1.0.0" },
{ name = "passlib", specifier = ">=1.7.4" },
{ name = "psycopg", specifier = ">=3.3.3" }, { name = "psycopg", specifier = ">=3.3.3" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-jose", specifier = ">=3.5.0" },
{ name = "redis", specifier = ">=7.1.0" }, { name = "redis", specifier = ">=7.1.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "sqlalchemy", specifier = ">=2.0.47" }, { name = "sqlalchemy", specifier = ">=2.0.47" },
@@ -124,6 +130,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.128.0" version = "0.128.0"
@@ -249,6 +267,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "jose"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/3d/832caa69cd0d3be2d608d8290be2221072669aa88e87690837f6b31c480f/jose-1.0.0.tar.gz", hash = "sha256:8436c3617cd94e1ba97828fbb1ce27c129f66c78fb855b4bb47e122b5f345fba", size = 9153, upload-time = "2015-11-13T10:52:21.506Z" }
[[package]] [[package]]
name = "msgpack" name = "msgpack"
version = "1.1.2" version = "1.1.2"
@@ -302,6 +326,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
] ]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "1.0.4" version = "1.0.4"
@@ -341,6 +374,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
] ]
[[package]]
name = "pyasn1"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@@ -441,6 +483,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
] ]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[[package]] [[package]]
name = "pytokens" name = "pytokens"
version = "0.4.1" version = "0.4.1"
@@ -507,6 +563,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/ed/92e9b8a3ffc562f21df14ef2538f54e911df29730e1f0d79130a4edc86e7/repath-0.9.0-py3-none-any.whl", hash = "sha256:ee079d6c91faeb843274d22d8f786094ee01316ecfe293a1eb6546312bb6a318", size = 4738, upload-time = "2019-10-08T00:25:20.842Z" }, { url = "https://files.pythonhosted.org/packages/87/ed/92e9b8a3ffc562f21df14ef2538f54e911df29730e1f0d79130a4edc86e7/repath-0.9.0-py3-none-any.whl", hash = "sha256:ee079d6c91faeb843274d22d8f786094ee01316ecfe293a1eb6546312bb6a318", size = 4738, upload-time = "2019-10-08T00:25:20.842Z" },
] ]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.14" version = "0.14.14"

BIN
web/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

3
web/assets/icons/README Normal file
View File

@@ -0,0 +1,3 @@
# Положи сюда своё изображение с именем loading-animation.png
# Это заменит мигающий логотип Flet при загрузке страницы.
# Рекомендуемый размер: PNG, минимум 200x200 px.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -9,10 +9,31 @@ class clolors:
class TextField_input(ft.TextField): class CastomTextField_input(ft.TextField):
def __init__(self): def __init__(self, on_change, on_submit, label="Ваше имя", hint_text="Введите текст...", prefix_icon=None, suffix_icon=None):
super().__init__( super().__init__(
label=label,
hint_text=hint_text,
filled=True, # заливка фона
bgcolor=clolors.ferst, # цвет заливки
border_color=clolors.laer2, # цвет границы
border_radius=12, # скругление углов
label_style=ft.TextStyle(
color=clolors.laer3,
weight=ft.FontWeight.BOLD,
),
hint_style=ft.TextStyle(
color=clolors.laer1,
italic=True,
),
text_style=ft.TextStyle(
color=clolors.laer3,
size=16,
),
prefix_icon=prefix_icon,
suffix_icon=suffix_icon,
on_change=on_change,
on_submit=on_submit,
) )
class CastomButton(ft.Button): class CastomButton(ft.Button):

View File

@@ -1,7 +1,49 @@
import os
import httpx
import flet as ft import flet as ft
class user(): API_URL = os.getenv("API_URL", "http://api:8000")
def __init__(self, page: ft.Page): _TOKEN_KEY = "auth_token"
pass
#page.client_storage.set("token", "your_jwt_token_here")
class AuthService:
"""Сервис авторизации: логин, логаут, проверка токена."""
def __init__(self, page: ft.Page):
self._page = page
async def login(self, username: str, password: str) -> str | None:
"""
Отправляет запрос на POST /auth/login.
Возвращает None при успехе, строку с ошибкой при неудаче.
"""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client:
resp = await client.post(
"/auth/login",
data={"username": username, "password": password},
)
if resp.status_code == 200:
token = resp.json()["access_token"]
self._page.session.set(_TOKEN_KEY, token)
return None
elif resp.status_code == 401:
return "Неверный логин или пароль"
else:
return f"Ошибка сервера: {resp.status_code}"
except httpx.ConnectError:
return "Не удалось подключиться к серверу"
except Exception as e:
return f"Ошибка: {e}"
async def logout(self):
"""Удаляет токен — пользователь разлогинен."""
self._page.session.remove(_TOKEN_KEY)
async def get_token(self) -> str | None:
"""Возвращает сохранённый токен или None если не авторизован."""
return self._page.session.get(_TOKEN_KEY)
async def is_authenticated(self) -> bool:
token = await self.get_token()
return token is not None and token != ""

View File

@@ -1,19 +1,36 @@
import flet as ft import flet as ft
from view import ViewsHendler from view import ViewsHendler
import designer as ds
from info import AuthService
async def main(page: ft.Page): async def main(page: ft.Page):
page.title = "COPP" page.title = "COPP"
page.bgcolor = ds.clolors.background
auth = AuthService(page)
_PUBLIC_ROUTES = {"/login"}
def route_change(e: ft.RouteChangeEvent): def route_change(e: ft.RouteChangeEvent):
print("Route changed to:", page.route) page.run_task(_guarded_route_change, page.route)
async def _guarded_route_change(route: str):
if route not in _PUBLIC_ROUTES and not await auth.is_authenticated():
await page.push_route("/login")
return
page.views.clear() page.views.clear()
view = ViewsHendler(page).get(route)
view = ViewsHendler(page).get(page.route) if view:
if view: # проверяем, что view получен
page.views.append(view) page.views.append(view)
else:
await page.push_route("/main")
page.update() page.update()
page.on_route_change = route_change page.on_route_change = route_change
await page.push_route("/main") await page.push_route("/main")
ft.run(main, port=80345, assets_dir="assets")
# export_asgi_app=True — Flet отдаёт ASGI-объект вместо запуска встроенного сервера.
# Uvicorn из CMD в Dockerfile подхватывает этот объект через «main:app».
app = ft.run(main, export_asgi_app=True, assets_dir="assets")

View File

@@ -1,9 +1,10 @@
import flet as ft import flet as ft
from views import main_view from views import main_view, login_view
def ViewsHendler(page: ft.Page): def ViewsHendler(page: ft.Page):
return { return {
"/main": main_view.MainView(), "/main": main_view.MainView(),
"/login": login_view.LoginView(page),
"/about": ft.View(route="/about", controls=[ft.Text("О нас",color="#008000", size=20)]), "/about": ft.View(route="/about", controls=[ft.Text("О нас",color="#008000", size=20)]),
"/contact": ft.View(route="/contact", controls=[ft.Text("Контакты",color="#FF0000", size=20)]) "/contact": ft.View(route="/contact", controls=[ft.Text("Контакты",color="#FF0000", size=20)])
} }

96
web/views/login_view.py Normal file
View File

@@ -0,0 +1,96 @@
import flet as ft
import designer as ds
from info import AuthService
class LoginView(ft.View):
def __init__(self, page: ft.Page):
self._auth = AuthService(page)
self._username = ft.TextField(
label="Логин",
hint_text="Введите логин...",
filled=True,
bgcolor=ds.clolors.ferst,
border_color=ds.clolors.laer2,
border_radius=12,
prefix_icon=ft.Icons.PERSON,
label_style=ft.TextStyle(color=ds.clolors.laer3, weight=ft.FontWeight.BOLD),
text_style=ft.TextStyle(color=ds.clolors.laer3, size=16),
)
self._password = ft.TextField(
label="Пароль",
hint_text="Введите пароль...",
filled=True,
bgcolor=ds.clolors.ferst,
border_color=ds.clolors.laer2,
border_radius=12,
prefix_icon=ft.Icons.LOCK,
password=True,
can_reveal_password=True,
label_style=ft.TextStyle(color=ds.clolors.laer3, weight=ft.FontWeight.BOLD),
text_style=ft.TextStyle(color=ds.clolors.laer3, size=16),
on_submit=lambda e: page.run_task(self._do_login),
)
self._error = ft.Text("", color=ft.Colors.RED_400, size=14, visible=False)
self._btn = ds.CastomButton(
text="Войти",
on_click=lambda e: page.run_task(self._do_login),
)
super().__init__(
route="/login",
bgcolor=ds.clolors.background,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
vertical_alignment=ft.MainAxisAlignment.CENTER,
)
self.controls = [
ft.Text("COPP", color=ds.clolors.ferst, size=30, weight=ft.FontWeight.BOLD),
ft.Container(height=8),
ft.Text("Вход в систему", color=ds.clolors.ferst, size=16),
ft.Container(height=24),
ft.Container(
width=360,
padding=ft.padding.all(24),
bgcolor=ds.clolors.laer3,
border_radius=16,
content=ft.Column(
spacing=16,
controls=[
self._username,
self._password,
self._error,
self._btn,
],
),
),
]
async def _do_login(self):
username = self._username.value.strip()
password = self._password.value
if not username or not password:
self._show_error("Заполните логин и пароль")
return
self._btn.disabled = True
self._error.visible = False
self.update()
error = await self._auth.login(username, password)
if error:
self._btn.disabled = False
self._show_error(error)
else:
# Успешный вход — переходим на главную
await self.page.push_route("/main")
def _show_error(self, message: str):
self._error.value = message
self._error.visible = True
self.update()