diff --git a/app/conftest.py b/app/conftest.py new file mode 100644 index 0000000..f85542d --- /dev/null +++ b/app/conftest.py @@ -0,0 +1,101 @@ +import os +import sys + +# SQLite in-memory for tests — no PostgreSQL needed +os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db") +os.environ.setdefault("SIGNING_KEY", "test-signing-key-32chars-padding!!") +os.environ.setdefault("ADMIN_USERNAME", "admin") +os.environ.setdefault("ADMIN_PASSWORD", "testpass123") +os.environ.setdefault("PUBLIC_HOST", "http://localhost") +os.environ.setdefault("ENABLE_STARTUP_MAINTENANCE", "0") +os.environ.setdefault("LOG_LEVEL", "ERROR") + +import pytest +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +# Patch docker before importing app modules +_docker_mock = MagicMock() +_docker_mock.containers.get.side_effect = Exception("no docker in tests") +_docker_mock.containers.list.return_value = [] +_docker_mock.containers.run.return_value = MagicMock(id="test-container-id", status="running", name="test") + +sys.modules.setdefault("docker", MagicMock()) + +with patch("docker.from_env", return_value=_docker_mock): + with patch("runtime.ensure_schema_compatibility", lambda: None): + from database import Base, get_db + import main as app_module + +engine = create_engine( + "sqlite:///./test.db", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(bind=engine) +Base.metadata.create_all(bind=engine) + +# Create admin user +from auth import hash_password +from models import User +with TestingSessionLocal() as db: + if not db.query(User).filter(User.username == "admin").first(): + import datetime as _dt + db.add(User( + username="admin", + password_hash=hash_password("testpass123"), + is_admin=True, + active=True, + expires_at=_dt.datetime(2099, 1, 1, tzinfo=_dt.timezone.utc), + )) + db.commit() + + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +app_module.app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(scope="session") +def client(): + with patch("runtime.docker_client", return_value=_docker_mock), \ + patch("runtime.ensure_schema_compatibility", lambda: None): + with TestClient(app_module.app, raise_server_exceptions=False, base_url="https://testserver") as c: + yield c + + +def _extract_csrf(client) -> str: + """GET / → берём CSRF из HTML и ставим куку вручную.""" + import re + r = client.get("/", follow_redirects=True) + assert r.status_code == 200 + m = re.search(r'name=["\']csrf_token["\'][^>]*value=["\']([^"\']+)["\']' + r'|value=["\']([^"\']+)["\'][^>]*name=["\']csrf_token["\']', r.text) + if not m: + m = re.search(r'csrf_token["\']?\s*[=:]\s*["\']([^"\']{10,})["\']', r.text) + assert m, f"csrf_token not found in HTML: {r.text[:500]}" + csrf = m.group(1) or m.group(2) + client.cookies.set("portal_csrf", csrf, domain="testserver") + return csrf + + +@pytest.fixture(scope="session") +def auth_client(client): + """Client with admin session cookie.""" + csrf = _extract_csrf(client) + r = client.post("/login", data={ + "username": "admin", + "password": "testpass123", + "csrf_token": csrf, + }, follow_redirects=True) + assert r.status_code == 200, f"login failed: {r.status_code} {r.text[:300]}" + return client diff --git a/app/static/style.css b/app/static/style.css index b2d28c5..aec24e7 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -742,7 +742,7 @@ button { 100% { transform: translate3d(-6%, 0, 0); } } -/* Effects disabled per request: no parallax, no animated clouds, no hover motion */ +/* ========== Dashboard refined light theme ========== */ .parallax-scene, .parallax-layer, .cloud-layer, @@ -753,64 +753,146 @@ button { animation: none !important; transform: none !important; } + +/* Richer background */ .dashboard-page { - background: linear-gradient(180deg, #a8d2ee 0%, #d8ecf9 100%) !important; -} -.dashboard-page .panel, -.dashboard-page .tile { - backdrop-filter: none !important; - background: rgba(255, 255, 255, 0.9) !important; - border: 1px solid rgba(198, 218, 235, 0.9) !important; + background: + radial-gradient(ellipse 80% 40% at 15% 0%, rgba(120,185,240,0.35) 0%, transparent 60%), + radial-gradient(ellipse 60% 50% at 90% 100%, rgba(90,160,230,0.22) 0%, transparent 60%), + linear-gradient(170deg, #deeffe 0%, #c8e4f8 45%, #b0d5f0 100%) !important; } + +/* Header — keep dark, refine */ .dashboard-page .header { - background: rgba(10, 25, 41, 0.92) !important; - backdrop-filter: blur(8px) !important; - border-bottom: 1px solid rgba(255,255,255,0.08) !important; -} -.dashboard-page .svc-credentials { - background: rgba(220, 238, 252, 0.9) !important; - border-color: rgba(180, 210, 235, 0.85) !important; + background: rgba(8, 20, 38, 0.94) !important; + backdrop-filter: blur(12px) !important; + border-bottom: 1px solid rgba(255,255,255,0.09) !important; + box-shadow: 0 1px 0 rgba(0,0,0,0.18) !important; } + +/* Top info panel */ .dashboard-page .panel { + backdrop-filter: none !important; + background: rgba(255, 255, 255, 0.82) !important; + border: 1px solid rgba(185, 215, 238, 0.75) !important; + box-shadow: 0 4px 24px rgba(15, 64, 103, 0.1) !important; width: 100%; min-width: 0; box-sizing: border-box; } -.tile, -.tile:hover, -.made-by, -.made-by:hover, + +/* Admin intro banner */ +.dashboard-page .admin-intro { + background: linear-gradient(135deg, #e8f5ff 0%, #d8ecfa 100%); + border: 1px solid rgba(160, 210, 240, 0.7); + color: #0e3d5f; + border-radius: 10px; +} + +/* Rules banner */ +.dashboard-page .rules-banner { + background: linear-gradient(135deg, #f0f8ff 0%, #e4f2fd 100%); + border: 1px solid rgba(160, 210, 240, 0.6); +} +.dashboard-page .rules-pill { + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(180, 215, 238, 0.7); + color: #1e4a6a; +} + +/* Category chips */ +.dashboard-page .category-chip { + background: rgba(255, 255, 255, 0.75) !important; + border: 1px solid rgba(160, 205, 235, 0.75) !important; + color: #1a4a6e !important; + font-weight: 600; +} +.dashboard-page .category-chip.active { + background: linear-gradient(135deg, #1675b4 0%, #0f5b94 100%) !important; + border-color: transparent !important; + color: #fff !important; + box-shadow: 0 2px 8px rgba(15, 91, 148, 0.35); +} + +/* Session notice */ +.dashboard-page .session-notice { + background: #e4f3ff; + border-color: #aad4f0; + color: #174768; +} + +/* Service tiles — clean white cards with depth */ +.dashboard-page .tile { + backdrop-filter: none !important; + background: #ffffff !important; + border: 1px solid rgba(185, 215, 238, 0.7) !important; + box-shadow: + 0 1px 3px rgba(15, 64, 103, 0.06), + 0 6px 20px rgba(15, 64, 103, 0.09) !important; + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease !important; +} + +.dashboard-page .tile-wrap:hover .tile { + transform: translateY(-3px) !important; + border-color: rgba(42, 130, 210, 0.45) !important; + box-shadow: + 0 2px 6px rgba(15, 64, 103, 0.08), + 0 12px 32px rgba(15, 64, 103, 0.16) !important; +} + +/* Icon box */ +.dashboard-page .tile-icon-box { + background: #f2f8fd !important; + border-color: rgba(185, 215, 238, 0.65) !important; +} + +/* Credentials block */ +.dashboard-page .svc-credentials { + background: linear-gradient(135deg, #edf6ff 0%, #e2f0fb 100%) !important; + border-color: rgba(180, 215, 240, 0.8) !important; +} + +/* Category badges on tile */ +.dashboard-page .service-cat-badge { + background: #edf5fc; + border-color: rgba(175, 210, 235, 0.8); + color: #1e4a6e; +} + +/* Made by footer */ +.dashboard-page .made-by { + color: rgba(20, 60, 95, 0.38) !important; + text-shadow: 0 1px 4px rgba(255,255,255,0.5) !important; + font-family: "Segoe UI", sans-serif !important; + font-size: 0.82rem !important; + font-weight: 500 !important; + letter-spacing: 0.04em; +} +.dashboard-page .made-by:hover { + color: rgba(15, 91, 148, 0.65) !important; +} + +/* Suppress transitions on non-tile elements */ .category-chip, .category-chip.active, .btn-link, .btn-link.secondary, button { transition: none !important; - transform: none !important; -} -.tile:hover { - box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06) !important; - border-color: transparent !important; } -/* 4-up desktop grid with adaptive breakpoints */ +/* 4-up desktop grid */ .service-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } @media (max-width: 1400px) { - .service-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } + .service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 1050px) { - .service-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + .service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 700px) { - .service-grid { - grid-template-columns: 1fr; - } + .service-grid { grid-template-columns: 1fr; } } /* ========== Login page redesign ========== */ @@ -832,7 +914,7 @@ button { /* LEFT PANEL */ .login-left { - flex: 0 0 33.333%; + flex: 0 0 25%; background: linear-gradient(150deg, #050d1a 0%, #091829 55%, #0e2344 100%); position: relative; display: flex; @@ -918,6 +1000,29 @@ button { font-size: 0.9rem; } + +.login-distrib-btn { + margin-top: 2.5rem; + padding: 0.78rem 1.5rem; + border: 1px solid rgba(255,255,255,0.28); + border-radius: 10px; + background: rgba(255,255,255,0.07); + color: rgba(220,238,255,0.9); + font-size: 0.88rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + width: 100%; + letter-spacing: 0.03em; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); + transition: background 0.2s, border-color 0.2s, color 0.2s; +} +.login-distrib-btn:hover { + background: rgba(255,255,255,0.13); + border-color: rgba(255,255,255,0.45); + color: #fff; +} + /* RIGHT PANEL */ .login-right { flex: 1; diff --git a/app/templates/login.html b/app/templates/login.html index 318ab5a..810726e 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -35,7 +35,7 @@