8 Commits

Author SHA1 Message Date
Danamir
e428e7f762 add new ui 2025-11-10 11:28:49 +03:00
Danamir
3f91dc91ec feat(frontend): Vue (CDN) UI matching templates design; reuse /static CSS and Bootstrap; mount /static in FastAPI 2025-11-10 08:45:25 +03:00
Danamir
779c256e7b feat(frontend): mount static UI at /app with simple auditories/equipment browser 2025-11-10 08:40:46 +03:00
Danamir
d686b26465 update requirements 2025-11-10 08:37:23 +03:00
Danamir
08e979ecb2 uppdate 2025-11-10 08:35:07 +03:00
Danamir
72f1d53051 Resolve .gitignore merge conflict; consolidate ignore rules 2025-11-10 08:30:45 +03:00
Danamir
b1e0693131 fix run 2025-11-10 08:25:56 +03:00
Danamir
f108e013c2 fix branch 2025-08-04 10:23:40 +03:00
24 changed files with 1111 additions and 30 deletions

28
.gitignore vendored
View File

@@ -1,11 +1,21 @@
*.csv
.vscode
instance
venv/
123
*.csv
*.db
c*.txt
migrations
__pycache__
.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__

2
backend/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Backend package initializer."""

View File

@@ -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)

View File

@@ -1,5 +1,7 @@
# backend/main.py
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from starlette.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from backend.routers.equipment_types import equipment_types
@@ -8,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
@@ -28,10 +32,25 @@ def ping():
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("/")
def root():
return RedirectResponse(url="/app/")
@app.get("/login")
def login_page():
return RedirectResponse(url="/app/login.html")
# Подключение роутов
app.include_router(equipment_types)
app.include_router(auditories)
app.include_router(oboruds)
app.include_router(components)
app.include_router(consumables)
app.include_router(zametki)
app.include_router(zametki)
app.include_router(auth)
app.include_router(owners)

View File

@@ -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")
# Тип оборудования по умолчанию

View File

@@ -3,11 +3,18 @@
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship, declarative_base
import datetime
from flask_sqlalchemy import SQLAlchemy
Base = declarative_base()
db = SQLAlchemy()
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'
@@ -30,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'
@@ -47,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")

View File

@@ -0,0 +1,2 @@
"""Routers package initializer."""

View File

@@ -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)

63
backend/routers/auth.py Normal file
View 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

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,10 +1,12 @@
from fastapi import APIRouter, Depends
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)
@@ -13,12 +15,28 @@ def create_oborud(item: schemas.OborudCreate, db: Session = Depends(database.get
return obj
@oboruds.get("/", response_model=list[schemas.OborudRead])
def list_oboruds(db: Session = Depends(database.get_db)):
return db.query(models.Oboruds).all()
def list_oboruds(aud_id: Optional[int] = None, db: Session = Depends(database.get_db)):
query = db.query(models.Oboruds)
if aud_id is not None:
query = query.filter(models.Oboruds.aud_id == aud_id)
return query.all()
@oboruds.get("/{oborud_id}", response_model=schemas.OborudRead)
def get_oborud(oborud_id: int, 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")
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
View 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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

79
backend/security.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 %}