Phase 4: cloud backend — licensing, heartbeat, site management
- New cloud_backend/ FastAPI service on port 8001 (SQLite for dev, swappable to PostgreSQL) - Endpoints: sysadmin auth (JWT), site registration, lock/unlock, heartbeat (X-Site-ID + X-Site-Key headers) - Default sysadmin seeded on first startup from ADMIN_USERNAME/ADMIN_PASSWORD env vars - cloud_backend added to docker-compose.yml with persistent data volume at ./data/cloud/ - local_backend cloud_sync.py updated to use correct /api/heartbeat/ endpoint with header auth - local_backend config.py: added SITE_KEY setting - Smoke tested: login, register site, heartbeat, lock, unlock, list all pass
This commit is contained in:
14
cloud_backend/.env.example
Normal file
14
cloud_backend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Cloud Backend — copy this to .env and fill in values
|
||||||
|
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SECRET_KEY=change-me-generate-a-long-random-string
|
||||||
|
|
||||||
|
# SQLite for local dev. Switch to postgresql://user:pass@host/dbname for VPS.
|
||||||
|
DATABASE_URL=sqlite:////app/data/cloud.db
|
||||||
|
|
||||||
|
# JWT expiry in minutes
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
|
||||||
|
# Default sysadmin credentials (created on first startup if admin table is empty)
|
||||||
|
ADMIN_USERNAME=sysadmin
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
10
cloud_backend/Dockerfile
Normal file
10
cloud_backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
44
cloud_backend/auth_utils.py
Normal file
44
cloud_backend/auth_utils.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from models.admin import Admin
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
payload = data.copy()
|
||||||
|
payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_admin(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> Admin:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
admin = db.query(Admin).filter(Admin.username == username).first()
|
||||||
|
if not admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin not found")
|
||||||
|
return admin
|
||||||
17
cloud_backend/config.py
Normal file
17
cloud_backend/config.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
_HERE = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||||
|
DATABASE_URL: str = "sqlite:////app/data/cloud.db"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||||
|
ADMIN_USERNAME: str = "sysadmin"
|
||||||
|
ADMIN_PASSWORD: str = "changeme"
|
||||||
|
|
||||||
|
model_config = {"env_file": str(_HERE / ".env")}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
17
cloud_backend/database.py
Normal file
17
cloud_backend/database.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
|
||||||
|
|
||||||
|
engine = create_engine(settings.DATABASE_URL, connect_args=connect_args)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
51
cloud_backend/main.py
Normal file
51
cloud_backend/main.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import engine, Base
|
||||||
|
from auth_utils import hash_password
|
||||||
|
import models.admin # noqa: F401
|
||||||
|
import models.site # noqa: F401
|
||||||
|
|
||||||
|
from routers import auth, sites, heartbeat
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_default_admin():
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models.admin import Admin
|
||||||
|
|
||||||
|
with Session(engine) as db:
|
||||||
|
if not db.query(Admin).filter(Admin.username == settings.ADMIN_USERNAME).first():
|
||||||
|
db.add(Admin(
|
||||||
|
username=settings.ADMIN_USERNAME,
|
||||||
|
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||||
|
role="sysadmin",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_seed_default_admin()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="POS Cloud Backend", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
|
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
|
||||||
|
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
0
cloud_backend/models/__init__.py
Normal file
0
cloud_backend/models/__init__.py
Normal file
11
cloud_backend/models/admin.py
Normal file
11
cloud_backend/models/admin.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Admin(Base):
|
||||||
|
__tablename__ = "admins"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String, unique=True, nullable=False)
|
||||||
|
password_hash = Column(String, nullable=False)
|
||||||
|
role = Column(String, default="sysadmin")
|
||||||
21
cloud_backend/models/site.py
Normal file
21
cloud_backend/models/site.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Site(Base):
|
||||||
|
__tablename__ = "sites"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
site_id = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
owner_name = Column(String, nullable=False)
|
||||||
|
contact_email = Column(String, nullable=False)
|
||||||
|
secret_key_hash = Column(String, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_locked = Column(Boolean, default=False)
|
||||||
|
lock_reason = Column(String, nullable=True)
|
||||||
|
license_expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_seen_ip = Column(String, nullable=True)
|
||||||
10
cloud_backend/requirements.txt
Normal file
10
cloud_backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
sqlalchemy==2.0.35
|
||||||
|
pydantic==2.9.2
|
||||||
|
pydantic-settings==2.5.2
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
httpx==0.27.2
|
||||||
0
cloud_backend/routers/__init__.py
Normal file
0
cloud_backend/routers/__init__.py
Normal file
18
cloud_backend/routers/auth.py
Normal file
18
cloud_backend/routers/auth.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from auth_utils import verify_password, create_access_token
|
||||||
|
from database import get_db
|
||||||
|
from models.admin import Admin
|
||||||
|
from schemas.admin import LoginRequest, TokenOut
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenOut)
|
||||||
|
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||||
|
admin = db.query(Admin).filter(Admin.username == body.username).first()
|
||||||
|
if not admin or not verify_password(body.password, admin.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
token = create_access_token({"sub": admin.username, "role": admin.role})
|
||||||
|
return TokenOut(access_token=token)
|
||||||
37
cloud_backend/routers/heartbeat.py
Normal file
37
cloud_backend/routers/heartbeat.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.site import Site
|
||||||
|
from schemas.site import HeartbeatRequest, HeartbeatResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=HeartbeatResponse)
|
||||||
|
def heartbeat(
|
||||||
|
body: HeartbeatRequest,
|
||||||
|
request: Request,
|
||||||
|
x_site_id: str = Header(..., alias="X-Site-ID"),
|
||||||
|
x_site_key: str = Header(..., alias="X-Site-Key"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
site = db.query(Site).filter(Site.site_id == x_site_id).first()
|
||||||
|
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
site.last_seen_at = now
|
||||||
|
site.last_seen_ip = request.client.host if request.client else None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
licensed = site.is_active and (site.license_expires_at.replace(tzinfo=timezone.utc) > now)
|
||||||
|
return HeartbeatResponse(
|
||||||
|
licensed=licensed,
|
||||||
|
locked=site.is_locked,
|
||||||
|
lock_reason=site.lock_reason,
|
||||||
|
expires_at=site.license_expires_at,
|
||||||
|
)
|
||||||
90
cloud_backend/routers/sites.py
Normal file
90
cloud_backend/routers/sites.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from auth_utils import get_current_admin
|
||||||
|
from database import get_db
|
||||||
|
from models.site import Site
|
||||||
|
from schemas.site import SiteCreate, SiteUpdate, SiteOut, SiteCreatedOut, LockRequest
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[SiteOut])
|
||||||
|
def list_sites(db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
return db.query(Site).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=SiteCreatedOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_site(body: SiteCreate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
raw_key = secrets.token_urlsafe(32)
|
||||||
|
site = Site(
|
||||||
|
site_id=str(uuid.uuid4()),
|
||||||
|
name=body.name,
|
||||||
|
owner_name=body.owner_name,
|
||||||
|
contact_email=body.contact_email,
|
||||||
|
secret_key_hash=_pwd.hash(raw_key),
|
||||||
|
license_expires_at=body.license_expires_at,
|
||||||
|
)
|
||||||
|
db.add(site)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(site)
|
||||||
|
data = SiteOut.model_validate(site).model_dump()
|
||||||
|
data["secret_key"] = raw_key
|
||||||
|
return SiteCreatedOut(**data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{site_id}", response_model=SiteOut)
|
||||||
|
def get_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{site_id}", response_model=SiteOut)
|
||||||
|
def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(site, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(site)
|
||||||
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{site_id}/lock", response_model=SiteOut)
|
||||||
|
def lock_site(site_id: str, body: LockRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
site.is_locked = True
|
||||||
|
site.lock_reason = body.reason
|
||||||
|
db.commit()
|
||||||
|
db.refresh(site)
|
||||||
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{site_id}/unlock", response_model=SiteOut)
|
||||||
|
def unlock_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
site.is_locked = False
|
||||||
|
site.lock_reason = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(site)
|
||||||
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{site_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||||
|
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
db.delete(site)
|
||||||
|
db.commit()
|
||||||
0
cloud_backend/schemas/__init__.py
Normal file
0
cloud_backend/schemas/__init__.py
Normal file
11
cloud_backend/schemas/admin.py
Normal file
11
cloud_backend/schemas/admin.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenOut(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
53
cloud_backend/schemas/site.py
Normal file
53
cloud_backend/schemas/site.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SiteCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
owner_name: str
|
||||||
|
contact_email: str
|
||||||
|
license_expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SiteUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
owner_name: str | None = None
|
||||||
|
contact_email: str | None = None
|
||||||
|
license_expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SiteOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
site_id: str
|
||||||
|
name: str
|
||||||
|
owner_name: str
|
||||||
|
contact_email: str
|
||||||
|
is_active: bool
|
||||||
|
is_locked: bool
|
||||||
|
lock_reason: str | None
|
||||||
|
license_expires_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
last_seen_ip: str | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SiteCreatedOut(SiteOut):
|
||||||
|
secret_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class LockRequest(BaseModel):
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatRequest(BaseModel):
|
||||||
|
version: str = "1.0.0"
|
||||||
|
uptime_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatResponse(BaseModel):
|
||||||
|
licensed: bool
|
||||||
|
locked: bool
|
||||||
|
lock_reason: str | None
|
||||||
|
expires_at: datetime
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
|
cloud_backend:
|
||||||
|
build: ./cloud_backend
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./cloud_backend/.env
|
||||||
|
volumes:
|
||||||
|
- ./data/cloud:/app/data
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./local_backend
|
build: ./local_backend
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ else:
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
SITE_ID: str = ""
|
SITE_ID: str = ""
|
||||||
|
SITE_KEY: str = ""
|
||||||
CLOUD_URL: str = ""
|
CLOUD_URL: str = ""
|
||||||
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||||
LICENSE_GRACE_HOURS: int = 24
|
LICENSE_GRACE_HOURS: int = 24
|
||||||
|
|||||||
@@ -46,8 +46,12 @@ async def _sync_once():
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{settings.CLOUD_URL}/api/sites/heartbeat",
|
f"{settings.CLOUD_URL}/api/heartbeat/",
|
||||||
json={"site_id": settings.SITE_ID},
|
headers={
|
||||||
|
"X-Site-ID": settings.SITE_ID,
|
||||||
|
"X-Site-Key": settings.SITE_KEY,
|
||||||
|
},
|
||||||
|
json={"version": "1.0.0", "uptime_seconds": 0},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|||||||
Reference in New Issue
Block a user