from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import create_engine, Column, String, Text, DateTime, ForeignKey from sqlalchemy.orm import DeclarativeBase, Session, relationship from pydantic import BaseModel, Field from typing import Optional import uuid, os, time from datetime import datetime DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://netdoc:netdocpass@db/netdoc") def get_engine(): for i in range(30): try: e = create_engine(DATABASE_URL) with e.connect(): pass return e except Exception: if i == 29: raise print(f"Waiting for DB... ({i+1}/30)") time.sleep(2) engine = get_engine() class Base(DeclarativeBase): pass class Site(Base): __tablename__ = "sites" id = Column(String, primary_key=True) name = Column(String, nullable=False) location = Column(String) contact = Column(String) phone = Column(String) email = Column(String) notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) devices = relationship("Device", back_populates="site", cascade="all, delete-orphan", order_by="Device.created_at") subnets = relationship("Subnet", back_populates="site", cascade="all, delete-orphan", order_by="Subnet.created_at") vlans = relationship("Vlan", back_populates="site", cascade="all, delete-orphan", order_by="Vlan.created_at") isp_connections = relationship("IspConnection", back_populates="site", cascade="all, delete-orphan", order_by="IspConnection.created_at") credentials = relationship("Credential", back_populates="site", cascade="all, delete-orphan", order_by="Credential.created_at") notes_list = relationship("Note", back_populates="site", cascade="all, delete-orphan", order_by="Note.created_at") class Device(Base): __tablename__ = "devices" id = Column(String, primary_key=True) site_id = Column(String, ForeignKey("sites.id"), nullable=False) hostname = Column(String, nullable=False) ip = Column(String, nullable=False) type = Column(String) status = Column(String) mac = Column(String) vlan = Column(String) model = Column(String) location = Column(String) notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) site = relationship("Site", back_populates="devices") class Subnet(Base): __tablename__ = "subnets" id = Column(String, primary_key=True) site_id = Column(String, ForeignKey("sites.id"), nullable=False) name = Column(String, nullable=False) network = Column(String, nullable=False) gateway = Column(String) dhcp_start = Column(String) dhcp_end = Column(String) dns = Column(String) vlan = Column(String) notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) site = relationship("Site", back_populates="subnets") class Vlan(Base): __tablename__ = "vlans" id = Column(String, primary_key=True) site_id = Column(String, ForeignKey("sites.id"), nullable=False) vlan_num = Column(String) name = Column(String, nullable=False) purpose = Column(String) ports = Column(String) notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) site = relationship("Site", back_populates="vlans") class IspConnection(Base): __tablename__ = "isp_connections" id = Column(String, primary_key=True) site_id = Column(String, ForeignKey("sites.id"), nullable=False) label = Column(String, nullable=False) provider = Column(String) type = Column(String) status = Column(String) ips = Column(String) gw = Column(String) dl = Column(String) ul = Column(String) circuit = Column(String) acct = Column(String) phone = Column(String) cost = Column(String) renew = Column(String) notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) site = relationship("Site", back_populates="isp_connections") class Credential(Base): __tablename__ = "credentials" id = Column(String, primary_key=True) site_id = Column(String, ForeignKey("sites.id"), nullable=False) label = Column(String, nullable=False) cat = Column(String) username = Column(String) password = Column(String) url = Column(String) enable = Column(String) notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) site = relationship("Site", back_populates="credentials") class Note(Base): __tablename__ = "notes" id = Column(String, primary_key=True) site_id = Column(String, ForeignKey("sites.id"), nullable=False) title = Column(String, nullable=False) content = Column(Text, default="") created_at = Column(DateTime, default=datetime.utcnow) site = relationship("Site", back_populates="notes_list") Base.metadata.create_all(engine) app = FastAPI() app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) # ── Pydantic schemas ────────────────────────────────────────────────────────── class SiteCreate(BaseModel): name: str location: Optional[str] = None contact: Optional[str] = None phone: Optional[str] = None email: Optional[str] = None notes: Optional[str] = None class DeviceCreate(BaseModel): hostname: str ip: str type: Optional[str] = None status: Optional[str] = None mac: Optional[str] = None vlan: Optional[str] = None model: Optional[str] = None location: Optional[str] = None notes: Optional[str] = None class SubnetCreate(BaseModel): name: str network: str gateway: Optional[str] = None dhcpStart: Optional[str] = None dhcpEnd: Optional[str] = None dns: Optional[str] = None vlan: Optional[str] = None notes: Optional[str] = None class VlanCreate(BaseModel): id: str # VLAN number (1-4094) name: str purpose: Optional[str] = None ports: Optional[str] = None notes: Optional[str] = None class IspCreate(BaseModel): label: str provider: Optional[str] = None type: Optional[str] = None status: Optional[str] = None ips: Optional[str] = None gw: Optional[str] = None dl: Optional[str] = None ul: Optional[str] = None circuit: Optional[str] = None acct: Optional[str] = None phone: Optional[str] = None cost: Optional[str] = None renew: Optional[str] = None notes: Optional[str] = None class CredCreate(BaseModel): label: str cat: Optional[str] = None user: Optional[str] = None password: Optional[str] = None url: Optional[str] = None enable: Optional[str] = None notes: Optional[str] = None class NoteCreate(BaseModel): title: str content: Optional[str] = None # ── Serializers ─────────────────────────────────────────────────────────────── def new_id(): return str(uuid.uuid4()) def s(val): return val or "" def site_to_dict(site: Site, full=False): d = { "_id": site.id, "name": site.name, "location": s(site.location), "contact": s(site.contact), "phone": s(site.phone), "email": s(site.email), "notes": s(site.notes), } if full: d["devices"] = [device_to_dict(x) for x in site.devices] d["subnets"] = [subnet_to_dict(x) for x in site.subnets] d["vlans"] = [vlan_to_dict(x) for x in site.vlans] d["isp"] = [isp_to_dict(x) for x in site.isp_connections] d["creds"] = [cred_to_dict(x) for x in site.credentials] d["notes"] = [note_to_dict(x) for x in site.notes_list] return d def device_to_dict(d: Device): return {"_id": d.id, "hostname": d.hostname, "ip": d.ip, "type": s(d.type), "status": s(d.status), "mac": s(d.mac), "vlan": s(d.vlan), "model": s(d.model), "location": s(d.location), "notes": s(d.notes)} def subnet_to_dict(x: Subnet): return {"_id": x.id, "name": x.name, "network": x.network, "gateway": s(x.gateway), "dhcpStart": s(x.dhcp_start), "dhcpEnd": s(x.dhcp_end), "dns": s(x.dns), "vlan": s(x.vlan), "notes": s(x.notes)} def vlan_to_dict(x: Vlan): return {"_id": x.id, "id": s(x.vlan_num), "name": x.name, "purpose": s(x.purpose), "ports": s(x.ports), "notes": s(x.notes)} def isp_to_dict(x: IspConnection): return {"_id": x.id, "label": x.label, "provider": s(x.provider), "type": s(x.type), "status": s(x.status), "ips": s(x.ips), "gw": s(x.gw), "dl": s(x.dl), "ul": s(x.ul), "circuit": s(x.circuit), "acct": s(x.acct), "phone": s(x.phone), "cost": s(x.cost), "renew": s(x.renew), "notes": s(x.notes)} def cred_to_dict(x: Credential): return {"_id": x.id, "label": x.label, "cat": s(x.cat), "user": s(x.username), "pass": s(x.password), "url": s(x.url), "enable": s(x.enable), "notes": s(x.notes)} def note_to_dict(x: Note): return {"_id": x.id, "title": x.title, "content": s(x.content)} # ── Sites ───────────────────────────────────────────────────────────────────── @app.get("/api/sites") def list_sites(): with Session(engine) as sess: return [site_to_dict(x) for x in sess.query(Site).order_by(Site.created_at).all()] @app.post("/api/sites", status_code=201) def create_site(data: SiteCreate): with Session(engine) as sess: site = Site(id=new_id(), **data.model_dump()) sess.add(site) sess.commit() sess.refresh(site) return site_to_dict(site, full=True) @app.get("/api/sites/{site_id}") def get_site(site_id: str): with Session(engine) as sess: site = sess.query(Site).filter(Site.id == site_id).first() if not site: raise HTTPException(404, "Site not found") return site_to_dict(site, full=True) @app.delete("/api/sites/{site_id}") def delete_site(site_id: str): with Session(engine) as sess: site = sess.query(Site).filter(Site.id == site_id).first() if not site: raise HTTPException(404) sess.delete(site) sess.commit() return {"ok": True} # ── Devices ─────────────────────────────────────────────────────────────────── @app.post("/api/sites/{site_id}/devices", status_code=201) def create_device(site_id: str, data: DeviceCreate): with Session(engine) as sess: if not sess.query(Site).filter(Site.id == site_id).first(): raise HTTPException(404) d = Device(id=new_id(), site_id=site_id, **data.model_dump()) sess.add(d) sess.commit() sess.refresh(d) return device_to_dict(d) @app.put("/api/sites/{site_id}/devices/{device_id}") def update_device(site_id: str, device_id: str, data: DeviceCreate): with Session(engine) as sess: d = sess.query(Device).filter(Device.id == device_id, Device.site_id == site_id).first() if not d: raise HTTPException(404) for k, v in data.model_dump().items(): setattr(d, k, v) sess.commit() sess.refresh(d) return device_to_dict(d) @app.delete("/api/sites/{site_id}/devices/{device_id}") def delete_device(site_id: str, device_id: str): with Session(engine) as sess: d = sess.query(Device).filter(Device.id == device_id, Device.site_id == site_id).first() if not d: raise HTTPException(404) sess.delete(d) sess.commit() return {"ok": True} # ── Subnets ─────────────────────────────────────────────────────────────────── @app.post("/api/sites/{site_id}/subnets", status_code=201) def create_subnet(site_id: str, data: SubnetCreate): with Session(engine) as sess: if not sess.query(Site).filter(Site.id == site_id).first(): raise HTTPException(404) x = Subnet(id=new_id(), site_id=site_id, name=data.name, network=data.network, gateway=data.gateway, dhcp_start=data.dhcpStart, dhcp_end=data.dhcpEnd, dns=data.dns, vlan=data.vlan, notes=data.notes) sess.add(x) sess.commit() sess.refresh(x) return subnet_to_dict(x) @app.put("/api/sites/{site_id}/subnets/{subnet_id}") def update_subnet(site_id: str, subnet_id: str, data: SubnetCreate): with Session(engine) as sess: x = sess.query(Subnet).filter(Subnet.id == subnet_id, Subnet.site_id == site_id).first() if not x: raise HTTPException(404) x.name = data.name x.network = data.network x.gateway = data.gateway x.dhcp_start = data.dhcpStart x.dhcp_end = data.dhcpEnd x.dns = data.dns x.vlan = data.vlan x.notes = data.notes sess.commit() sess.refresh(x) return subnet_to_dict(x) @app.delete("/api/sites/{site_id}/subnets/{subnet_id}") def delete_subnet(site_id: str, subnet_id: str): with Session(engine) as sess: x = sess.query(Subnet).filter(Subnet.id == subnet_id, Subnet.site_id == site_id).first() if not x: raise HTTPException(404) sess.delete(x) sess.commit() return {"ok": True} # ── VLANs ───────────────────────────────────────────────────────────────────── @app.post("/api/sites/{site_id}/vlans", status_code=201) def create_vlan(site_id: str, data: VlanCreate): with Session(engine) as sess: if not sess.query(Site).filter(Site.id == site_id).first(): raise HTTPException(404) x = Vlan(id=new_id(), site_id=site_id, vlan_num=data.id, name=data.name, purpose=data.purpose, ports=data.ports, notes=data.notes) sess.add(x) sess.commit() sess.refresh(x) return vlan_to_dict(x) @app.put("/api/sites/{site_id}/vlans/{vlan_id}") def update_vlan(site_id: str, vlan_id: str, data: VlanCreate): with Session(engine) as sess: x = sess.query(Vlan).filter(Vlan.id == vlan_id, Vlan.site_id == site_id).first() if not x: raise HTTPException(404) x.vlan_num = data.id x.name = data.name x.purpose = data.purpose x.ports = data.ports x.notes = data.notes sess.commit() sess.refresh(x) return vlan_to_dict(x) @app.delete("/api/sites/{site_id}/vlans/{vlan_id}") def delete_vlan(site_id: str, vlan_id: str): with Session(engine) as sess: x = sess.query(Vlan).filter(Vlan.id == vlan_id, Vlan.site_id == site_id).first() if not x: raise HTTPException(404) sess.delete(x) sess.commit() return {"ok": True} # ── ISP Connections ─────────────────────────────────────────────────────────── @app.post("/api/sites/{site_id}/isp", status_code=201) def create_isp(site_id: str, data: IspCreate): with Session(engine) as sess: if not sess.query(Site).filter(Site.id == site_id).first(): raise HTTPException(404) x = IspConnection(id=new_id(), site_id=site_id, **data.model_dump()) sess.add(x) sess.commit() sess.refresh(x) return isp_to_dict(x) @app.put("/api/sites/{site_id}/isp/{isp_id}") def update_isp(site_id: str, isp_id: str, data: IspCreate): with Session(engine) as sess: x = sess.query(IspConnection).filter(IspConnection.id == isp_id, IspConnection.site_id == site_id).first() if not x: raise HTTPException(404) for k, v in data.model_dump().items(): setattr(x, k, v) sess.commit() sess.refresh(x) return isp_to_dict(x) @app.delete("/api/sites/{site_id}/isp/{isp_id}") def delete_isp(site_id: str, isp_id: str): with Session(engine) as sess: x = sess.query(IspConnection).filter(IspConnection.id == isp_id, IspConnection.site_id == site_id).first() if not x: raise HTTPException(404) sess.delete(x) sess.commit() return {"ok": True} # ── Credentials ─────────────────────────────────────────────────────────────── @app.post("/api/sites/{site_id}/creds", status_code=201) def create_cred(site_id: str, data: CredCreate): with Session(engine) as sess: if not sess.query(Site).filter(Site.id == site_id).first(): raise HTTPException(404) x = Credential(id=new_id(), site_id=site_id, label=data.label, cat=data.cat, username=data.user, password=data.password, url=data.url, enable=data.enable, notes=data.notes) sess.add(x) sess.commit() sess.refresh(x) return cred_to_dict(x) @app.put("/api/sites/{site_id}/creds/{cred_id}") def update_cred(site_id: str, cred_id: str, data: CredCreate): with Session(engine) as sess: x = sess.query(Credential).filter(Credential.id == cred_id, Credential.site_id == site_id).first() if not x: raise HTTPException(404) x.label = data.label x.cat = data.cat x.username = data.user x.password = data.password x.url = data.url x.enable = data.enable x.notes = data.notes sess.commit() sess.refresh(x) return cred_to_dict(x) @app.delete("/api/sites/{site_id}/creds/{cred_id}") def delete_cred(site_id: str, cred_id: str): with Session(engine) as sess: x = sess.query(Credential).filter(Credential.id == cred_id, Credential.site_id == site_id).first() if not x: raise HTTPException(404) sess.delete(x) sess.commit() return {"ok": True} # ── Notes ───────────────────────────────────────────────────────────────────── @app.post("/api/sites/{site_id}/notes", status_code=201) def create_note(site_id: str, data: NoteCreate): with Session(engine) as sess: if not sess.query(Site).filter(Site.id == site_id).first(): raise HTTPException(404) x = Note(id=new_id(), site_id=site_id, title=data.title, content=data.content or "") sess.add(x) sess.commit() sess.refresh(x) return note_to_dict(x) @app.put("/api/sites/{site_id}/notes/{note_id}") def update_note(site_id: str, note_id: str, data: NoteCreate): with Session(engine) as sess: x = sess.query(Note).filter(Note.id == note_id, Note.site_id == site_id).first() if not x: raise HTTPException(404) x.title = data.title x.content = data.content or "" sess.commit() sess.refresh(x) return note_to_dict(x) @app.delete("/api/sites/{site_id}/notes/{note_id}") def delete_note(site_id: str, note_id: str): with Session(engine) as sess: x = sess.query(Note).filter(Note.id == note_id, Note.site_id == site_id).first() if not x: raise HTTPException(404) sess.delete(x) sess.commit() return {"ok": True} # ── Export / Import ─────────────────────────────────────────────────────────── @app.get("/api/export") def export_all(): with Session(engine) as sess: sites = sess.query(Site).order_by(Site.created_at).all() result = {"tenants": {}, "order": []} for site in sites: result["order"].append(site.id) result["tenants"][site.id] = site_to_dict(site, full=True) return result @app.post("/api/import") def import_all(data: dict): with Session(engine) as sess: for site_id in data.get("order", []): t = data.get("tenants", {}).get(site_id, {}) if sess.query(Site).filter(Site.id == site_id).first(): continue site = Site(id=site_id, name=t.get("name", "Imported Site"), location=t.get("location"), contact=t.get("contact"), phone=t.get("phone"), email=t.get("email"), notes=t.get("notes")) sess.add(site) for d in t.get("devices", []): sess.add(Device(id=new_id(), site_id=site_id, hostname=d.get("hostname", ""), ip=d.get("ip", ""), type=d.get("type"), status=d.get("status"), mac=d.get("mac"), vlan=d.get("vlan"), model=d.get("model"), location=d.get("location"), notes=d.get("notes"))) for x in t.get("subnets", []): sess.add(Subnet(id=new_id(), site_id=site_id, name=x.get("name", ""), network=x.get("network", ""), gateway=x.get("gateway"), dhcp_start=x.get("dhcpStart"), dhcp_end=x.get("dhcpEnd"), dns=x.get("dns"), vlan=x.get("vlan"), notes=x.get("notes"))) for x in t.get("vlans", []): sess.add(Vlan(id=new_id(), site_id=site_id, vlan_num=x.get("id"), name=x.get("name", ""), purpose=x.get("purpose"), ports=x.get("ports"), notes=x.get("notes"))) for x in t.get("isp", []): sess.add(IspConnection(id=new_id(), site_id=site_id, label=x.get("label", ""), provider=x.get("provider"), type=x.get("type"), status=x.get("status"), ips=x.get("ips"), gw=x.get("gw"), dl=x.get("dl"), ul=x.get("ul"), circuit=x.get("circuit"), acct=x.get("acct"), phone=x.get("phone"), cost=x.get("cost"), renew=x.get("renew"), notes=x.get("notes"))) for x in t.get("creds", []): sess.add(Credential(id=new_id(), site_id=site_id, label=x.get("label", ""), cat=x.get("cat"), username=x.get("user"), password=x.get("pass"), url=x.get("url"), enable=x.get("enable"), notes=x.get("notes"))) for x in t.get("notes", []): sess.add(Note(id=new_id(), site_id=site_id, title=x.get("title", "Imported Note"), content=x.get("content", ""))) sess.commit() return {"ok": True}