feat: categories, runtime nav, and UX updates
This commit is contained in:
+176
-7
@@ -1,5 +1,6 @@
|
||||
import datetime as dt
|
||||
import enum
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -68,7 +69,7 @@ engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
app = FastAPI(title="МОНТ - инфра полигон")
|
||||
app = FastAPI(title="МОНТ - инфрастуктурный полигон")
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -137,6 +138,25 @@ class Service(Base):
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
||||
|
||||
|
||||
class ServiceCategory(Base):
|
||||
__tablename__ = "service_categories"
|
||||
__table_args__ = (UniqueConstraint("service_id", "category_id", name="uq_service_category"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE"), index=True)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
||||
|
||||
|
||||
class UserServiceAccess(Base):
|
||||
__tablename__ = "user_service_access"
|
||||
__table_args__ = (UniqueConstraint("user_id", "service_id", name="uq_user_service"),)
|
||||
@@ -217,6 +237,26 @@ def parse_rdp_target(target: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def set_service_categories(db: Session, service_id: int, category_ids: list[int]) -> None:
|
||||
normalized = sorted({int(x) for x in (category_ids or [])})
|
||||
if normalized:
|
||||
existing_ids = set(db.scalars(select(Category.id).where(Category.id.in_(normalized))).all())
|
||||
missing = sorted(set(normalized) - existing_ids)
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown category ids: {missing}")
|
||||
|
||||
existing_links = db.scalars(select(ServiceCategory).where(ServiceCategory.service_id == service_id)).all()
|
||||
current = {row.category_id: row for row in existing_links}
|
||||
wanted = set(normalized)
|
||||
|
||||
for cat_id in wanted:
|
||||
if cat_id not in current:
|
||||
db.add(ServiceCategory(service_id=service_id, category_id=cat_id))
|
||||
for cat_id, row in current.items():
|
||||
if cat_id not in wanted:
|
||||
db.delete(row)
|
||||
|
||||
|
||||
def service_uses_universal_pool(service: Service) -> bool:
|
||||
return UNIVERSAL_POOL_SIZE > 0 and service.type == ServiceType.RDP
|
||||
|
||||
@@ -886,6 +926,33 @@ def ensure_schema_compatibility() -> None:
|
||||
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL UNIQUE,
|
||||
slug VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS service_categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
service_id INT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
category_id INT NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (service_id, category_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_service_categories_service_id ON service_categories(service_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_service_categories_category_id ON service_categories(category_id)"))
|
||||
# Handle installs where service type is VARCHAR + CHECK.
|
||||
conn.execute(
|
||||
text(
|
||||
@@ -1171,8 +1238,13 @@ def bootstrap_admin():
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_schema_compatibility()
|
||||
# Multiple uvicorn workers run startup in parallel. Serialize schema bootstrap
|
||||
# to avoid DDL races on first run and during schema extension.
|
||||
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_schema_compatibility()
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
ensure_icons_dir()
|
||||
bootstrap_admin()
|
||||
db = SessionLocal()
|
||||
@@ -1199,7 +1271,7 @@ def startup_event():
|
||||
def index(request: Request, user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
if not user:
|
||||
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||||
response = templates.TemplateResponse("login.html", {"request": request, "csrf_token": csrf})
|
||||
response = templates.TemplateResponse("login.html", {"request": request, "csrf_token": csrf, "login_error": ""})
|
||||
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/")
|
||||
return response
|
||||
|
||||
@@ -1213,18 +1285,67 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
|
||||
)
|
||||
.order_by(Service.name)
|
||||
).all()
|
||||
|
||||
service_categories = {svc.id: [] for svc in services}
|
||||
categories = []
|
||||
if services:
|
||||
service_ids = [svc.id for svc in services]
|
||||
rows = db.execute(
|
||||
select(ServiceCategory.service_id, Category.id, Category.name, Category.slug)
|
||||
.join(Category, Category.id == ServiceCategory.category_id)
|
||||
.where(ServiceCategory.service_id.in_(service_ids))
|
||||
.order_by(Category.name)
|
||||
).all()
|
||||
category_map = {}
|
||||
for service_id, category_id, category_name, category_slug in rows:
|
||||
service_categories.setdefault(service_id, []).append(
|
||||
{
|
||||
"id": category_id,
|
||||
"name": category_name,
|
||||
"slug": category_slug,
|
||||
}
|
||||
)
|
||||
if category_id not in category_map:
|
||||
category_map[category_id] = {"id": category_id, "name": category_name, "slug": category_slug}
|
||||
categories = sorted(category_map.values(), key=lambda x: x["name"].lower())
|
||||
|
||||
selected_category_slug = (request.query_params.get("category") or "").strip().lower()
|
||||
if selected_category_slug:
|
||||
services = [
|
||||
svc for svc in services
|
||||
if any(cat["slug"] == selected_category_slug for cat in service_categories.get(svc.id, []))
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
{"request": request, "user": user, "services": services, "csrf_token": request.cookies.get(CSRF_COOKIE, "")},
|
||||
{
|
||||
"request": request,
|
||||
"user": user,
|
||||
"services": services,
|
||||
"categories": categories,
|
||||
"selected_category_slug": selected_category_slug,
|
||||
"service_categories": service_categories,
|
||||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
users = db.scalars(select(User).order_by(User.id)).all()
|
||||
categories = db.scalars(select(Category).order_by(Category.name)).all()
|
||||
services = db.scalars(select(Service).where(Service.type.in_([ServiceType.WEB, ServiceType.RDP])).order_by(Service.id)).all()
|
||||
web_services = [s for s in services if s.type == ServiceType.WEB]
|
||||
rdp_services = [s for s in services if s.type == ServiceType.RDP]
|
||||
service_category_map = {s.id: [] for s in services}
|
||||
if services:
|
||||
service_rows = db.execute(
|
||||
select(ServiceCategory.service_id, ServiceCategory.category_id).where(
|
||||
ServiceCategory.service_id.in_([s.id for s in services])
|
||||
)
|
||||
).all()
|
||||
for service_id, category_id in service_rows:
|
||||
service_category_map.setdefault(service_id, []).append(category_id)
|
||||
acl_rows = db.scalars(select(UserServiceAccess)).all()
|
||||
acl = {}
|
||||
for row in acl_rows:
|
||||
@@ -1284,6 +1405,8 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
||||
"web_services": web_services,
|
||||
"rdp_services": rdp_services,
|
||||
"services": services,
|
||||
"categories": categories,
|
||||
"service_category_map": service_category_map,
|
||||
"acl": acl,
|
||||
"pool_status": pool_status,
|
||||
"service_health": service_health,
|
||||
@@ -1310,8 +1433,21 @@ def login(
|
||||
raise HTTPException(status_code=403, detail="CSRF failed")
|
||||
|
||||
user = db.scalar(select(User).where(User.username == username))
|
||||
if not user or not verify_password(password, user.password_hash) or not user_is_valid(user):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials or expired user")
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if not user_is_valid(user):
|
||||
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||||
response = templates.TemplateResponse(
|
||||
"login.html",
|
||||
{
|
||||
"request": request,
|
||||
"csrf_token": csrf,
|
||||
"login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру",
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/")
|
||||
return response
|
||||
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
issue_auth_cookie(response, user)
|
||||
@@ -1664,6 +1800,8 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
|
||||
warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
|
||||
)
|
||||
db.add(service)
|
||||
db.flush()
|
||||
set_service_categories(db, service.id, payload.get("category_ids", []))
|
||||
db.commit()
|
||||
ensure_warm_pool(service)
|
||||
return {"id": service.id}
|
||||
@@ -1732,6 +1870,8 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep
|
||||
parse_rdp_target(service.target)
|
||||
if "warm_pool_size" in payload:
|
||||
service.warm_pool_size = max(0, int(payload["warm_pool_size"]))
|
||||
if "category_ids" in payload:
|
||||
set_service_categories(db, service.id, payload.get("category_ids", []))
|
||||
db.commit()
|
||||
ensure_warm_pool(service)
|
||||
open_warm_web_url(service, service.target)
|
||||
@@ -1767,6 +1907,35 @@ def prewarm_now(service_id: int, request: Request, _: User = Depends(require_adm
|
||||
return {"ok": True, "pool": get_pool_status_for_service(service)}
|
||||
|
||||
|
||||
@app.post("/api/admin/categories")
|
||||
def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
validate_csrf(request)
|
||||
name = (payload.get("name") or "").strip()
|
||||
slug = (payload.get("slug") or "").strip().lower().replace(" ", "-")
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Category name is required")
|
||||
if not slug:
|
||||
raise HTTPException(status_code=400, detail="Category slug is required")
|
||||
exists = db.scalar(select(Category).where((Category.name == name) | (Category.slug == slug)))
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="Category already exists")
|
||||
category = Category(name=name, slug=slug)
|
||||
db.add(category)
|
||||
db.commit()
|
||||
return {"id": category.id}
|
||||
|
||||
|
||||
@app.delete("/api/admin/categories/{category_id}")
|
||||
def delete_category(category_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
validate_csrf(request)
|
||||
category = db.get(Category, category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
db.delete(category)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.put("/api/admin/web-pool-size")
|
||||
def update_web_pool_size(payload: dict, request: Request, _: User = Depends(require_admin)):
|
||||
validate_csrf(request)
|
||||
|
||||
Reference in New Issue
Block a user