diff --git a/backend/create_new_db.py b/backend/create_new_db.py index dece05e..a3ac8de 100644 --- a/backend/create_new_db.py +++ b/backend/create_new_db.py @@ -1,5 +1,26 @@ -from backend.models import Base -from backend.database import engine +from sqlalchemy.orm import sessionmaker + +from backend.models import Base, User +from backend.database import engine +from backend.security import get_password_hash + + +def recreate_db_with_admin(): + # Drop and recreate all tables + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + + # Seed default admin user (username: admin, password: admin) + Session = sessionmaker(bind=engine) + db = Session() + try: + if not db.query(User).filter(User.username == 'admin').first(): + db.add(User(username='admin', password_hash=get_password_hash('admin'), role='admin')) + db.commit() + finally: + db.close() + + +if __name__ == "__main__": + recreate_db_with_admin() -Base.metadata.drop_all(bind=engine) # Опционально, если вдруг есть -Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index c28c42b..8a432d8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,8 @@ from backend.routers.oboruds import oboruds from backend.routers.components import components from backend.routers.rashodniki import consumables from backend.routers.zametki import zametki +from backend.routers.auth import auth +from backend.routers.owners import owners @@ -38,6 +40,10 @@ app.mount("/app", StaticFiles(directory="frontend", html=True), name="frontend") def root(): return RedirectResponse(url="/app/") +@app.get("/login") +def login_page(): + return RedirectResponse(url="/app/login.html") + # Подключение роутов app.include_router(equipment_types) @@ -46,3 +52,5 @@ app.include_router(oboruds) app.include_router(components) app.include_router(consumables) app.include_router(zametki) +app.include_router(auth) +app.include_router(owners) diff --git a/backend/migrate_data.py b/backend/migrate_data.py index c1c7689..1bcaf71 100644 --- a/backend/migrate_data.py +++ b/backend/migrate_data.py @@ -1,10 +1,12 @@ import sys -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine, text, inspect from sqlalchemy.orm import sessionmaker -from backend.database import SessionLocal as NewSession +from backend.database import SessionLocal as NewSession, engine as new_engine from backend import models +from pathlib import Path -OLD_DB_URL = "sqlite:///./backend/old_app.db" + +OLD_DB_URL = "sqlite:///./instance/project.-10-11-25.db" old_engine = create_engine(OLD_DB_URL, connect_args={"check_same_thread": False}) OldSession = sessionmaker(bind=old_engine) old_db = OldSession() @@ -14,7 +16,25 @@ new_db = NewSession() def log(msg: str): print(f"[INFO] {msg}", file=sys.stderr) + +def ensure_schema(): + """Ensure new DB has required tables/columns (owners table and oboruds.owner_id).""" + # Create any missing tables defined in models (e.g., owners) + models.Base.metadata.create_all(bind=new_engine) + + # If oboruds.owner_id is missing (older DB), add the column (SQLite allows simple ALTER) + try: + inspector = inspect(new_engine) + cols = [c["name"] for c in inspector.get_columns("oboruds")] + if "owner_id" not in cols: + with new_engine.begin() as conn: + conn.execute(text("ALTER TABLE oboruds ADD COLUMN owner_id INTEGER")) + log("Добавлен столбец oboruds.owner_id") + except Exception as e: + log(f"Предупреждение: проверка/добавление owner_id не выполнена: {e}") + def migrate(): + ensure_schema() log("Запуск переноса данных из old_app.db → app.db") # Тип оборудования по умолчанию diff --git a/backend/models.py b/backend/models.py index de24b3f..a20c754 100644 --- a/backend/models.py +++ b/backend/models.py @@ -7,6 +7,15 @@ import datetime Base = declarative_base() +class User(Base): + __tablename__ = 'users' + + id = Column(Integer, primary_key=True) + username = Column(String(150), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + role = Column(String(50), nullable=False, default='viewer') # 'admin' | 'editor' | 'viewer' + + @@ -28,6 +37,14 @@ class EquipmentType(Base): oboruds = relationship("Oboruds", back_populates="type") +class Owner(Base): + __tablename__ = 'owners' + + id = Column(Integer, primary_key=True) + name = Column(String, unique=True, nullable=False) + + oboruds = relationship("Oboruds", back_populates="owner") + class Oboruds(Base): __tablename__ = 'oboruds' @@ -45,6 +62,9 @@ class Oboruds(Base): type_id = Column(Integer, ForeignKey("equipment_types.id")) type = relationship("EquipmentType", back_populates="oboruds") + owner_id = Column(Integer, ForeignKey("owners.id")) + owner = relationship("Owner", back_populates="oboruds") + components = relationship("Component", back_populates="oborud") consumables = relationship("Consumable", back_populates="oborud") diff --git a/backend/routers/auditories.py b/backend/routers/auditories.py index 3e7c1c9..5840275 100644 --- a/backend/routers/auditories.py +++ b/backend/routers/auditories.py @@ -1,10 +1,11 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from .. import models, schemas, database +from ..security import require_roles auditories = APIRouter(prefix="/auditories", tags=["auditories"]) -@auditories.post("/", response_model=schemas.AuditoryRead) +@auditories.post("/", response_model=schemas.AuditoryRead, dependencies=[Depends(require_roles(["admin", "editor"]))]) def create_auditory(item: schemas.AuditoryCreate, db: Session = Depends(database.get_db)): obj = models.Auditory(**item.dict()) db.add(obj) diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..63fd503 --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,63 @@ +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from .. import schemas, database +from ..security import authenticate_user, create_access_token, get_password_hash, require_roles +from ..models import User + + +auth = APIRouter(prefix="/auth", tags=["auth"]) + + +@auth.post("/token", response_model=schemas.Token) +def login_for_access_token( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(database.get_db), +): + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password") + access_token_expires = timedelta(minutes=60) + access_token = create_access_token(data={"sub": user.username, "role": user.role}, expires_delta=access_token_expires) + return {"access_token": access_token, "token_type": "bearer"} + + +@auth.post("/users", response_model=schemas.UserRead, dependencies=[Depends(require_roles(["admin"]))]) +def create_user(item: schemas.UserCreate, db: Session = Depends(database.get_db)): + if db.query(User).filter(User.username == item.username).first(): + raise HTTPException(status_code=400, detail="Username already exists") + obj = User(username=item.username, password_hash=get_password_hash(item.password), role=item.role) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@auth.post("/users/admin", response_model=schemas.UserRead, dependencies=[Depends(require_roles(["admin"]))]) +def create_admin_user(item: schemas.UserCreate, db: Session = Depends(database.get_db)): + if db.query(User).filter(User.username == item.username).first(): + raise HTTPException(status_code=400, detail="Username already exists") + obj = User(username=item.username, password_hash=get_password_hash(item.password), role="admin") + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@auth.get("/users", response_model=list[schemas.UserRead], dependencies=[Depends(require_roles(["admin"]))]) +def list_users(db: Session = Depends(database.get_db)): + return db.query(User).all() + + +@auth.patch("/users/{user_id}/role", response_model=schemas.UserRead, dependencies=[Depends(require_roles(["admin"]))]) +def update_user_role(user_id: int, payload: schemas.UserRoleUpdate, db: Session = Depends(database.get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + user.role = payload.role + db.commit() + db.refresh(user) + return user diff --git a/backend/routers/components.py b/backend/routers/components.py index d8cb387..39fc332 100644 --- a/backend/routers/components.py +++ b/backend/routers/components.py @@ -1,11 +1,12 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from .. import models, schemas, database +from ..security import require_roles components = APIRouter(prefix="/components", tags=["components"]) -@components.post("/", response_model=schemas.ComponentRead) +@components.post("/", response_model=schemas.ComponentRead, dependencies=[Depends(require_roles(["admin", "editor"]))]) def create_component(item: schemas.ComponentCreate, db: Session = Depends(database.get_db)): obj = models.Component(**item.dict()) db.add(obj) diff --git a/backend/routers/equipment_types.py b/backend/routers/equipment_types.py index 868e2f3..195eb81 100644 --- a/backend/routers/equipment_types.py +++ b/backend/routers/equipment_types.py @@ -1,10 +1,11 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from .. import models, schemas, database +from ..security import require_roles equipment_types = APIRouter(prefix="/equipment-types", tags=["equipment_types"]) -@equipment_types.post("/", response_model=schemas.EquipmentTypeRead) +@equipment_types.post("/", response_model=schemas.EquipmentTypeRead, dependencies=[Depends(require_roles(["admin", "editor"]))]) def create_equipment_type(item: schemas.EquipmentTypeCreate, db: Session = Depends(database.get_db)): obj = models.EquipmentType(**item.dict()) db.add(obj) diff --git a/backend/routers/oboruds.py b/backend/routers/oboruds.py index a7d5285..951e888 100644 --- a/backend/routers/oboruds.py +++ b/backend/routers/oboruds.py @@ -2,10 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException from typing import Optional from sqlalchemy.orm import Session from .. import models, schemas, database +from ..security import require_roles oboruds = APIRouter(prefix="/oboruds", tags=["oboruds"]) -@oboruds.post("/", response_model=schemas.OborudRead) +@oboruds.post("/", response_model=schemas.OborudRead, dependencies=[Depends(require_roles(["admin", "editor"]))]) def create_oborud(item: schemas.OborudCreate, db: Session = Depends(database.get_db)): obj = models.Oboruds(**item.dict()) db.add(obj) @@ -26,3 +27,16 @@ def get_oborud(oborud_id: int, db: Session = Depends(database.get_db)): if not obj: raise HTTPException(status_code=404, detail="Oborud not found") return obj + + +@oboruds.patch("/{oborud_id}", response_model=schemas.OborudRead, dependencies=[Depends(require_roles(["admin", "editor"]))]) +def update_oborud(oborud_id: int, payload: schemas.OborudUpdate, db: Session = Depends(database.get_db)): + obj = db.query(models.Oboruds).filter(models.Oboruds.id == oborud_id).first() + if not obj: + raise HTTPException(status_code=404, detail="Oborud not found") + data = payload.dict(exclude_unset=True) + for k, v in data.items(): + setattr(obj, k, v) + db.commit() + db.refresh(obj) + return obj diff --git a/backend/routers/owners.py b/backend/routers/owners.py new file mode 100644 index 0000000..b1bc952 --- /dev/null +++ b/backend/routers/owners.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from .. import models, schemas, database +from ..security import require_roles + + +owners = APIRouter(prefix="/owners", tags=["owners"]) + + +@owners.post("/", response_model=schemas.OwnerRead, dependencies=[Depends(require_roles(["admin", "editor"]))]) +def create_owner(item: schemas.OwnerCreate, db: Session = Depends(database.get_db)): + obj = models.Owner(**item.dict()) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@owners.get("/", response_model=list[schemas.OwnerRead]) +def list_owners(db: Session = Depends(database.get_db)): + return db.query(models.Owner).all() + diff --git a/backend/routers/rashodniki.py b/backend/routers/rashodniki.py index e8bfe28..7ef53f9 100644 --- a/backend/routers/rashodniki.py +++ b/backend/routers/rashodniki.py @@ -1,10 +1,11 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from .. import models, schemas, database +from ..security import require_roles consumables = APIRouter(prefix="/consumables", tags=["consumables"]) -@consumables.post("/", response_model=schemas.ConsumableRead) +@consumables.post("/", response_model=schemas.ConsumableRead, dependencies=[Depends(require_roles(["admin", "editor"]))]) def create_consumable(item: schemas.ConsumableCreate, db: Session = Depends(database.get_db)): obj = models.Consumable(**item.dict()) db.add(obj) diff --git a/backend/routers/zametki.py b/backend/routers/zametki.py index 00e1730..6dd9ab5 100644 --- a/backend/routers/zametki.py +++ b/backend/routers/zametki.py @@ -1,10 +1,11 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from .. import models, schemas, database +from ..security import require_roles zametki = APIRouter(prefix="/zametki", tags=["zametki"]) -@zametki.post("/", response_model=schemas.ZametkaRead) +@zametki.post("/", response_model=schemas.ZametkaRead, dependencies=[Depends(require_roles(["admin", "editor"]))]) def create_zametka(item: schemas.ZametkaCreate, db: Session = Depends(database.get_db)): obj = models.Zametki(**item.dict()) db.add(obj) diff --git a/backend/schemas.py b/backend/schemas.py index 6b476ec..c3fdc7c 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,7 +1,7 @@ # backend/schemas.py from pydantic import BaseModel -from typing import Optional, List +from typing import Optional, List, Literal from datetime import datetime @@ -49,6 +49,20 @@ class ConsumableRead(ConsumableBase): orm_mode = True +# === Owner === +class OwnerBase(BaseModel): + name: str + +class OwnerCreate(OwnerBase): + pass + +class OwnerRead(OwnerBase): + id: int + + class Config: + orm_mode = True + + # === Oborud === class OborudBase(BaseModel): invNumber: Optional[int] @@ -58,13 +72,25 @@ class OborudBase(BaseModel): kolichestvo: Optional[int] = None aud_id: int type_id: int + owner_id: Optional[int] = None class OborudCreate(OborudBase): pass +class OborudUpdate(BaseModel): + invNumber: Optional[int] = None + nazvanie: Optional[str] = None + raspologenie: Optional[str] = None + numberppasu: Optional[str] = None + kolichestvo: Optional[int] = None + aud_id: Optional[int] = None + type_id: Optional[int] = None + owner_id: Optional[int] = None + class OborudRead(OborudBase): id: int type: EquipmentTypeRead + owner: Optional[OwnerRead] = None components: List[ComponentRead] = [] consumables: List[ConsumableRead] = [] @@ -100,3 +126,33 @@ class ZametkaRead(ZametkaBase): class Config: orm_mode = True + + +# === Auth/User === +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + +class TokenData(BaseModel): + username: Optional[str] = None + role: Optional[str] = None + +Role = Literal["admin", "editor", "viewer"] + + +class UserBase(BaseModel): + username: str + role: Role = "viewer" + +class UserCreate(UserBase): + password: str + +class UserRead(UserBase): + id: int + + class Config: + orm_mode = True + + +class UserRoleUpdate(BaseModel): + role: Role diff --git a/backend/security.py b/backend/security.py new file mode 100644 index 0000000..4b154cb --- /dev/null +++ b/backend/security.py @@ -0,0 +1,79 @@ +import os +from datetime import datetime, timedelta, timezone +from typing import Optional, Callable, Iterable + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from . import models +from .database import get_db + + +SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")) + +# Use pbkdf2_sha256 to avoid external bcrypt backend issues +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") + + +def verify_password(plain_password: str, password_hash: str) -> bool: + return pwd_context.verify(plain_password, password_hash) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + +def get_user_by_username(db: Session, username: str) -> Optional[models.User]: + return db.query(models.User).filter(models.User.username == username).first() + + +def authenticate_user(db: Session, username: str, password: str) -> Optional[models.User]: + user = get_user_by_username(db, username) + if not user: + return None + if not verify_password(password, user.password_hash): + return None + return user + + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> models.User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + user = get_user_by_username(db, username=username) + if user is None: + raise credentials_exception + return user + + +def require_roles(allowed_roles: Iterable[str]) -> Callable[[models.User], models.User]: + allowed = set(allowed_roles) + + def _dependency(user: models.User = Depends(get_current_user)) -> models.User: + if user.role not in allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return user + + return _dependency diff --git a/frontend/app.js b/frontend/app.js index 34fb61f..43966c4 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -3,6 +3,7 @@ const { createApp } = Vue; const api = { auds: "/auditories/", oboruds: (audId) => `/oboruds/?aud_id=${encodeURIComponent(audId)}`, + owners: "/owners/", }; async function fetchJSON(url) { @@ -20,9 +21,52 @@ createApp({ oboruds: [], status: '', error: '', + // auth/user management + token: '', + role: '', + users: [], + newAdminUsername: '', + newAdminPassword: '', + newAudName: '', + owners: [], + newOwnerName: '', }; }, + computed: { + isAuth() { return !!this.token; }, + isAdmin() { return this.role === 'admin'; }, + isEditor() { return this.role === 'editor'; }, + canEdit() { return this.isAdmin || this.isEditor; }, + }, methods: { + logout() { + try { + localStorage.removeItem('access_token'); + localStorage.removeItem('role'); + } catch {} + this.token = ''; + this.role = ''; + this.users = []; + this.status = ''; + this.error = ''; + this.view = 'byAud'; + // опционально: редирект на страницу логина + // window.location.href = '/login'; + }, + authHeaders() { + const h = {}; + if (this.token) h['Authorization'] = `Bearer ${this.token}`; + return h; + }, + async fetchAuth(url, options = {}) { + const opt = { ...options, headers: { ...(options.headers||{}), ...this.authHeaders() } }; + const res = await fetch(url, opt); + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text}`); + } + return res.json(); + }, async loadAuditories() { this.status = 'Загрузка аудиторий…'; this.error = ''; @@ -45,6 +89,8 @@ createApp({ this.error = ''; try { this.oboruds = await fetchJSON(api.oboruds(this.selectedAudId)); + // init selected owner helper field + this.oboruds.forEach(o => { o.selectedOwnerId = o.owner?.id || ''; }); this.status = ''; } catch (e) { console.error(e); @@ -52,8 +98,124 @@ createApp({ this.status = ''; } }, + async saveOwner(item) { + try { + this.status = 'Сохранение владельца…'; + await this.fetchAuth(`/oboruds/${item.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ owner_id: item.selectedOwnerId || null }), + }); + this.status = 'Сохранено'; + } catch (e) { + console.error(e); + this.error = 'Не удалось сохранить владельца'; + this.status = ''; + } + }, + async loadOwners() { + try { + this.status = this.status || 'Загрузка владельцев…'; + const data = await fetchJSON(api.owners); + this.owners = data; + this.status = ''; + } catch (e) { + console.error(e); + this.error = 'Не удалось загрузить владельцев'; + this.status = ''; + } + }, + async createOwner() { + if (!this.newOwnerName) { + this.status = 'Укажите имя владельца'; + return; + } + try { + this.status = 'Добавление владельца…'; + await this.fetchAuth(api.owners, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: this.newOwnerName }), + }); + this.newOwnerName = ''; + await this.loadOwners(); + // refresh table owner names if visible + if (this.selectedAudId) await this.loadOboruds(); + this.status = 'Владелец добавлен'; + } catch (e) { + console.error(e); + this.error = 'Не удалось добавить владельца'; + this.status = ''; + } + }, + async loadUsers() { + try { + this.status = 'Загрузка пользователей…'; + this.error = ''; + this.users = await this.fetchAuth('/auth/users'); + this.status = ''; + } catch (e) { + console.error(e); + this.error = 'Не удалось загрузить пользователей'; + this.status = ''; + } + }, + async createAdmin() { + if (!this.newAdminUsername || !this.newAdminPassword) { + this.status = 'Укажите логин и пароль'; + return; + } + try { + this.status = 'Создание администратора…'; + this.error = ''; + await this.fetchAuth('/auth/users/admin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: this.newAdminUsername, password: this.newAdminPassword }), + }); + this.newAdminUsername = ''; + this.newAdminPassword = ''; + await this.loadUsers(); + this.status = 'Администратор создан'; + } catch (e) { + console.error(e); + this.error = 'Не удалось создать администратора'; + this.status = ''; + } + }, + async createAuditory() { + if (!this.newAudName) { + this.status = 'Укажите название аудитории'; + return; + } + try { + this.status = 'Добавление аудитории…'; + this.error = ''; + await this.fetchAuth('/auditories/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ audnazvanie: this.newAudName }), + }); + this.newAudName = ''; + await this.loadAuditories(); + this.status = 'Аудитория добавлена'; + } catch (e) { + console.error(e); + this.error = 'Не удалось добавить аудиторию'; + this.status = ''; + } + } }, mounted() { + // read auth from localStorage + try { + this.token = localStorage.getItem('access_token') || ''; + this.role = localStorage.getItem('role') || ''; + } catch {} this.loadAuditories(); + this.loadOwners(); + if (this.isAdmin) { + this.loadUsers(); + } } }).mount('#app'); diff --git a/frontend/index.html b/frontend/index.html index f09791f..5a8cd58 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,24 +15,31 @@