web sttarting

This commit is contained in:
2026-03-31 13:25:14 +05:00
parent 9cd6d83428
commit 59b3bcd0f4
30 changed files with 720 additions and 242 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ venv.bak/
./node_modules ./node_modules
node_modules node_modules
data

View File

@@ -3,7 +3,7 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Устанавливаем flet (uvicorn уже входит в зависимости flet) # Устанавливаем flet (uvicorn уже входит в зависимости flet)
RUN pip install --no-cache-dir flet "uvicorn[standard]" httpx RUN pip install --no-cache-dir flet flet-web "uvicorn[standard]" httpx
# Копируем исходники веб-приложения # Копируем исходники веб-приложения
COPY . . COPY . .

View File

@@ -1,10 +1,10 @@
# Database configuration package # Database configuration package
from pydantic.generics import GenericModel from pydantic import BaseModel
import re import re
import os import os
import urllib.parse import urllib.parse
class Settings(GenericModel): class Settings(BaseModel):
DB_HOST: str = os.getenv("DB_HOST", "postgres") DB_HOST: str = os.getenv("DB_HOST", "postgres")
DB_PORT: int = int(os.getenv("DB_PORT", "5432")) DB_PORT: int = int(os.getenv("DB_PORT", "5432"))
DB_USER: str = os.getenv("DB_USER", "postgres") DB_USER: str = os.getenv("DB_USER", "postgres")
@@ -32,3 +32,15 @@ class Settings(GenericModel):
def DATABASE_URL_syncpg(self) -> str: def DATABASE_URL_syncpg(self) -> str:
pwd = urllib.parse.quote_plus(str(self.DB_PASS)) pwd = urllib.parse.quote_plus(str(self.DB_PASS))
return f"postgresql+pg8000://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" return f"postgresql+pg8000://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def PG8000_CONNECT_ARGS(self) -> dict:
"""connect_args для pg8000 — отключаем SSL (сервер его не поддерживает)."""
return {"ssl_context": None}
def make_engine():
"""Создаёт SQLAlchemy engine с правильными параметрами для pg8000."""
from sqlalchemy import create_engine
s = Settings()
return create_engine(s.DATABASE_URL_syncpg, connect_args=s.PG8000_CONNECT_ARGS, future=True)

View File

@@ -1,17 +1,13 @@
from sqlalchemy import Column, String, ForeignKey from sqlalchemy import Column, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from .simple_base import Base from .simple_base import Base
import uuid import uuid
from sqlalchemy.orm import relationship
class Group(Base): class Group(Base):
__tablename__ = "group" __tablename__ = "group"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name_group = Column(String(150), nullable=False) name_group = Column(String(150), nullable=False, unique=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user = relationship("User", backref="groups", passive_deletes=True)
__all__ = ["Base", "Group"] __all__ = ["Base", "Group"]

View File

@@ -8,10 +8,7 @@ class Organization(Base):
__tablename__ = "organization" __tablename__ = "organization"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name_organization = Column(String(150), nullable=False) name_organization = Column(String(150), nullable=False, unique=True)
group_id = Column(UUID(as_uuid=True), ForeignKey("group.id", ondelete="CASCADE"), nullable=False)
group = relationship("Group", backref="organizations", passive_deletes=True)
__all__ = ["Base", "Organization"] __all__ = ["Base", "Organization"]

View File

@@ -10,7 +10,7 @@ class Response(Base):
__tablename__ = "responses" __tablename__ = "responses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id"), nullable=False) poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), nullable=True) user_id = Column(UUID(as_uuid=True), nullable=True)
submitted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) submitted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@@ -22,7 +22,7 @@ class Answer(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)
response_id = Column(UUID(as_uuid=True), ForeignKey("responses.id", ondelete="CASCADE"), nullable=False) response_id = Column(UUID(as_uuid=True), ForeignKey("responses.id", ondelete="CASCADE"), nullable=False)
question_id = Column(UUID(as_uuid=True), ForeignKey("questions.id"), nullable=False) question_id = Column(UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
choice_id = Column(UUID(as_uuid=True), nullable=True) choice_id = Column(UUID(as_uuid=True), nullable=True)
text = Column(Text, nullable=True) text = Column(Text, nullable=True)

View File

@@ -1,5 +1,6 @@
from sqlalchemy import Column, String from sqlalchemy import Column, String, ForeignKey
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from .simple_base import Base from .simple_base import Base
import uuid import uuid
@@ -12,5 +13,11 @@ class User(Base):
username = Column(String(150), nullable=False, unique=True) username = Column(String(150), nullable=False, unique=True)
hashed_password = Column(String(256), nullable=False) hashed_password = Column(String(256), nullable=False)
group_id = Column(UUID(as_uuid=True), ForeignKey("group.id", ondelete="SET NULL"), nullable=True)
organization_id = Column(UUID(as_uuid=True), ForeignKey("organization.id", ondelete="SET NULL"), nullable=True)
group = relationship("Group", foreign_keys=[group_id])
organization = relationship("Organization", foreign_keys=[organization_id])
__all__ = ["Base", "User"] __all__ = ["Base", "User"]

View File

@@ -9,13 +9,14 @@ services:
- APP_PATH=/home/user/api-copp - APP_PATH=/home/user/api-copp
- DEBUG=True - DEBUG=True
- DB_HOST=192.168.1.11 - DB_HOST=postgres
- DB_PORT=5432 - DB_PORT=5432
- DB_USER=admin1 - DB_USER=postgres
- DB_PASS=Y6%r35RpckeP^F - DB_PASS=postgres
- DB_NAME=profi - DB_NAME=profi
# Redis connection (external) # Redis connection (external)
- REDIS_HOST=192.168.1.19 - REDIS_HOST=redis
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_DB=0 - REDIS_DB=0
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn - REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn

25
docker-compose_db.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
redis:
image: redis:alpine
container_name: redis
command:
- redis-server --appendonly yes
- redis-server --requirepass CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
ports:
- 6379:6379
volumes:
- ./data:/redis
restart: always
postgres:
image: postgres:latest
container_name: postgres
#restart: always
ports:
- "5432:5432"
environment:
- POSTGRES_PASSWORD=postgres
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/postgres:/var/lib/postgresql/data

10
docker-compose_web.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
web:
build:
context: ./web
dockerfile: ./Dockerfile.web
ports:
- "80:80"
environment:
- API_URL=http://api:8000

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from typing import Optional
import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd.tables.users import User from bd.tables.users import User
from route.auth_utils import hash_password, verify_password, create_access_token from route.auth_utils import hash_password, verify_password, create_access_token
@@ -12,10 +12,9 @@ from route.auth_utils import hash_password, verify_password, create_access_token
router = APIRouter(tags=["auth"], prefix="/auth") router = APIRouter(tags=["auth"], prefix="/auth")
def _get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_engine(), future=True)
return sessionmaker(bind=engine, future=True)
class RegisterIn(BaseModel): class RegisterIn(BaseModel):
@@ -23,6 +22,8 @@ class RegisterIn(BaseModel):
password: str password: str
first_name: str first_name: str
last_name: str last_name: str
group_id: Optional[str] = None
organization_id: Optional[str] = None
@field_validator("password") @field_validator("password")
@classmethod @classmethod
@@ -42,7 +43,7 @@ class RegisterIn(BaseModel):
@router.post("/register", status_code=201) @router.post("/register", status_code=201)
def register(payload: RegisterIn): def register(payload: RegisterIn):
Session = _get_session() Session = get_session()
with Session() as session: with Session() as session:
existing = session.query(User).filter(User.username == payload.username).first() existing = session.query(User).filter(User.username == payload.username).first()
if existing: if existing:
@@ -52,6 +53,8 @@ def register(payload: RegisterIn):
last_name=payload.last_name.strip(), last_name=payload.last_name.strip(),
username=payload.username, username=payload.username,
hashed_password=hash_password(payload.password), hashed_password=hash_password(payload.password),
group_id=uuid.UUID(payload.group_id) if payload.group_id else None,
organization_id=uuid.UUID(payload.organization_id) if payload.organization_id else None,
) )
session.add(user) session.add(user)
session.commit() session.commit()
@@ -61,7 +64,7 @@ def register(payload: RegisterIn):
@router.post("/login") @router.post("/login")
def login(form: OAuth2PasswordRequestForm = Depends()): def login(form: OAuth2PasswordRequestForm = Depends()):
Session = _get_session() Session = get_session()
with Session() as session: with Session() as session:
user = session.query(User).filter(User.username == form.username).first() user = session.query(User).filter(User.username == form.username).first()
if not user or not verify_password(form.password, user.hashed_password): if not user or not verify_password(form.password, user.hashed_password):

View File

@@ -9,9 +9,7 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
SECRET_KEY: str = os.getenv("SECRET_KEY", "") SECRET_KEY: str = os.getenv("SECRET_KEY", "")
if not SECRET_KEY: if not SECRET_KEY:
@@ -46,10 +44,9 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def _get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_engine(), future=True)
return sessionmaker(bind=engine, future=True)
def get_current_user(token: str = Depends(oauth2_scheme)): def get_current_user(token: str = Depends(oauth2_scheme)):
@@ -74,7 +71,7 @@ def get_current_user(token: str = Depends(oauth2_scheme)):
except Exception: except Exception:
raise credentials_exc raise credentials_exc
Session = _get_session() Session = get_session()
with Session() as session: with Session() as session:
user = session.get(User, uid) user = session.get(User, uid)
if user is None: if user is None:

View File

@@ -3,13 +3,11 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
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 from route.auth_utils import require_admin_key
router = APIRouter(tags=["choices"], prefix="/choices") router = APIRouter(tags=["choices"], prefix="/choices")
@@ -26,13 +24,12 @@ class ChoiceUpdate(BaseModel):
def get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_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_choice(payload: ChoiceCreate, _: object = Depends(get_current_user)): def create_choice(payload: ChoiceCreate):
Session = get_session() Session = get_session()
try: try:
qid = uuid.UUID(payload.question_id) qid = uuid.UUID(payload.question_id)
@@ -49,8 +46,8 @@ def create_choice(payload: ChoiceCreate, _: object = Depends(get_current_user)):
return {"id": str(ch.id)} return {"id": str(ch.id)}
@router.put("/{choice_id}") @router.put("/{choice_id}", dependencies=[Depends(require_admin_key)])
def update_choice(choice_id: str, payload: ChoiceUpdate, _: object = Depends(get_current_user)): def update_choice(choice_id: str, payload: ChoiceUpdate):
Session = get_session() Session = get_session()
try: try:
cid = uuid.UUID(choice_id) cid = uuid.UUID(choice_id)
@@ -70,8 +67,8 @@ def update_choice(choice_id: str, payload: ChoiceUpdate, _: object = Depends(get
return {"id": str(ch.id)} return {"id": str(ch.id)}
@router.delete("/{choice_id}", status_code=204) @router.delete("/{choice_id}", status_code=204, dependencies=[Depends(require_admin_key)])
def delete_choice(choice_id: str, _: object = Depends(get_current_user)): def delete_choice(choice_id: str):
Session = get_session() Session = get_session()
try: try:
cid = uuid.UUID(choice_id) cid = uuid.UUID(choice_id)

View File

@@ -3,49 +3,43 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd.tables.group import Group from bd.tables.group import Group
from bd.tables.users import User from route.auth_utils import require_admin_key
from route.auth_utils import get_current_user
router = APIRouter(tags=["groups"], prefix="/groups") router = APIRouter(tags=["groups"], prefix="/groups")
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
name_group: str name_group: str
user_id: str
class GroupUpdate(BaseModel): class GroupUpdate(BaseModel):
name_group: Optional[str] name_group: Optional[str] = None
user_id: Optional[str]
def get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_engine(), future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201) def _group_dict(grp: Group) -> dict:
def create_group(payload: GroupCreate, _: object = Depends(get_current_user)): return {"id": str(grp.id), "name_group": grp.name_group}
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_group(payload: GroupCreate):
Session = get_session() Session = get_session()
try:
uid = uuid.UUID(payload.user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid user_id UUID")
with Session() as session: with Session() as session:
user = session.get(User, uid) existing = session.query(Group).filter(Group.name_group == payload.name_group).first()
if not user: if existing:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=400, detail="Group name already exists")
grp = Group(name_group=payload.name_group, user_id=uid) grp = Group(name_group=payload.name_group)
session.add(grp) session.add(grp)
session.commit() session.commit()
session.refresh(grp) session.refresh(grp)
return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)} return _group_dict(grp)
@router.get("/") @router.get("/")
@@ -53,7 +47,7 @@ def list_groups():
Session = get_session() Session = get_session()
with Session() as session: with Session() as session:
rows = session.query(Group).all() rows = session.query(Group).all()
return [{"id": str(g.id), "name_group": g.name_group, "user_id": str(g.user_id)} for g in rows] return [_group_dict(g) for g in rows]
@router.get("/{group_id}") @router.get("/{group_id}")
@@ -67,11 +61,11 @@ def get_group(group_id: str):
grp = session.get(Group, gid) grp = session.get(Group, gid)
if not grp: if not grp:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)} return _group_dict(grp)
@router.put("/{group_id}") @router.put("/{group_id}", dependencies=[Depends(require_admin_key)])
def update_group(group_id: str, payload: GroupUpdate, _: object = Depends(get_current_user)): def update_group(group_id: str, payload: GroupUpdate):
Session = get_session() Session = get_session()
try: try:
gid = uuid.UUID(group_id) gid = uuid.UUID(group_id)
@@ -83,23 +77,14 @@ def update_group(group_id: str, payload: GroupUpdate, _: object = Depends(get_cu
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
if payload.name_group is not None: if payload.name_group is not None:
grp.name_group = payload.name_group grp.name_group = payload.name_group
if payload.user_id is not None:
try:
new_uid = uuid.UUID(payload.user_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid user_id UUID")
user = session.get(User, new_uid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
grp.user_id = new_uid
session.add(grp) session.add(grp)
session.commit() session.commit()
session.refresh(grp) session.refresh(grp)
return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)} return _group_dict(grp)
@router.delete("/{group_id}", status_code=204) @router.delete("/{group_id}", status_code=204, dependencies=[Depends(require_admin_key)])
def delete_group(group_id: str, _: object = Depends(get_current_user)): def delete_group(group_id: str):
Session = get_session() Session = get_session()
try: try:
gid = uuid.UUID(group_id) gid = uuid.UUID(group_id)

View File

@@ -2,9 +2,7 @@ from fastapi import APIRouter, HTTPException
from typing import Optional from typing import Optional
import uuid import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
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
@@ -60,9 +58,8 @@ PAIRS = [
def get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_engine(), future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/holland", status_code=201) @router.post("/holland", status_code=201)
@@ -83,3 +80,11 @@ def create_holland_poll(author_id: Optional[str] = None):
session.commit() session.commit()
session.refresh(poll) session.refresh(poll)
return {"id": str(poll.id), "questions": len(poll.questions)} return {"id": str(poll.id), "questions": len(poll.questions)}
@router.get("/holland")
def list_holland_polls():
Session = get_session()
with Session() as session:
rows = session.query(Poll).filter(Poll.title.like("%Голланда%")).all()
return [{"id": str(p.id), "title": p.title, "description": p.description} for p in rows]

View File

@@ -4,8 +4,8 @@ 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, make_engine
from sqlalchemy import create_engine, text from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from pydantic import BaseModel from pydantic import BaseModel
@@ -48,9 +48,7 @@ async def create_tables():
Endpoint вызывается по нажатию кнопки в UI (POST). Endpoint вызывается по нажатию кнопки в UI (POST).
""" """
settings = Settings() engine = make_engine()
db_url = settings.DATABASE_URL_syncpg
engine = create_engine(db_url, future=True)
metadatas = collect_metadatas() metadatas = collect_metadatas()
if not metadatas: if not metadatas:
@@ -75,8 +73,7 @@ async def create_tables():
@router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)]) @router.post("/db/migrate-users", dependencies=[Depends(require_admin_key)])
async def migrate_users(): async def migrate_users():
"""Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют.""" """Добавляет колонки username и hashed_password в таблицу users, если они ещё не существуют."""
settings = Settings() engine = make_engine()
engine = create_engine(settings.DATABASE_URL_syncpg, future=True)
try: try:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text(""" conn.execute(text("""
@@ -94,6 +91,30 @@ class ClearDBIn(BaseModel):
confirm: bool confirm: bool
@router.post("/db/migrate", dependencies=[Depends(require_admin_key)])
async def migrate_tables():
"""Приводит схему БД в соответствие с моделями: убирает устаревшие колонки, добавляет новые."""
engine = make_engine()
migrations = [
# Убираем старый FK и колонку group.user_id (если остался от прежней схемы)
'ALTER TABLE "group" DROP COLUMN IF EXISTS user_id',
# Делаем organization.group_id необязательным
"ALTER TABLE organization ALTER COLUMN group_id DROP NOT NULL",
# Добавляем новые колонки в users
'ALTER TABLE users ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES "group"(id) ON DELETE SET NULL',
"ALTER TABLE users ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organization(id) ON DELETE SET NULL",
]
applied = []
try:
with engine.begin() as conn:
for sql in migrations:
conn.execute(text(sql))
applied.append(sql)
return {"status": "ok", "applied": applied}
except SQLAlchemyError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/db/clear", dependencies=[Depends(require_admin_key)]) @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`.
@@ -103,9 +124,7 @@ async def clear_tables(payload: ClearDBIn):
if not payload.confirm: if not payload.confirm:
raise HTTPException(status_code=400, detail="Confirmation required") raise HTTPException(status_code=400, detail="Confirmation required")
settings = Settings() engine = make_engine()
db_url = settings.DATABASE_URL_syncpg
engine = create_engine(db_url, future=True)
metadatas = collect_metadatas() metadatas = collect_metadatas()
if not metadatas: if not metadatas:
@@ -120,8 +139,9 @@ async def clear_tables(payload: ClearDBIn):
unique.append(md) unique.append(md)
try: try:
for md in unique: with engine.begin() as conn:
md.drop_all(bind=engine) conn.execute(text("DROP SCHEMA public CASCADE"))
return {"status": "ok", "detail": f"Dropped {len(unique)} metadata groups"} conn.execute(text("CREATE SCHEMA public"))
return {"status": "ok", "detail": "All tables dropped via DROP SCHEMA CASCADE"}
except SQLAlchemyError as e: except SQLAlchemyError as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,49 +3,48 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from bd.tables.organization import Organization from bd.tables.organization import Organization
from bd.tables.group import Group from route.auth_utils import require_admin_key
from route.auth_utils import get_current_user
router = APIRouter(tags=["organizations"], prefix="/organizations") router = APIRouter(tags=["organizations"], prefix="/organizations")
class OrganizationCreate(BaseModel): class OrganizationCreate(BaseModel):
name_organization: str name_organization: str
group_id: str
class OrganizationUpdate(BaseModel): class OrganizationUpdate(BaseModel):
name_organization: Optional[str] name_organization: Optional[str] = None
group_id: Optional[str]
def get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_engine(), future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/", status_code=201) def _org_dict(org: Organization) -> dict:
def create_organization(payload: OrganizationCreate, _: object = Depends(get_current_user)): return {
"id": str(org.id),
"name_organization": org.name_organization,
}
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
def create_organization(payload: OrganizationCreate):
Session = get_session() Session = get_session()
try:
gid = uuid.UUID(payload.group_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid group_id UUID")
with Session() as session: with Session() as session:
grp = session.get(Group, gid) existing = session.query(Organization).filter(
if not grp: Organization.name_organization == payload.name_organization
raise HTTPException(status_code=404, detail="Group not found") ).first()
org = Organization(name_organization=payload.name_organization, group_id=gid) if existing:
raise HTTPException(status_code=400, detail="Organization name already exists")
org = Organization(name_organization=payload.name_organization)
session.add(org) session.add(org)
session.commit() session.commit()
session.refresh(org) session.refresh(org)
return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)} return _org_dict(org)
@router.get("/") @router.get("/")
@@ -53,7 +52,7 @@ def list_organizations():
Session = get_session() Session = get_session()
with Session() as session: with Session() as session:
rows = session.query(Organization).all() rows = session.query(Organization).all()
return [{"id": str(o.id), "name_organization": o.name_organization, "group_id": str(o.group_id)} for o in rows] return [_org_dict(o) for o in rows]
@router.get("/{org_id}") @router.get("/{org_id}")
@@ -67,11 +66,11 @@ def get_organization(org_id: str):
org = session.get(Organization, oid) org = session.get(Organization, oid)
if not org: if not org:
raise HTTPException(status_code=404, detail="Organization not found") raise HTTPException(status_code=404, detail="Organization not found")
return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)} return _org_dict(org)
@router.put("/{org_id}") @router.put("/{org_id}", dependencies=[Depends(require_admin_key)])
def update_organization(org_id: str, payload: OrganizationUpdate, _: object = Depends(get_current_user)): def update_organization(org_id: str, payload: OrganizationUpdate):
Session = get_session() Session = get_session()
try: try:
oid = uuid.UUID(org_id) oid = uuid.UUID(org_id)
@@ -83,23 +82,14 @@ def update_organization(org_id: str, payload: OrganizationUpdate, _: object = De
raise HTTPException(status_code=404, detail="Organization not found") raise HTTPException(status_code=404, detail="Organization not found")
if payload.name_organization is not None: if payload.name_organization is not None:
org.name_organization = payload.name_organization org.name_organization = payload.name_organization
if payload.group_id is not None:
try:
new_gid = uuid.UUID(payload.group_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid group_id UUID")
grp = session.get(Group, new_gid)
if not grp:
raise HTTPException(status_code=404, detail="Group not found")
org.group_id = new_gid
session.add(org) session.add(org)
session.commit() session.commit()
session.refresh(org) session.refresh(org)
return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)} return _org_dict(org)
@router.delete("/{org_id}", status_code=204) @router.delete("/{org_id}", status_code=204, dependencies=[Depends(require_admin_key)])
def delete_organization(org_id: str, _: object = Depends(get_current_user)): def delete_organization(org_id: str):
Session = get_session() Session = get_session()
try: try:
oid = uuid.UUID(org_id) oid = uuid.UUID(org_id)

View File

@@ -3,14 +3,12 @@ from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
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 from route.auth_utils import require_admin_key
router = APIRouter(tags=["polls"], prefix="/polls") router = APIRouter(tags=["polls"], prefix="/polls")
@@ -34,13 +32,12 @@ class PollIn(BaseModel):
def get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_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_poll(payload: PollIn, _: object = Depends(get_current_user)): def create_poll(payload: PollIn):
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

@@ -3,13 +3,11 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
import uuid import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
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 from route.auth_utils import require_admin_key
router = APIRouter(tags=["questions"], prefix="/questions") router = APIRouter(tags=["questions"], prefix="/questions")
@@ -28,13 +26,12 @@ class QuestionUpdate(BaseModel):
def get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_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_question(payload: QuestionCreate, _: object = Depends(get_current_user)): def create_question(payload: QuestionCreate):
Session = get_session() Session = get_session()
try: try:
pid = uuid.UUID(payload.poll_id) pid = uuid.UUID(payload.poll_id)
@@ -51,8 +48,8 @@ def create_question(payload: QuestionCreate, _: object = Depends(get_current_use
return {"id": str(q.id)} return {"id": str(q.id)}
@router.put("/{question_id}") @router.put("/{question_id}", dependencies=[Depends(require_admin_key)])
def update_question(question_id: str, payload: QuestionUpdate, _: object = Depends(get_current_user)): def update_question(question_id: str, payload: QuestionUpdate):
Session = get_session() Session = get_session()
try: try:
qid = uuid.UUID(question_id) qid = uuid.UUID(question_id)
@@ -74,8 +71,8 @@ def update_question(question_id: str, payload: QuestionUpdate, _: object = Depen
return {"id": str(q.id)} return {"id": str(q.id)}
@router.delete("/{question_id}", status_code=204) @router.delete("/{question_id}", status_code=204, dependencies=[Depends(require_admin_key)])
def delete_question(question_id: str, _: object = Depends(get_current_user)): def delete_question(question_id: str):
Session = get_session() Session = get_session()
try: try:
qid = uuid.UUID(question_id) qid = uuid.UUID(question_id)

View File

@@ -3,9 +3,7 @@ from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
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
@@ -26,9 +24,8 @@ class ResponseIn(BaseModel):
def get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_engine(), future=True)
return sessionmaker(bind=engine, future=True)
@router.post("/{poll_id}/responses", status_code=201) @router.post("/{poll_id}/responses", status_code=201)
@@ -71,6 +68,17 @@ def submit_response(poll_id: str, payload: ResponseIn):
return {"id": str(resp.id)} return {"id": str(resp.id)}
@router.get("/responses/")
def list_all_responses():
Session = get_session()
with Session() as session:
rows = session.query(Response).all()
return [
{"id": str(r.id), "poll_id": str(r.poll_id), "user_id": str(r.user_id) if r.user_id else None, "submitted_at": r.submitted_at.isoformat()}
for r in rows
]
@router.get("/{poll_id}/responses") @router.get("/{poll_id}/responses")
def list_responses(poll_id: str): def list_responses(poll_id: str):
Session = get_session() Session = get_session()

View File

@@ -3,9 +3,7 @@ from pydantic import BaseModel, field_validator
from typing import Optional from typing import Optional
import uuid import uuid
from bd import Settings from bd import make_engine
from sqlalchemy import create_engine
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 from route.auth_utils import get_current_user, require_admin_key, hash_password
@@ -18,6 +16,8 @@ class UserCreate(BaseModel):
password: str password: str
first_name: str first_name: str
last_name: str last_name: str
group_id: Optional[str] = None
organization_id: Optional[str] = None
@field_validator("username") @field_validator("username")
@classmethod @classmethod
@@ -38,12 +38,24 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
group_id: Optional[str] = None
organization_id: Optional[str] = None
def get_session(): def get_session():
settings = Settings() from sqlalchemy.orm import sessionmaker
engine = create_engine(settings.DATABASE_URL_syncpg, future=True) return sessionmaker(bind=make_engine(), future=True)
return sessionmaker(bind=engine, future=True)
def _user_dict(user: User) -> dict:
return {
"id": str(user.id),
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"group_id": str(user.group_id) if user.group_id else None,
"organization_id": str(user.organization_id) if user.organization_id else None,
}
@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) @router.post("/", status_code=201, dependencies=[Depends(require_admin_key)])
@@ -58,11 +70,13 @@ def create_user(payload: UserCreate):
last_name=payload.last_name.strip(), last_name=payload.last_name.strip(),
username=payload.username, username=payload.username,
hashed_password=hash_password(payload.password), hashed_password=hash_password(payload.password),
group_id=uuid.UUID(payload.group_id) if payload.group_id else None,
organization_id=uuid.UUID(payload.organization_id) if payload.organization_id else None,
) )
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
return {"id": str(user.id), "username": user.username} return _user_dict(user)
@router.put("/{user_id}") @router.put("/{user_id}")
@@ -82,10 +96,14 @@ def update_user(user_id: str, payload: UserUpdate, current_user: User = Depends(
user.first_name = payload.first_name user.first_name = payload.first_name
if payload.last_name is not None: if payload.last_name is not None:
user.last_name = payload.last_name user.last_name = payload.last_name
if payload.group_id is not None:
user.group_id = uuid.UUID(payload.group_id)
if payload.organization_id is not None:
user.organization_id = uuid.UUID(payload.organization_id)
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 _user_dict(user)
@router.delete("/{user_id}", status_code=204) @router.delete("/{user_id}", status_code=204)
@@ -108,7 +126,15 @@ def delete_user(user_id: str, current_user: User = Depends(get_current_user)):
@router.get("/me") @router.get("/me")
def get_me(current_user: User = Depends(get_current_user)): def get_me(current_user: User = Depends(get_current_user)):
return {"id": str(current_user.id), "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name} return _user_dict(current_user)
@router.get("/", dependencies=[Depends(require_admin_key)])
def list_users():
Session = get_session()
with Session() as session:
rows = session.query(User).all()
return [_user_dict(u) for u in rows]
@router.get("/{user_id}") @router.get("/{user_id}")
@@ -122,4 +148,4 @@ def get_user(user_id: str, current_user: User = Depends(get_current_user)):
user = session.get(User, uid) user = session.get(User, uid)
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name} return _user_dict(user)

View File

@@ -10,7 +10,7 @@ class clolors:
class CastomTextField_input(ft.TextField): class CastomTextField_input(ft.TextField):
def __init__(self, on_change, on_submit, label="Ваше имя", hint_text="Введите текст...", prefix_icon=None, suffix_icon=None): def __init__(self, on_change=None, on_submit=None, label="Ваше имя", hint_text="Введите текст...", prefix_icon=None, suffix_icon=None, **kwargs):
super().__init__( super().__init__(
label=label, label=label,
hint_text=hint_text, hint_text=hint_text,
@@ -34,6 +34,7 @@ class CastomTextField_input(ft.TextField):
suffix_icon=suffix_icon, suffix_icon=suffix_icon,
on_change=on_change, on_change=on_change,
on_submit=on_submit, on_submit=on_submit,
**kwargs,
) )
class CastomButton(ft.Button): class CastomButton(ft.Button):
@@ -50,6 +51,38 @@ class CastomButton(ft.Button):
on_click=on_click on_click=on_click
) )
class CastomDropdown(ft.Dropdown):
def __init__(self, label="", hint_text="", options=None, on_change=None):
super().__init__(
label=label,
hint_text=hint_text,
options=options or [],
filled=True,
fill_color=clolors.laer3,
color=clolors.ferst,
border_color=clolors.laer2,
border_radius=12,
focused_border_color=clolors.laer1,
label_style=ft.TextStyle(
color=clolors.ferst,
weight=ft.FontWeight.BOLD,
),
hint_style=ft.TextStyle(
color=clolors.laer1,
italic=True,
),
text_style=ft.TextStyle(
color=clolors.ferst,
size=16,
),
menu_style=ft.MenuStyle(
bgcolor=clolors.laer3,
),
)
if on_change is not None:
self.on_select = on_change
class CastomSwitch(ft.Switch): class CastomSwitch(ft.Switch):
def __init__(self, label, on_change): def __init__(self, label, on_change):
super().__init__( super().__init__(
@@ -239,4 +272,5 @@ class AlterDiaalogApproval(ft.AlertDialog):
def accept(self, e): def accept(self, e):
self.dialog_result = True self.dialog_result = True
self.open = False self.open = False
self.page.update() self.page.update()
self.page.run_task(self.page.push_route, "/login")

View File

@@ -1,4 +1,5 @@
import os import os
import secrets
import httpx import httpx
import flet as ft import flet as ft
@@ -25,7 +26,13 @@ class AuthService:
) )
if resp.status_code == 200: if resp.status_code == 200:
token = resp.json()["access_token"] token = resp.json()["access_token"]
self._page.session.set(_TOKEN_KEY, token) self._page.session.store.set(_TOKEN_KEY, token)
# Случайный ключ для URL профиля — хранится только в памяти сессии
self._page.session.store.set("session_key", secrets.token_hex(16))
# user_id Для фетчинга данных
me = await self._get_me(token)
if me:
self._page.session.store.set("user_id", me["id"])
return None return None
elif resp.status_code == 401: elif resp.status_code == 401:
return "Неверный логин или пароль" return "Неверный логин или пароль"
@@ -36,14 +43,94 @@ class AuthService:
except Exception as e: except Exception as e:
return f"Ошибка: {e}" return f"Ошибка: {e}"
async def _get_me(self, token: str) -> dict | None:
"""Внутренний метод: получает данные текущего пользователя по токену."""
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client:
resp = await client.get(
"/users/me",
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
async def get_me(self) -> dict | None:
"""Возвращает данные текущего пользователя или None."""
token = self._page.session.store.get(_TOKEN_KEY)
if not token:
return None
return await self._get_me(token)
async def logout(self): async def logout(self):
"""Удаляет токен — пользователь разлогинен.""" """Удаляет токен — пользователь разлогинен."""
self._page.session.remove(_TOKEN_KEY) self._page.session.store.remove(_TOKEN_KEY)
self._page.session.store.remove("user_id")
self._page.session.store.remove("session_key")
async def get_token(self) -> str | None: async def get_token(self) -> str | None:
"""Возвращает сохранённый токен или None если не авторизован.""" """Возвращает сохранённый токен или None если не авторизован."""
return self._page.session.get(_TOKEN_KEY) return self._page.session.store.get(_TOKEN_KEY)
async def is_authenticated(self) -> bool: async def is_authenticated(self) -> bool:
token = await self.get_token() token = await self.get_token()
return token is not None and token != "" return token is not None and token != ""
async def register(
self,
username: str,
password: str,
first_name: str,
last_name: str,
group_id: str | None = None,
organization_id: str | None = None,
) -> str | None:
"""
Отправляет запрос на POST /auth/register.
Возвращает None при успехе, строку с ошибкой при неудаче.
"""
payload = {
"username": username,
"password": password,
"first_name": first_name,
"last_name": last_name,
}
if group_id:
payload["group_id"] = group_id
if organization_id:
payload["organization_id"] = organization_id
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client:
resp = await client.post("/auth/register", json=payload)
if resp.status_code == 201:
return None
elif resp.status_code == 400:
return resp.json().get("detail", "Ошибка регистрации")
else:
return f"Ошибка сервера: {resp.status_code}"
except httpx.ConnectError:
return "Не удалось подключиться к серверу"
except Exception as e:
return f"Ошибка: {e}"
async def get_organizations(self) -> list:
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client:
resp = await client.get("/organizations/")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []
async def get_groups(self) -> list:
try:
async with httpx.AsyncClient(base_url=API_URL, timeout=10) as client:
resp = await client.get("/groups/")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return []

View File

@@ -1,36 +1,14 @@
import flet as ft import flet as ft
from view import ViewsHendler
import designer as ds import designer as ds
from info import AuthService from router import setup_router
async def main(page: ft.Page): async def main(page: ft.Page):
page.title = "COPP" page.title = "COPP"
page.bgcolor = ds.clolors.background page.bgcolor = ds.clolors.background
setup_router(page)
auth = AuthService(page) initial = page.route if page.route and page.route not in ("/", "") else "/login"
_PUBLIC_ROUTES = {"/login"} await page.push_route(initial)
def route_change(e: ft.RouteChangeEvent):
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()
view = ViewsHendler(page).get(route)
if view:
page.views.append(view)
else:
await page.push_route("/main")
page.update()
page.on_route_change = route_change
await page.push_route("/main") app = ft.run(main, export_asgi_app=True, 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")

40
web/router.py Normal file
View File

@@ -0,0 +1,40 @@
import flet as ft
from view import resolve_view, PUBLIC_ROUTES
async def _handle_route_change(page: ft.Page, e: ft.RouteChangeEvent):
route = page.route
token = page.session.store.get("auth_token")
# Публичные маршруты — без проверки токена
if route in PUBLIC_ROUTES:
views = resolve_view(page, route)
page.views.clear()
page.views.extend(views)
page.update()
return
# Защищённый маршрут без токена → логин
if not token:
await page.push_route("/login")
return
views = resolve_view(page, route)
# Неизвестный маршрут → на страницу пользователя
if views is None:
session_key = page.session.store.get("session_key") or ""
await page.push_route(f"/user/{session_key}")
return
page.views.clear()
page.views.extend(views)
page.update()
def setup_router(page: ft.Page):
"""Привязывает router к странице. Вызывается один раз в main()."""
async def _on_route_change(e: ft.RouteChangeEvent):
await _handle_route_change(page, e)
page.on_route_change = _on_route_change

View File

@@ -1,10 +1,20 @@
import flet as ft import flet as ft
from views import main_view, login_view from views import main_view, login_view, reg_view, user_view
def ViewsHendler(page: ft.Page): PUBLIC_ROUTES = {"/login", "/reg"}
return {
"/main": main_view.MainView(),
"/login": login_view.LoginView(page), def resolve_view(page: ft.Page, route: str) -> list[ft.View] | None:
"/about": ft.View(route="/about", controls=[ft.Text("О нас",color="#008000", size=20)]), """
"/contact": ft.View(route="/contact", controls=[ft.Text("Контакты",color="#FF0000", size=20)]) Возвращает список вью для заданного маршрута или None если маршрут неизвестен.
} Flet использует page.views как стек: последний элемент — активный экран.
"""
if route == "/login":
return [login_view.LoginView(page)]
elif route == "/reg":
return [reg_view.RegView(page)]
elif route == "/main":
return [main_view.MainView()]
elif route.startswith("/user/"):
return [user_view.UserView(page)]
return None

View File

@@ -0,0 +1,12 @@
import flet as ft
import designer as ds
class MainPageUser(ft.Column):
def __init__(self):
super().__init__(
spacing=16,
controls=[
ft.Text("Добро пожаловать в COPP!", color=ds.clolors.ferst, size=24, weight=ft.FontWeight.BOLD),
],
)

View File

@@ -7,31 +7,18 @@ class LoginView(ft.View):
def __init__(self, page: ft.Page): def __init__(self, page: ft.Page):
self._auth = AuthService(page) self._auth = AuthService(page)
self._username = ft.TextField( self._username = ds.CastomTextField_input(
label="Логин", label="Логин",
hint_text="Введите логин...", hint_text="Введите логин...",
filled=True,
bgcolor=ds.clolors.ferst,
border_color=ds.clolors.laer2,
border_radius=12,
prefix_icon=ft.Icons.PERSON, 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( self._password = ds.CastomTextField_input(
label="Пароль", label="Пароль",
hint_text="Введите пароль...", hint_text="Введите пароль...",
filled=True,
bgcolor=ds.clolors.ferst,
border_color=ds.clolors.laer2,
border_radius=12,
prefix_icon=ft.Icons.LOCK, prefix_icon=ft.Icons.LOCK,
password=True, password=True,
can_reveal_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._error = ft.Text("", color=ft.Colors.RED_400, size=14, visible=False)
@@ -41,6 +28,12 @@ class LoginView(ft.View):
on_click=lambda e: page.run_task(self._do_login), on_click=lambda e: page.run_task(self._do_login),
) )
self._reg_link = ft.TextButton(
content="Нет аккаунта? Зарегистрироваться",
style=ft.ButtonStyle(color=ds.clolors.ferst),
on_click=lambda e: page.run_task(page.push_route, "/reg"),
)
super().__init__( super().__init__(
route="/login", route="/login",
bgcolor=ds.clolors.background, bgcolor=ds.clolors.background,
@@ -64,6 +57,7 @@ class LoginView(ft.View):
self._password, self._password,
self._error, self._error,
self._btn, self._btn,
self._reg_link,
], ],
), ),
), ),
@@ -87,8 +81,9 @@ class LoginView(ft.View):
self._btn.disabled = False self._btn.disabled = False
self._show_error(error) self._show_error(error)
else: else:
# Успешный вход — переходим на главную # Успешный вход — переходим на страницу пользователя
await self.page.push_route("/main") session_key = self.page.session.store.get("session_key")
await self.page.push_route(f"/user/{session_key}")
def _show_error(self, message: str): def _show_error(self, message: str):
self._error.value = message self._error.value = message

167
web/views/reg_view.py Normal file
View File

@@ -0,0 +1,167 @@
import asyncio
import flet as ft
import designer as ds
from info import AuthService
class RegView(ft.View):
def __init__(self, page: ft.Page):
self._auth = AuthService(page)
self._orgs: list = []
self._first_name = ds.CastomTextField_input(
label="Имя",
hint_text="Введите имя...",
prefix_icon=ft.Icons.PERSON,
)
self._last_name = ds.CastomTextField_input(
label="Фамилия",
hint_text="Введите фамилию...",
prefix_icon=ft.Icons.PERSON_OUTLINE,
)
self._username = ds.CastomTextField_input(
label="Логин",
hint_text="Введите логин...",
prefix_icon=ft.Icons.ALTERNATE_EMAIL,
)
self._password = ds.CastomTextField_input(
label="Пароль",
hint_text="Введите пароль...",
prefix_icon=ft.Icons.LOCK,
password=True,
can_reveal_password=True,
)
self._org_dropdown = ds.CastomDropdown(
label="Организация",
hint_text="Загрузка...",
)
self._org_dropdown.on_select = self._on_org_change
self._group_dropdown = ds.CastomDropdown(
label="Группа",
hint_text="Загрузка...",
)
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_register),
)
self._btn_cansel = ds.CastomButton(
text="Назад",
on_click=lambda e: page.run_task(self._do_cancel),
)
super().__init__(
route="/reg",
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,
scroll="auto",
controls=[
self._first_name,
self._last_name,
self._username,
self._password,
self._org_dropdown,
self._group_dropdown,
self._error,
ft.Row(
controls=[
self._btn_cansel,
self._btn,
],
)
],
),
),
]
def did_mount(self):
self.page.run_task(self._load_data)
async def _load_data(self):
orgs, groups = await asyncio.gather(
self._auth.get_organizations(),
self._auth.get_groups(),
)
self._orgs = orgs or []
_opt_style = ft.ButtonStyle(color=ds.clolors.ferst)
self._org_dropdown.options = [
ft.DropdownOption(key=o["id"], text=o["name_organization"], style=_opt_style) for o in self._orgs
]
self._org_dropdown.hint_text = "Выберите организацию..."
self._group_dropdown.options = [
ft.DropdownOption(key=g["id"], text=g["name_group"], style=_opt_style) for g in (groups or [])
]
self._group_dropdown.hint_text = "Выберите группу..."
self.update()
def _on_org_change(self, e):
# Автоматически выбираем группу, к которой привязана организация
selected_id = e.control.value
for o in self._orgs:
if o["id"] == selected_id:
self._group_dropdown.value = o.get("group_id")
break
self.update()
async def _do_cancel(self):
await self.page.push_route("/login")
async def _do_register(self):
first_name = self._first_name.value.strip() if self._first_name.value else ""
last_name = self._last_name.value.strip() if self._last_name.value else ""
username = self._username.value.strip() if self._username.value else ""
password = self._password.value or ""
if not all([first_name, last_name, username, password]):
self._show_error("Заполните все обязательные поля")
return
self._btn.disabled = True
self._error.visible = False
self.update()
error = await self._auth.register(
username=username,
password=password,
first_name=first_name,
last_name=last_name,
group_id=self._group_dropdown.value,
organization_id=self._org_dropdown.value,
)
if error:
self._btn.disabled = False
self._show_error(error)
else:
await self.page.push_route("/login")
def _show_error(self, message: str):
self._error.value = message
self._error.visible = True
self.update()

81
web/views/user_view.py Normal file
View File

@@ -0,0 +1,81 @@
import flet as ft
import designer as ds
from info import AuthService
class UserView(ft.View):
def __init__(self, page: ft.Page):
self._auth = AuthService(page)
session_key = page.session.store.get("session_key") or ""
self.spacing_groop = 6
self.border_radius = 15
self.content = ft.Container(
expand=True,
content=ft.Column(
alignment=ft.MainAxisAlignment.CENTER,
spacing=self.spacing_groop,
controls=[
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
spacing=self.spacing_groop,
controls=[
ft.Container(
border_radius=self.border_radius,
height=200,
width=200,
bgcolor=ds.clolors.laer2
),
ft.Container(
border_radius=self.border_radius,
height=200,
width=200,
bgcolor=ds.clolors.laer2
)
]
),
ft.Row(
alignment=ft.MainAxisAlignment.CENTER,
spacing=self.spacing_groop,
controls=[
ft.Container(
border_radius=self.border_radius,
height=200,
width=200,
bgcolor=ds.clolors.laer2
),
ft.Container(
border_radius=self.border_radius,
height=200,
width=200,
bgcolor=ds.clolors.laer2
)
]
),
],
)
)
super().__init__(
route=f"/user/{session_key}",
bgcolor=ds.clolors.background,
horizontal_alignment=ft.CrossAxisAlignment.START,
vertical_alignment=ft.MainAxisAlignment.START,
)
self.controls = [
ft.Column(
expand=True,
alignment=ft.MainAxisAlignment.CENTER,
controls=[
ft.Row(
controls=[
ft.Text("Добро пожаловать в COPP!", color=ds.clolors.ferst, size=24, weight=ft.FontWeight.BOLD),
]
),
self.content
]
)
]