Compare commits
7 Commits
dev_fix_ru
...
feature/fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e428e7f762 | ||
|
|
3f91dc91ec | ||
|
|
779c256e7b | ||
|
|
d686b26465 | ||
|
|
08e979ecb2 | ||
|
|
72f1d53051 | ||
|
|
f108e013c2 |
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,11 +1,21 @@
|
|||||||
*.csv
|
|
||||||
.vscode
|
.vscode
|
||||||
instance
|
|
||||||
venv/
|
|
||||||
123
|
|
||||||
*.csv
|
|
||||||
*.db
|
|
||||||
c*.txt
|
|
||||||
migrations
|
|
||||||
__pycache__
|
|
||||||
.idea
|
.idea
|
||||||
|
venv/
|
||||||
|
instance/
|
||||||
|
|
||||||
|
# Ignore Python bytecode caches everywhere
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
|
||||||
|
# Migrations and DB files
|
||||||
|
migrations/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Data and temp files
|
||||||
|
*.csv
|
||||||
|
c*.txt
|
||||||
|
|
||||||
|
# Legacy specific ignores (if present)
|
||||||
|
backend/venv
|
||||||
|
backend/__pycache__
|
||||||
|
backend/routeres/__pycache__
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
from backend.models import Base
|
from sqlalchemy.orm import sessionmaker
|
||||||
from backend.database import engine
|
|
||||||
|
|
||||||
Base.metadata.drop_all(bind=engine) # Опционально, если вдруг есть
|
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)
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# backend/main.py
|
# backend/main.py
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from backend.routers.equipment_types import equipment_types
|
from backend.routers.equipment_types import equipment_types
|
||||||
@@ -9,6 +10,8 @@ from backend.routers.oboruds import oboruds
|
|||||||
from backend.routers.components import components
|
from backend.routers.components import components
|
||||||
from backend.routers.rashodniki import consumables
|
from backend.routers.rashodniki import consumables
|
||||||
from backend.routers.zametki import zametki
|
from backend.routers.zametki import zametki
|
||||||
|
from backend.routers.auth import auth
|
||||||
|
from backend.routers.owners import owners
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -29,9 +32,17 @@ def ping():
|
|||||||
return {"message": "pong"}
|
return {"message": "pong"}
|
||||||
|
|
||||||
|
|
||||||
|
# Serve static assets and frontend
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
app.mount("/app", StaticFiles(directory="frontend", html=True), name="frontend")
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return RedirectResponse(url="/docs")
|
return RedirectResponse(url="/app/")
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
def login_page():
|
||||||
|
return RedirectResponse(url="/app/login.html")
|
||||||
|
|
||||||
|
|
||||||
# Подключение роутов
|
# Подключение роутов
|
||||||
@@ -41,3 +52,5 @@ app.include_router(oboruds)
|
|||||||
app.include_router(components)
|
app.include_router(components)
|
||||||
app.include_router(consumables)
|
app.include_router(consumables)
|
||||||
app.include_router(zametki)
|
app.include_router(zametki)
|
||||||
|
app.include_router(auth)
|
||||||
|
app.include_router(owners)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import sys
|
import sys
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text, inspect
|
||||||
from sqlalchemy.orm import sessionmaker
|
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 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})
|
old_engine = create_engine(OLD_DB_URL, connect_args={"check_same_thread": False})
|
||||||
OldSession = sessionmaker(bind=old_engine)
|
OldSession = sessionmaker(bind=old_engine)
|
||||||
old_db = OldSession()
|
old_db = OldSession()
|
||||||
@@ -14,7 +16,25 @@ new_db = NewSession()
|
|||||||
def log(msg: str):
|
def log(msg: str):
|
||||||
print(f"[INFO] {msg}", file=sys.stderr)
|
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():
|
def migrate():
|
||||||
|
ensure_schema()
|
||||||
log("Запуск переноса данных из old_app.db → app.db")
|
log("Запуск переноса данных из old_app.db → app.db")
|
||||||
|
|
||||||
# Тип оборудования по умолчанию
|
# Тип оборудования по умолчанию
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import datetime
|
|||||||
Base = declarative_base()
|
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")
|
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):
|
class Oboruds(Base):
|
||||||
__tablename__ = 'oboruds'
|
__tablename__ = 'oboruds'
|
||||||
@@ -45,6 +62,9 @@ class Oboruds(Base):
|
|||||||
type_id = Column(Integer, ForeignKey("equipment_types.id"))
|
type_id = Column(Integer, ForeignKey("equipment_types.id"))
|
||||||
type = relationship("EquipmentType", back_populates="oboruds")
|
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")
|
components = relationship("Component", back_populates="oborud")
|
||||||
consumables = relationship("Consumable", back_populates="oborud")
|
consumables = relationship("Consumable", back_populates="oborud")
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .. import models, schemas, database
|
from .. import models, schemas, database
|
||||||
|
from ..security import require_roles
|
||||||
|
|
||||||
auditories = APIRouter(prefix="/auditories", tags=["auditories"])
|
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)):
|
def create_auditory(item: schemas.AuditoryCreate, db: Session = Depends(database.get_db)):
|
||||||
obj = models.Auditory(**item.dict())
|
obj = models.Auditory(**item.dict())
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
|
|||||||
63
backend/routers/auth.py
Normal file
63
backend/routers/auth.py
Normal file
@@ -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
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .. import models, schemas, database
|
from .. import models, schemas, database
|
||||||
|
from ..security import require_roles
|
||||||
|
|
||||||
|
|
||||||
components = APIRouter(prefix="/components", tags=["components"])
|
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)):
|
def create_component(item: schemas.ComponentCreate, db: Session = Depends(database.get_db)):
|
||||||
obj = models.Component(**item.dict())
|
obj = models.Component(**item.dict())
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .. import models, schemas, database
|
from .. import models, schemas, database
|
||||||
|
from ..security import require_roles
|
||||||
|
|
||||||
equipment_types = APIRouter(prefix="/equipment-types", tags=["equipment_types"])
|
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)):
|
def create_equipment_type(item: schemas.EquipmentTypeCreate, db: Session = Depends(database.get_db)):
|
||||||
obj = models.EquipmentType(**item.dict())
|
obj = models.EquipmentType(**item.dict())
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .. import models, schemas, database
|
from .. import models, schemas, database
|
||||||
|
from ..security import require_roles
|
||||||
|
|
||||||
oboruds = APIRouter(prefix="/oboruds", tags=["oboruds"])
|
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)):
|
def create_oborud(item: schemas.OborudCreate, db: Session = Depends(database.get_db)):
|
||||||
obj = models.Oboruds(**item.dict())
|
obj = models.Oboruds(**item.dict())
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
@@ -26,3 +27,16 @@ def get_oborud(oborud_id: int, db: Session = Depends(database.get_db)):
|
|||||||
if not obj:
|
if not obj:
|
||||||
raise HTTPException(status_code=404, detail="Oborud not found")
|
raise HTTPException(status_code=404, detail="Oborud not found")
|
||||||
return obj
|
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
|
||||||
|
|||||||
22
backend/routers/owners.py
Normal file
22
backend/routers/owners.py
Normal file
@@ -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()
|
||||||
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .. import models, schemas, database
|
from .. import models, schemas, database
|
||||||
|
from ..security import require_roles
|
||||||
|
|
||||||
consumables = APIRouter(prefix="/consumables", tags=["consumables"])
|
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)):
|
def create_consumable(item: schemas.ConsumableCreate, db: Session = Depends(database.get_db)):
|
||||||
obj = models.Consumable(**item.dict())
|
obj = models.Consumable(**item.dict())
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .. import models, schemas, database
|
from .. import models, schemas, database
|
||||||
|
from ..security import require_roles
|
||||||
|
|
||||||
zametki = APIRouter(prefix="/zametki", tags=["zametki"])
|
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)):
|
def create_zametka(item: schemas.ZametkaCreate, db: Session = Depends(database.get_db)):
|
||||||
obj = models.Zametki(**item.dict())
|
obj = models.Zametki(**item.dict())
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# backend/schemas.py
|
# backend/schemas.py
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Literal
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +49,20 @@ class ConsumableRead(ConsumableBase):
|
|||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
# === Owner ===
|
||||||
|
class OwnerBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class OwnerCreate(OwnerBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class OwnerRead(OwnerBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
# === Oborud ===
|
# === Oborud ===
|
||||||
class OborudBase(BaseModel):
|
class OborudBase(BaseModel):
|
||||||
invNumber: Optional[int]
|
invNumber: Optional[int]
|
||||||
@@ -58,13 +72,25 @@ class OborudBase(BaseModel):
|
|||||||
kolichestvo: Optional[int] = None
|
kolichestvo: Optional[int] = None
|
||||||
aud_id: int
|
aud_id: int
|
||||||
type_id: int
|
type_id: int
|
||||||
|
owner_id: Optional[int] = None
|
||||||
|
|
||||||
class OborudCreate(OborudBase):
|
class OborudCreate(OborudBase):
|
||||||
pass
|
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):
|
class OborudRead(OborudBase):
|
||||||
id: int
|
id: int
|
||||||
type: EquipmentTypeRead
|
type: EquipmentTypeRead
|
||||||
|
owner: Optional[OwnerRead] = None
|
||||||
components: List[ComponentRead] = []
|
components: List[ComponentRead] = []
|
||||||
consumables: List[ConsumableRead] = []
|
consumables: List[ConsumableRead] = []
|
||||||
|
|
||||||
@@ -100,3 +126,33 @@ class ZametkaRead(ZametkaBase):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
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
|
||||||
|
|||||||
79
backend/security.py
Normal file
79
backend/security.py
Normal file
@@ -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
|
||||||
221
frontend/app.js
Normal file
221
frontend/app.js
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
const { createApp } = Vue;
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
auds: "/auditories/",
|
||||||
|
oboruds: (audId) => `/oboruds/?aud_id=${encodeURIComponent(audId)}`,
|
||||||
|
owners: "/owners/",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
view: 'byAud',
|
||||||
|
auditories: [],
|
||||||
|
selectedAudId: '',
|
||||||
|
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 = '';
|
||||||
|
try {
|
||||||
|
this.auditories = await fetchJSON(api.auds);
|
||||||
|
this.status = '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
this.error = 'Не удалось загрузить аудитории';
|
||||||
|
this.status = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadOboruds() {
|
||||||
|
if (!this.selectedAudId) {
|
||||||
|
this.error = '';
|
||||||
|
this.status = 'Выберите аудиторию';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.status = 'Загрузка оборудования…';
|
||||||
|
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);
|
||||||
|
this.error = 'Не удалось загрузить оборудование';
|
||||||
|
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');
|
||||||
229
frontend/index.html
Normal file
229
frontend/index.html
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>АСУ Инвентаризация</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>
|
||||||
|
<a href="/app/">АСУ Инвентаризация</a>
|
||||||
|
</h1>
|
||||||
|
<h2>Учет оборудования. Демоверсия</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<div class="row no-print">
|
||||||
|
<nav class="no-print navbar navbar-expand-lg navbar-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/app/">Главная</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#" @click.prevent="view='byAud'">По аудитории</a></li>
|
||||||
|
<li class="nav-item" v-if="isAdmin"><a class="nav-link" href="#" @click.prevent="view='users'">Пользователи</a></li>
|
||||||
|
<li class="nav-item" v-if="isAdmin"><a class="nav-link" href="#" @click.prevent="view='audManage'">Аудитории</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/docs" target="_blank">API Docs</a></li>
|
||||||
|
<li class="nav-item" v-if="canEdit"><a class="nav-link" href="#" @click.prevent="view='owners'">Владельцы</a></li>
|
||||||
|
</ul>
|
||||||
|
<span class="navbar-text ms-auto" v-if="isAuth">Роль: {{ role }}</span>
|
||||||
|
<a class="btn btn-outline-primary ms-2" v-if="!isAuth" href="/login">Войти</a>
|
||||||
|
<button class="btn btn-outline-secondary ms-2" v-else @click.prevent="logout">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div v-if="view==='byAud'" class="row">
|
||||||
|
<div class="card col-md-10 col-10">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">Оборудование по аудитории</h3>
|
||||||
|
<div class="mb-2 d-flex align-items-center gap-2">
|
||||||
|
<label for="aud-select" class="me-2">Аудитория:</label>
|
||||||
|
<select id="aud-select" class="form-select w-auto" v-model="selectedAudId">
|
||||||
|
<option value="">— выберите аудиторию —</option>
|
||||||
|
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" @click="loadOboruds">Показать</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" :class="{error: !!error}">{{ status }}</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table datatable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Инв. номер</th>
|
||||||
|
<th scope="col">Название</th>
|
||||||
|
<th scope="col">Расположение</th>
|
||||||
|
<th scope="col">Кол-во</th>
|
||||||
|
<th scope="col">Тип</th>
|
||||||
|
<th scope="col">Владелец</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="it in oboruds" :key="it.id">
|
||||||
|
<td>{{ it.id }}</td>
|
||||||
|
<td class="inv">{{ it.invNumber ?? '' }}</td>
|
||||||
|
<td>{{ it.nazvanie ?? '' }}</td>
|
||||||
|
<td class="rasp">{{ it.raspologenie ?? '' }}</td>
|
||||||
|
<td>{{ it.kolichestvo ?? '' }}</td>
|
||||||
|
<td>{{ it.type?.name ?? '' }}</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="canEdit">
|
||||||
|
<select class="form-select form-select-sm d-inline w-auto" v-model="it.selectedOwnerId">
|
||||||
|
<option value="">— нет —</option>
|
||||||
|
<option v-for="ow in owners" :key="ow.id" :value="ow.id">{{ ow.name }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-outline-primary ms-2" @click="saveOwner(it)">Сохранить</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ it.owner?.name ?? '' }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="view==='users'" class="row">
|
||||||
|
<div class="card col-md-10 col-10">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">Администрирование пользователей</h3>
|
||||||
|
<div v-if="role!=='admin'" class="alert alert-warning">Недостаточно прав. Войдите как администратор.</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<h5 class="mt-2">Создать пользователя-админа</h5>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Логин</label>
|
||||||
|
<input class="form-control" v-model="newAdminUsername" placeholder="username" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Пароль</label>
|
||||||
|
<input type="password" class="form-control" v-model="newAdminPassword" placeholder="password" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-success" @click="createAdmin">Создать админа</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
|
||||||
|
|
||||||
|
<h5 class="mt-4">Пользователи</h5>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table datatable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Логин</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.id">
|
||||||
|
<td>{{ u.id }}</td>
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.role }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="view==='audManage'" class="row">
|
||||||
|
<div class="card col-md-10 col-10">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">Управление аудиториями</h3>
|
||||||
|
<div v-if="role!=='admin'" class="alert alert-warning">Недостаточно прав. Войдите как администратор.</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<h5 class="mt-2">Добавить аудиторию</h5>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Название аудитории</label>
|
||||||
|
<input class="form-control" v-model="newAudName" placeholder="например, 519" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-success" @click="createAuditory">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
|
||||||
|
|
||||||
|
<h5 class="mt-4">Список аудиторий</h5>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table datatable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="a in auditories" :key="a.id">
|
||||||
|
<td>{{ a.id }}</td>
|
||||||
|
<td>{{ a.audnazvanie }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="view==='owners'" class="row">
|
||||||
|
<div class="card col-md-10 col-10">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">Управление владельцами</h3>
|
||||||
|
<div v-if="!canEdit" class="alert alert-warning">Недостаточно прав. Войдите как администратор или редактор.</div>
|
||||||
|
<div v-else>
|
||||||
|
<h5 class="mt-2">Добавить владельца</h5>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Имя владельца</label>
|
||||||
|
<input class="form-control" v-model="newOwnerName" placeholder="например, Иванов И.И." />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-success" @click="createOwner">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
|
||||||
|
|
||||||
|
<h5 class="mt-4">Список владельцев</h5>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table datatable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="o in owners" :key="o.id">
|
||||||
|
<td>{{ o.id }}</td>
|
||||||
|
<td>{{ o.name }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="/app/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
frontend/login.html
Normal file
44
frontend/login.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Вход — АСУ Инвентаризация</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/index.css" />
|
||||||
|
<style>
|
||||||
|
.login-card { max-width: 420px; margin: 40px auto; }
|
||||||
|
.muted { color: #6c757d; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1><a href="/app/">АСУ Инвентаризация</a></h1>
|
||||||
|
<h2>Авторизация</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<div class="card login-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3">Вход</h5>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="mb-3 text-start">
|
||||||
|
<label for="username" class="form-label">Логин</label>
|
||||||
|
<input type="text" id="username" class="form-control" autocomplete="username" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-start">
|
||||||
|
<label for="password" class="form-label">Пароль</label>
|
||||||
|
<input type="password" id="password" class="form-control" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<div id="status" class="muted mb-2"></div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div class="muted mt-3">Демо: admin / admin (после инициализации БД)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app/login.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
58
frontend/login.js
Normal file
58
frontend/login.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
function setStatus(msg, type = "info") {
|
||||||
|
const el = document.getElementById("status");
|
||||||
|
el.textContent = msg || "";
|
||||||
|
el.style.color = type === 'error' ? '#b91c1c' : type === 'ok' ? '#16a34a' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRoleFromJWT(token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return payload.role || null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('username', username);
|
||||||
|
params.set('password', password);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`Ошибка входа (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const token = data.access_token;
|
||||||
|
if (!token) throw new Error('Токен не получен');
|
||||||
|
localStorage.setItem('access_token', token);
|
||||||
|
const role = decodeRoleFromJWT(token);
|
||||||
|
if (role) localStorage.setItem('role', role);
|
||||||
|
return { token, role };
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setStatus('Входим…');
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
try {
|
||||||
|
const { role } = await login(username, password);
|
||||||
|
setStatus(`Успешный вход${role ? ' (' + role + ')' : ''}`, 'ok');
|
||||||
|
// Небольшая задержка для визуального отклика
|
||||||
|
setTimeout(() => { window.location.href = '/app/'; }, 400);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setStatus(e.message || 'Ошибка авторизации', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
19
frontend/styles.css
Normal file
19
frontend/styles.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
:root { --bg: #0f172a; --fg: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --err: #ef4444; --warn: #f59e0b; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background: var(--bg); color: var(--fg); }
|
||||||
|
header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #1f2937; }
|
||||||
|
header h1 { margin: 0; font-size: 20px; }
|
||||||
|
header nav a { color: var(--accent); text-decoration: none; }
|
||||||
|
main { padding: 20px; max-width: 1000px; margin: 0 auto; }
|
||||||
|
.panel { background: #0b1220; border: 1px solid #1f2937; border-radius: 8px; padding: 16px; }
|
||||||
|
.controls { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||||
|
select, button { padding: 8px 10px; border-radius: 6px; border: 1px solid #1f2937; background: #0a0f1a; color: var(--fg); }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
.status { min-height: 20px; color: var(--muted); margin-bottom: 8px; }
|
||||||
|
.status.error { color: var(--err); }
|
||||||
|
.status.warn { color: var(--warn); }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border-bottom: 1px solid #1f2937; padding: 8px; text-align: left; }
|
||||||
|
th { color: var(--muted); font-weight: 600; }
|
||||||
|
|
||||||
28
requirements.txt
Normal file
28
requirements.txt
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# FastAPI backend
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.9.0
|
||||||
|
click==8.2.1
|
||||||
|
colorama==0.4.6
|
||||||
|
fastapi==0.116.1
|
||||||
|
greenlet==3.2.3
|
||||||
|
h11==0.16.0
|
||||||
|
idna==3.10
|
||||||
|
pydantic==2.11.7
|
||||||
|
pydantic_core==2.33.2
|
||||||
|
sniffio==1.3.1
|
||||||
|
SQLAlchemy==2.0.42
|
||||||
|
starlette==0.47.2
|
||||||
|
typing-inspection==0.4.1
|
||||||
|
typing_extensions==4.14.1
|
||||||
|
uvicorn==0.35.0
|
||||||
|
|
||||||
|
# Flask app
|
||||||
|
Flask==3.0.3
|
||||||
|
Flask-Migrate==4.0.7
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
waitress==3.0.2
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
python-jose==3.3.0
|
||||||
|
passlib==1.7.4
|
||||||
|
bcrypt==4.2.0
|
||||||
147
templates/all_OLD.html
Normal file
147
templates/all_OLD.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="modal fade" id="getmodal" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-body" id="textarea">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<a id="modal_invnom"> </a><a id="modal_matcenn"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
№ из ведомости
|
||||||
|
<input type="text" class="form-control" id ='modal_vednumber' placeholder="Номер из ведомости">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
Количество
|
||||||
|
<input type="text" class="form-control" id ='modal_kolvo' placeholder="Количество">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
Балансовый счёт
|
||||||
|
<input type="text" class="form-control" id ='modal_balance' placeholder="Балансовый счёт">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
Расположение
|
||||||
|
<input type="text" class="form-control" id ='modal_rapolog' placeholder="Введите расположение">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="modalclose">Закрыть</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="modalsavetodb" >Сохранить изменения</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Modal2 -->
|
||||||
|
<div class="modal fade" id="addmodal" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-body" id="textarea">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<a id="modal_invnom"> </a><a id="modal2_matcenn"></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
№ из ведомости
|
||||||
|
<input type="text" class="form-control" id ='modal2_vednumber' placeholder="Номер из ведомости">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
Инвентарный номер
|
||||||
|
<input type="text" class="form-control" id ='modal2_invnom' placeholder="Инвентарный номер">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
Название
|
||||||
|
<input type="text" class="form-control" id ='modal2_nazvanie' placeholder="Название">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
Количество
|
||||||
|
<input type="text" class="form-control" id ='modal2_kolvo' placeholder="Количество">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
Балансовый счёт
|
||||||
|
<input type="text" class="form-control" id ='modal2_balance' placeholder="Балансовый счёт">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
Расположение
|
||||||
|
<input type="text" class="form-control" id ='modal2_rapolog' placeholder="Введите расположение">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="modal2close">Закрыть</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="modal2savetodb" >Сохранить изменения</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<h3 id ='123' class=" no-print"> Все мат. ценности </h3>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row col-12">
|
||||||
|
<button class="button" id="printallbutton"> Печать </button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row col-12">
|
||||||
|
<button class="button" id="addoborud"> Добавить </button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="card col-md-11 table-responsive">
|
||||||
|
<table id="alldatatable" class="alldatable table pagebreak" >
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">№ <br>п/п <br>АСУ</th>
|
||||||
|
<th scope="col">№ п/п <br>вед</th>
|
||||||
|
<th scope="col">Инв. номер</th>
|
||||||
|
<th scope="col">Название</th>
|
||||||
|
<th scope="col">Кол-во</th>
|
||||||
|
<th scope="col">Счёт</th>
|
||||||
|
<th scope="col">Ауд - я</th>
|
||||||
|
<th scope="col">Расположение</th>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="{{url_for('static', filename='js/allmatc.js') }}"></script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user