From 59b3bcd0f45c81e57e511f2ef70a50d306c91c68 Mon Sep 17 00:00:00 2001 From: jze9 Date: Tue, 31 Mar 2026 13:25:14 +0500 Subject: [PATCH] web sttarting --- .gitignore | 1 + Dockerfile.web | 2 +- bd/__init__.py | 16 ++- bd/tables/group.py | 8 +- bd/tables/organization.py | 5 +- bd/tables/response.py | 4 +- bd/tables/users.py | 9 +- docker-compose.yml | 9 +- docker-compose_db.yml | 25 ++++ docker-compose_web.yml | 10 ++ route/auth.py | 21 ++-- route/auth_utils.py | 13 +-- route/choice_crud.py | 23 ++-- route/groups_crud.py | 61 ++++------ route/holland_crud.py | 17 ++- route/init_data_base.py | 46 +++++--- route/organizations_crud.py | 68 +++++------ route/poll_crud.py | 15 +-- route/question_crud.py | 23 ++-- route/response_crud.py | 20 +++- route/users_crud.py | 46 ++++++-- web/designer.py | 38 +++++- web/info.py | 93 ++++++++++++++- web/main.py | 34 +----- web/router.py | 40 +++++++ web/view.py | 26 +++-- web/views/content_users/main_page.py | 12 ++ web/views/login_view.py | 29 ++--- web/views/reg_view.py | 167 +++++++++++++++++++++++++++ web/views/user_view.py | 81 +++++++++++++ 30 files changed, 720 insertions(+), 242 deletions(-) create mode 100644 docker-compose_db.yml create mode 100644 docker-compose_web.yml create mode 100644 web/router.py create mode 100644 web/views/content_users/main_page.py create mode 100644 web/views/reg_view.py create mode 100644 web/views/user_view.py diff --git a/.gitignore b/.gitignore index c76881c..4f92a84 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ venv.bak/ ./node_modules node_modules +data \ No newline at end of file diff --git a/Dockerfile.web b/Dockerfile.web index a26e547..5b18f32 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -3,7 +3,7 @@ FROM python:3.11-slim WORKDIR /app # Устанавливаем 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 . . diff --git a/bd/__init__.py b/bd/__init__.py index 3ffc02b..c2bd00b 100644 --- a/bd/__init__.py +++ b/bd/__init__.py @@ -1,10 +1,10 @@ # Database configuration package -from pydantic.generics import GenericModel +from pydantic import BaseModel import re import os import urllib.parse -class Settings(GenericModel): +class Settings(BaseModel): DB_HOST: str = os.getenv("DB_HOST", "postgres") DB_PORT: int = int(os.getenv("DB_PORT", "5432")) DB_USER: str = os.getenv("DB_USER", "postgres") @@ -32,3 +32,15 @@ class Settings(GenericModel): def DATABASE_URL_syncpg(self) -> str: 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}" + + @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) diff --git a/bd/tables/group.py b/bd/tables/group.py index 8c2c509..22f22be 100644 --- a/bd/tables/group.py +++ b/bd/tables/group.py @@ -1,17 +1,13 @@ -from sqlalchemy import Column, String, ForeignKey +from sqlalchemy import Column, String from sqlalchemy.dialects.postgresql import UUID from .simple_base import Base import uuid -from sqlalchemy.orm import relationship class Group(Base): __tablename__ = "group" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name_group = Column(String(150), nullable=False) - user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - - user = relationship("User", backref="groups", passive_deletes=True) + name_group = Column(String(150), nullable=False, unique=True) __all__ = ["Base", "Group"] diff --git a/bd/tables/organization.py b/bd/tables/organization.py index c18445b..0e4dbeb 100644 --- a/bd/tables/organization.py +++ b/bd/tables/organization.py @@ -8,10 +8,7 @@ class Organization(Base): __tablename__ = "organization" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name_organization = Column(String(150), nullable=False) - group_id = Column(UUID(as_uuid=True), ForeignKey("group.id", ondelete="CASCADE"), nullable=False) - - group = relationship("Group", backref="organizations", passive_deletes=True) + name_organization = Column(String(150), nullable=False, unique=True) __all__ = ["Base", "Organization"] diff --git a/bd/tables/response.py b/bd/tables/response.py index 41f7c86..0ee3e73 100644 --- a/bd/tables/response.py +++ b/bd/tables/response.py @@ -10,7 +10,7 @@ class Response(Base): __tablename__ = "responses" 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) 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) 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) text = Column(Text, nullable=True) diff --git a/bd/tables/users.py b/bd/tables/users.py index bbb31a0..d665105 100644 --- a/bd/tables/users.py +++ b/bd/tables/users.py @@ -1,5 +1,6 @@ -from sqlalchemy import Column, String +from sqlalchemy import Column, String, ForeignKey from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship from .simple_base import Base import uuid @@ -12,5 +13,11 @@ class User(Base): username = Column(String(150), nullable=False, unique=True) 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"] diff --git a/docker-compose.yml b/docker-compose.yml index a80a7e8..471bf47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,13 +9,14 @@ services: - APP_PATH=/home/user/api-copp - DEBUG=True - - DB_HOST=192.168.1.11 + - DB_HOST=postgres - DB_PORT=5432 - - DB_USER=admin1 - - DB_PASS=Y6%r35RpckeP^F + - DB_USER=postgres + - DB_PASS=postgres - DB_NAME=profi + # Redis connection (external) - - REDIS_HOST=192.168.1.19 + - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_DB=0 - REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn diff --git a/docker-compose_db.yml b/docker-compose_db.yml new file mode 100644 index 0000000..ce25aa2 --- /dev/null +++ b/docker-compose_db.yml @@ -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 \ No newline at end of file diff --git a/docker-compose_web.yml b/docker-compose_web.yml new file mode 100644 index 0000000..faf5355 --- /dev/null +++ b/docker-compose_web.yml @@ -0,0 +1,10 @@ +services: + web: + build: + context: ./web + dockerfile: ./Dockerfile.web + ports: + - "80:80" + environment: + - API_URL=http://api:8000 + diff --git a/route/auth.py b/route/auth.py index a4d45f5..c136929 100644 --- a/route/auth.py +++ b/route/auth.py @@ -1,10 +1,10 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, field_validator +from typing import Optional +import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.users import User 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") -def _get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) +def get_session(): + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) class RegisterIn(BaseModel): @@ -23,6 +22,8 @@ class RegisterIn(BaseModel): password: str first_name: str last_name: str + group_id: Optional[str] = None + organization_id: Optional[str] = None @field_validator("password") @classmethod @@ -42,7 +43,7 @@ class RegisterIn(BaseModel): @router.post("/register", status_code=201) def register(payload: RegisterIn): - Session = _get_session() + Session = get_session() with Session() as session: existing = session.query(User).filter(User.username == payload.username).first() if existing: @@ -52,6 +53,8 @@ def register(payload: RegisterIn): last_name=payload.last_name.strip(), username=payload.username, 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.commit() @@ -61,7 +64,7 @@ def register(payload: RegisterIn): @router.post("/login") def login(form: OAuth2PasswordRequestForm = Depends()): - Session = _get_session() + 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): diff --git a/route/auth_utils.py b/route/auth_utils.py index 5e4664a..9863896 100644 --- a/route/auth_utils.py +++ b/route/auth_utils.py @@ -9,9 +9,7 @@ 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 +from bd import make_engine SECRET_KEY: str = os.getenv("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) -def _get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) +def get_session(): + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) def get_current_user(token: str = Depends(oauth2_scheme)): @@ -74,7 +71,7 @@ def get_current_user(token: str = Depends(oauth2_scheme)): except Exception: raise credentials_exc - Session = _get_session() + Session = get_session() with Session() as session: user = session.get(User, uid) if user is None: diff --git a/route/choice_crud.py b/route/choice_crud.py index 7317d23..07bf2fe 100644 --- a/route/choice_crud.py +++ b/route/choice_crud.py @@ -3,13 +3,11 @@ from pydantic import BaseModel from typing import Optional import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.choice import Choice 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") @@ -26,13 +24,12 @@ class ChoiceUpdate(BaseModel): def get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) -@router.post("/", status_code=201) -def create_choice(payload: ChoiceCreate, _: object = Depends(get_current_user)): +@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) +def create_choice(payload: ChoiceCreate): Session = get_session() try: 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)} -@router.put("/{choice_id}") -def update_choice(choice_id: str, payload: ChoiceUpdate, _: object = Depends(get_current_user)): +@router.put("/{choice_id}", dependencies=[Depends(require_admin_key)]) +def update_choice(choice_id: str, payload: ChoiceUpdate): Session = get_session() try: 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)} -@router.delete("/{choice_id}", status_code=204) -def delete_choice(choice_id: str, _: object = Depends(get_current_user)): +@router.delete("/{choice_id}", status_code=204, dependencies=[Depends(require_admin_key)]) +def delete_choice(choice_id: str): Session = get_session() try: cid = uuid.UUID(choice_id) diff --git a/route/groups_crud.py b/route/groups_crud.py index 98385d0..5afa5a7 100644 --- a/route/groups_crud.py +++ b/route/groups_crud.py @@ -3,49 +3,43 @@ from pydantic import BaseModel from typing import Optional import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.group import Group -from bd.tables.users import User -from route.auth_utils import get_current_user +from route.auth_utils import require_admin_key router = APIRouter(tags=["groups"], prefix="/groups") class GroupCreate(BaseModel): name_group: str - user_id: str class GroupUpdate(BaseModel): - name_group: Optional[str] - user_id: Optional[str] + name_group: Optional[str] = None def get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) -@router.post("/", status_code=201) -def create_group(payload: GroupCreate, _: object = Depends(get_current_user)): +def _group_dict(grp: Group) -> dict: + 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() - try: - uid = uuid.UUID(payload.user_id) - except Exception: - raise HTTPException(status_code=400, detail="Invalid user_id UUID") with Session() as session: - user = session.get(User, uid) - if not user: - raise HTTPException(status_code=404, detail="User not found") - grp = Group(name_group=payload.name_group, user_id=uid) + existing = session.query(Group).filter(Group.name_group == payload.name_group).first() + if existing: + raise HTTPException(status_code=400, detail="Group name already exists") + grp = Group(name_group=payload.name_group) session.add(grp) session.commit() 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("/") @@ -53,7 +47,7 @@ def list_groups(): Session = get_session() with Session() as session: 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}") @@ -67,11 +61,11 @@ def get_group(group_id: str): grp = session.get(Group, gid) if not grp: 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}") -def update_group(group_id: str, payload: GroupUpdate, _: object = Depends(get_current_user)): +@router.put("/{group_id}", dependencies=[Depends(require_admin_key)]) +def update_group(group_id: str, payload: GroupUpdate): Session = get_session() try: 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") if payload.name_group is not None: 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.commit() 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) -def delete_group(group_id: str, _: object = Depends(get_current_user)): +@router.delete("/{group_id}", status_code=204, dependencies=[Depends(require_admin_key)]) +def delete_group(group_id: str): Session = get_session() try: gid = uuid.UUID(group_id) diff --git a/route/holland_crud.py b/route/holland_crud.py index e32fd20..9917375 100644 --- a/route/holland_crud.py +++ b/route/holland_crud.py @@ -2,9 +2,7 @@ from fastapi import APIRouter, HTTPException from typing import Optional import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.poll import Poll from bd.tables.question import Question @@ -60,9 +58,8 @@ PAIRS = [ def get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) @router.post("/holland", status_code=201) @@ -83,3 +80,11 @@ def create_holland_poll(author_id: Optional[str] = None): session.commit() session.refresh(poll) 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] diff --git a/route/init_data_base.py b/route/init_data_base.py index e6f2c25..efaebec 100644 --- a/route/init_data_base.py +++ b/route/init_data_base.py @@ -4,8 +4,8 @@ import importlib from pathlib import Path from typing import List -from bd import Settings -from sqlalchemy import create_engine, text +from bd import Settings, make_engine +from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from pydantic import BaseModel @@ -48,9 +48,7 @@ async def create_tables(): Endpoint вызывается по нажатию кнопки в UI (POST). """ - settings = Settings() - db_url = settings.DATABASE_URL_syncpg - engine = create_engine(db_url, future=True) + engine = make_engine() metadatas = collect_metadatas() if not metadatas: @@ -75,8 +73,7 @@ async def create_tables(): @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) + engine = make_engine() try: with engine.connect() as conn: conn.execute(text(""" @@ -94,6 +91,30 @@ class ClearDBIn(BaseModel): 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)]) async def clear_tables(payload: ClearDBIn): """Полная очистка всех таблиц, описанных в `bd.tables`. @@ -103,9 +124,7 @@ async def clear_tables(payload: ClearDBIn): if not payload.confirm: raise HTTPException(status_code=400, detail="Confirmation required") - settings = Settings() - db_url = settings.DATABASE_URL_syncpg - engine = create_engine(db_url, future=True) + engine = make_engine() metadatas = collect_metadatas() if not metadatas: @@ -120,8 +139,9 @@ async def clear_tables(payload: ClearDBIn): unique.append(md) try: - for md in unique: - md.drop_all(bind=engine) - return {"status": "ok", "detail": f"Dropped {len(unique)} metadata groups"} + with engine.begin() as conn: + conn.execute(text("DROP SCHEMA public CASCADE")) + conn.execute(text("CREATE SCHEMA public")) + return {"status": "ok", "detail": "All tables dropped via DROP SCHEMA CASCADE"} except SQLAlchemyError as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/route/organizations_crud.py b/route/organizations_crud.py index 4f98123..c908dc9 100644 --- a/route/organizations_crud.py +++ b/route/organizations_crud.py @@ -3,49 +3,48 @@ from pydantic import BaseModel from typing import Optional import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.organization import Organization -from bd.tables.group import Group -from route.auth_utils import get_current_user +from route.auth_utils import require_admin_key router = APIRouter(tags=["organizations"], prefix="/organizations") class OrganizationCreate(BaseModel): name_organization: str - group_id: str - class OrganizationUpdate(BaseModel): - name_organization: Optional[str] - group_id: Optional[str] + name_organization: Optional[str] = None + def get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) -@router.post("/", status_code=201) -def create_organization(payload: OrganizationCreate, _: object = Depends(get_current_user)): +def _org_dict(org: Organization) -> dict: + 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() - try: - gid = uuid.UUID(payload.group_id) - except Exception: - raise HTTPException(status_code=400, detail="Invalid group_id UUID") with Session() as session: - grp = session.get(Group, gid) - if not grp: - raise HTTPException(status_code=404, detail="Group not found") - org = Organization(name_organization=payload.name_organization, group_id=gid) + existing = session.query(Organization).filter( + Organization.name_organization == payload.name_organization + ).first() + if existing: + raise HTTPException(status_code=400, detail="Organization name already exists") + org = Organization(name_organization=payload.name_organization) session.add(org) session.commit() 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("/") @@ -53,7 +52,7 @@ def list_organizations(): Session = get_session() with Session() as session: 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}") @@ -67,11 +66,11 @@ def get_organization(org_id: str): org = session.get(Organization, oid) if not org: 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}") -def update_organization(org_id: str, payload: OrganizationUpdate, _: object = Depends(get_current_user)): +@router.put("/{org_id}", dependencies=[Depends(require_admin_key)]) +def update_organization(org_id: str, payload: OrganizationUpdate): Session = get_session() try: 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") if payload.name_organization is not None: 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.commit() 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) -def delete_organization(org_id: str, _: object = Depends(get_current_user)): +@router.delete("/{org_id}", status_code=204, dependencies=[Depends(require_admin_key)]) +def delete_organization(org_id: str): Session = get_session() try: oid = uuid.UUID(org_id) diff --git a/route/poll_crud.py b/route/poll_crud.py index f8a2aa8..40566e7 100644 --- a/route/poll_crud.py +++ b/route/poll_crud.py @@ -3,14 +3,12 @@ from pydantic import BaseModel from typing import List, Optional import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.poll import Poll from bd.tables.question import Question 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") @@ -34,13 +32,12 @@ class PollIn(BaseModel): def get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) -@router.post("/", status_code=201) -def create_poll(payload: PollIn, _: object = Depends(get_current_user)): +@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) +def create_poll(payload: PollIn): Session = get_session() with Session() as session: poll = Poll(title=payload.title, description=payload.description) diff --git a/route/question_crud.py b/route/question_crud.py index fb837eb..67194a2 100644 --- a/route/question_crud.py +++ b/route/question_crud.py @@ -3,13 +3,11 @@ from pydantic import BaseModel from typing import Optional import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.question import Question 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") @@ -28,13 +26,12 @@ class QuestionUpdate(BaseModel): def get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) -@router.post("/", status_code=201) -def create_question(payload: QuestionCreate, _: object = Depends(get_current_user)): +@router.post("/", status_code=201, dependencies=[Depends(require_admin_key)]) +def create_question(payload: QuestionCreate): Session = get_session() try: 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)} -@router.put("/{question_id}") -def update_question(question_id: str, payload: QuestionUpdate, _: object = Depends(get_current_user)): +@router.put("/{question_id}", dependencies=[Depends(require_admin_key)]) +def update_question(question_id: str, payload: QuestionUpdate): Session = get_session() try: qid = uuid.UUID(question_id) @@ -74,8 +71,8 @@ def update_question(question_id: str, payload: QuestionUpdate, _: object = Depen return {"id": str(q.id)} -@router.delete("/{question_id}", status_code=204) -def delete_question(question_id: str, _: object = Depends(get_current_user)): +@router.delete("/{question_id}", status_code=204, dependencies=[Depends(require_admin_key)]) +def delete_question(question_id: str): Session = get_session() try: qid = uuid.UUID(question_id) diff --git a/route/response_crud.py b/route/response_crud.py index 57f7e92..6faf996 100644 --- a/route/response_crud.py +++ b/route/response_crud.py @@ -3,9 +3,7 @@ from pydantic import BaseModel from typing import List, Optional import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.poll import Poll from bd.tables.question import Question @@ -26,9 +24,8 @@ class ResponseIn(BaseModel): def get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_engine(), future=True) @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)} +@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") def list_responses(poll_id: str): Session = get_session() diff --git a/route/users_crud.py b/route/users_crud.py index 2a79e57..89a2601 100644 --- a/route/users_crud.py +++ b/route/users_crud.py @@ -3,9 +3,7 @@ from pydantic import BaseModel, field_validator from typing import Optional import uuid -from bd import Settings -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from bd import make_engine from bd.tables.users import User from route.auth_utils import get_current_user, require_admin_key, hash_password @@ -18,6 +16,8 @@ class UserCreate(BaseModel): password: str first_name: str last_name: str + group_id: Optional[str] = None + organization_id: Optional[str] = None @field_validator("username") @classmethod @@ -38,12 +38,24 @@ class UserCreate(BaseModel): class UserUpdate(BaseModel): first_name: Optional[str] = None last_name: Optional[str] = None + group_id: Optional[str] = None + organization_id: Optional[str] = None def get_session(): - settings = Settings() - engine = create_engine(settings.DATABASE_URL_syncpg, future=True) - return sessionmaker(bind=engine, future=True) + from sqlalchemy.orm import sessionmaker + return sessionmaker(bind=make_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)]) @@ -58,11 +70,13 @@ def create_user(payload: UserCreate): last_name=payload.last_name.strip(), username=payload.username, 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.commit() session.refresh(user) - return {"id": str(user.id), "username": user.username} + return _user_dict(user) @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 if payload.last_name is not None: 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.commit() 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) @@ -108,7 +126,15 @@ def delete_user(user_id: str, current_user: User = Depends(get_current_user)): @router.get("/me") 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}") @@ -122,4 +148,4 @@ def get_user(user_id: str, current_user: User = Depends(get_current_user)): user = session.get(User, uid) if not user: 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) diff --git a/web/designer.py b/web/designer.py index f0c036f..3457258 100644 --- a/web/designer.py +++ b/web/designer.py @@ -10,7 +10,7 @@ class clolors: 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__( label=label, hint_text=hint_text, @@ -34,6 +34,7 @@ class CastomTextField_input(ft.TextField): suffix_icon=suffix_icon, on_change=on_change, on_submit=on_submit, + **kwargs, ) class CastomButton(ft.Button): @@ -50,6 +51,38 @@ class CastomButton(ft.Button): 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): def __init__(self, label, on_change): super().__init__( @@ -239,4 +272,5 @@ class AlterDiaalogApproval(ft.AlertDialog): def accept(self, e): self.dialog_result = True self.open = False - self.page.update() \ No newline at end of file + self.page.update() + self.page.run_task(self.page.push_route, "/login") \ No newline at end of file diff --git a/web/info.py b/web/info.py index 23d2fed..4b02b9d 100644 --- a/web/info.py +++ b/web/info.py @@ -1,4 +1,5 @@ import os +import secrets import httpx import flet as ft @@ -25,7 +26,13 @@ class AuthService: ) if resp.status_code == 200: 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 elif resp.status_code == 401: return "Неверный логин или пароль" @@ -36,14 +43,94 @@ class AuthService: except Exception as 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): """Удаляет токен — пользователь разлогинен.""" - 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: """Возвращает сохранённый токен или None если не авторизован.""" - return self._page.session.get(_TOKEN_KEY) + return self._page.session.store.get(_TOKEN_KEY) async def is_authenticated(self) -> bool: token = await self.get_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 [] diff --git a/web/main.py b/web/main.py index ecf2adf..55e1491 100644 --- a/web/main.py +++ b/web/main.py @@ -1,36 +1,14 @@ import flet as ft -from view import ViewsHendler import designer as ds -from info import AuthService +from router import setup_router + async def main(page: ft.Page): page.title = "COPP" page.bgcolor = ds.clolors.background - - auth = AuthService(page) - _PUBLIC_ROUTES = {"/login"} - - 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 + setup_router(page) + initial = page.route if page.route and page.route not in ("/", "") else "/login" + await page.push_route(initial) - await page.push_route("/main") - - -# export_asgi_app=True — Flet отдаёт ASGI-объект вместо запуска встроенного сервера. -# Uvicorn из CMD в Dockerfile подхватывает этот объект через «main:app». -app = ft.run(main, export_asgi_app=True, assets_dir="assets") \ No newline at end of file +app = ft.run(main, export_asgi_app=True, assets_dir="assets") diff --git a/web/router.py b/web/router.py new file mode 100644 index 0000000..48e19df --- /dev/null +++ b/web/router.py @@ -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 diff --git a/web/view.py b/web/view.py index 417d02c..1fc4671 100644 --- a/web/view.py +++ b/web/view.py @@ -1,10 +1,20 @@ 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): - return { - "/main": main_view.MainView(), - "/login": login_view.LoginView(page), - "/about": ft.View(route="/about", controls=[ft.Text("О нас",color="#008000", size=20)]), - "/contact": ft.View(route="/contact", controls=[ft.Text("Контакты",color="#FF0000", size=20)]) - } \ No newline at end of file +PUBLIC_ROUTES = {"/login", "/reg"} + + +def resolve_view(page: ft.Page, route: str) -> list[ft.View] | None: + """ + Возвращает список вью для заданного маршрута или 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 diff --git a/web/views/content_users/main_page.py b/web/views/content_users/main_page.py new file mode 100644 index 0000000..c9a73ab --- /dev/null +++ b/web/views/content_users/main_page.py @@ -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), + + ], + ) \ No newline at end of file diff --git a/web/views/login_view.py b/web/views/login_view.py index cd74dd6..38b7d03 100644 --- a/web/views/login_view.py +++ b/web/views/login_view.py @@ -7,31 +7,18 @@ class LoginView(ft.View): def __init__(self, page: ft.Page): self._auth = AuthService(page) - self._username = ft.TextField( + self._username = ds.CastomTextField_input( 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( + self._password = ds.CastomTextField_input( 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) @@ -41,6 +28,12 @@ class LoginView(ft.View): 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__( route="/login", bgcolor=ds.clolors.background, @@ -64,6 +57,7 @@ class LoginView(ft.View): self._password, self._error, self._btn, + self._reg_link, ], ), ), @@ -87,8 +81,9 @@ class LoginView(ft.View): self._btn.disabled = False self._show_error(error) 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): self._error.value = message diff --git a/web/views/reg_view.py b/web/views/reg_view.py new file mode 100644 index 0000000..26474e4 --- /dev/null +++ b/web/views/reg_view.py @@ -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() + diff --git a/web/views/user_view.py b/web/views/user_view.py new file mode 100644 index 0000000..7971810 --- /dev/null +++ b/web/views/user_view.py @@ -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 + ] + ) + + ]