a working product with ugly ui

This commit is contained in:
2025-12-12 20:15:27 +05:00
parent e6d04f986f
commit 4d3085623a
77 changed files with 8750 additions and 0 deletions

0
app/__init__.py Normal file
View File

39
app/config.py Normal file
View File

@@ -0,0 +1,39 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Database
DATABASE_URL: str
# Security
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Registration
ALLOW_REGISTRATION: bool = False
# CORS
ALLOWED_ORIGINS: str = "http://localhost:8000"
# Environment
ENVIRONMENT: str = "development"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True
)
@property
def cors_origins(self) -> List[str]:
"""Parse comma-separated CORS origins into a list."""
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
# Global settings instance
settings = Settings()

29
app/database.py Normal file
View File

@@ -0,0 +1,29 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
# Create SQLAlchemy engine
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
echo=settings.ENVIRONMENT == "development"
)
# Create SessionLocal class
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
def get_db():
"""
Dependency function to get database session.
Yields a database session and ensures it's closed after use.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

94
app/dependencies.py Normal file
View File

@@ -0,0 +1,94 @@
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from jose import JWTError, jwt
from app.database import get_db
from app.config import settings
from app.models.user import User
# Security scheme for JWT
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""
Dependency to get the current authenticated user from JWT token.
Raises HTTP 401 if token is invalid or user not found.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# Decode JWT token
token = credentials.credentials
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Dependency to get the current active user.
Can be extended to check if user is active/banned.
"""
# Add user.is_active check here if needed in the future
return current_user
async def get_current_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Dependency to get the current admin user.
Raises HTTP 403 if user is not an admin.
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
async def get_optional_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
db: Session = Depends(get_db)
) -> Optional[User]:
"""
Dependency to optionally get the current user.
Returns None if no token provided or token is invalid.
Useful for endpoints that work for both authenticated and guest users.
"""
if credentials is None:
return None
try:
token = credentials.credentials
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
return None
user = db.query(User).filter(User.id == user_id).first()
return user
except JWTError:
return None

44
app/main.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, maps, items
# Create FastAPI application
app = FastAPI(
title="ISP Wiremap API",
description="API for ISP cable and network infrastructure mapping",
version="1.0.0"
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
app.include_router(auth.router)
app.include_router(maps.router)
app.include_router(items.router)
@app.get("/")
async def root():
"""Root endpoint for API health check."""
return {
"message": "ISP Wiremap API",
"version": "1.0.0",
"status": "running"
}
@app.get("/api/health")
async def health_check():
"""Health check endpoint for monitoring."""
return {
"status": "healthy",
"environment": settings.ENVIRONMENT
}

7
app/models/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from app.models.user import User
from app.models.map import Map
from app.models.map_item import MapItem
from app.models.share import Share
from app.models.session import Session
__all__ = ["User", "Map", "MapItem", "Share", "Session"]

19
app/models/map.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.database import Base
class Map(Base):
"""Map model for storing map metadata and ownership."""
__tablename__ = "maps"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
is_default_public = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

46
app/models/map_item.py Normal file
View File

@@ -0,0 +1,46 @@
from sqlalchemy import Column, String, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from geoalchemy2 import Geography
import uuid
from app.database import Base
class MapItem(Base):
"""
Map item model for storing cables, switches, APs, and wireless mesh.
Types:
- cable: Fiber, Cat6, Cat6 PoE
- wireless_mesh: Wireless connections between APs
- switch: Network switch
- indoor_ap: Indoor access point
- outdoor_ap: Outdoor access point
Geometry:
- Point for devices (switches, APs)
- LineString for cables and wireless mesh
Properties (JSONB):
- For cables: cable_type, name, notes, length_meters, start_device_id, end_device_id
- For devices: name, notes, port_count, connections (array of {cable_id, port_number})
- For wireless_mesh: name, notes, start_ap_id, end_ap_id
"""
__tablename__ = "map_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True)
type = Column(String(50), nullable=False, index=True)
# PostGIS geography column for spatial data
# Using GeometryCollection to support both Point and LineString geometries
geometry = Column(Geography(geometry_type='GEOMETRY', srid=4326), nullable=False)
# JSONB column for flexible properties based on item type
properties = Column(JSONB, nullable=False, server_default='{}')
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)

22
app/models/session.py Normal file
View File

@@ -0,0 +1,22 @@
from sqlalchemy import Column, String, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.database import Base
class Session(Base):
"""Session model for tracking WebSocket connections and user presence."""
__tablename__ = "sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True)
map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True)
socket_id = Column(String(255), nullable=False)
# Denormalized username for quick access
username = Column(String(50), nullable=True)
connected_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
last_seen = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

27
app/models/share.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.database import Base
class Share(Base):
"""Share model for managing map access via share links."""
__tablename__ = "shares"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True)
share_token = Column(String(64), unique=True, nullable=False, index=True)
# Access levels: 'read_only', 'edit', 'guest_read_only'
access_level = Column(String(20), nullable=False)
# Whether authentication is required to use this share link
requires_auth = Column(Boolean, default=True, nullable=False)
# Optional expiration date
expires_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)

19
app/models/user.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.database import Base
class User(Base):
"""User model for authentication and ownership."""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

0
app/routers/__init__.py Normal file
View File

80
app/routers/auth.py Normal file
View File

@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.schemas.auth import LoginRequest, TokenResponse
from app.schemas.user import UserCreate, UserResponse, UserWithToken
from app.services.auth_service import authenticate_user, create_user, create_tokens_for_user
from app.models.user import User
router = APIRouter(prefix="/api/auth", tags=["authentication"])
@router.post("/register", response_model=UserWithToken, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
db: Session = Depends(get_db)
):
"""
Register a new user.
Registration must be enabled via ALLOW_REGISTRATION environment variable.
"""
user = create_user(db, user_data)
tokens = create_tokens_for_user(user)
return UserWithToken(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
created_at=user.created_at,
updated_at=user.updated_at,
access_token=tokens.access_token,
refresh_token=tokens.refresh_token,
token_type=tokens.token_type
)
@router.post("/login", response_model=TokenResponse)
async def login(
credentials: LoginRequest,
db: Session = Depends(get_db)
):
"""
Login with username and password.
Returns JWT access and refresh tokens.
"""
user = authenticate_user(db, credentials.username, credentials.password)
tokens = create_tokens_for_user(user)
return tokens
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""
Get current authenticated user information.
Requires valid JWT token in Authorization header.
"""
return UserResponse(
id=current_user.id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
created_at=current_user.created_at,
updated_at=current_user.updated_at
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_access_token(
current_user: User = Depends(get_current_user)
):
"""
Refresh access token using a valid refresh token.
Returns new access and refresh tokens.
"""
tokens = create_tokens_for_user(current_user)
return tokens

90
app/routers/items.py Normal file
View File

@@ -0,0 +1,90 @@
"""Map items router for CRUD operations."""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_optional_current_user
from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate, MapItemResponse
from app.services import item_service
from app.services.item_service import geography_to_geojson
router = APIRouter(prefix="/api/maps/{map_id}/items", tags=["map-items"])
def format_item_response(item) -> dict:
"""Format map item for response with GeoJSON geometry."""
return {
"id": str(item.id),
"map_id": str(item.map_id),
"type": item.type,
"geometry": geography_to_geojson(item.geometry),
"properties": item.properties,
"created_at": item.created_at.isoformat(),
"updated_at": item.updated_at.isoformat(),
"created_by": str(item.created_by) if item.created_by else None,
"updated_by": str(item.updated_by) if item.updated_by else None,
}
@router.get("", response_model=List[dict])
async def list_map_items(
map_id: UUID,
current_user: User = Depends(get_optional_current_user),
db: Session = Depends(get_db)
):
"""Get all items for a map."""
items = item_service.get_map_items(db, map_id, current_user)
return [format_item_response(item) for item in items]
@router.post("", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_map_item(
map_id: UUID,
item_data: MapItemCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new map item."""
item = item_service.create_map_item(db, map_id, item_data, current_user)
return format_item_response(item)
@router.get("/{item_id}", response_model=dict)
async def get_map_item(
map_id: UUID,
item_id: UUID,
current_user: User = Depends(get_optional_current_user),
db: Session = Depends(get_db)
):
"""Get a specific map item."""
item = item_service.get_map_item_by_id(db, item_id, current_user)
return format_item_response(item)
@router.patch("/{item_id}", response_model=dict)
async def update_map_item(
map_id: UUID,
item_id: UUID,
item_data: MapItemUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update a map item."""
item = item_service.update_map_item(db, item_id, item_data, current_user)
return format_item_response(item)
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_map_item(
map_id: UUID,
item_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a map item."""
item_service.delete_map_item(db, item_id, current_user)
return None

94
app/routers/maps.py Normal file
View File

@@ -0,0 +1,94 @@
"""Maps router for CRUD operations."""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_optional_current_user
from app.models.user import User
from app.schemas.map import MapCreate, MapUpdate, MapResponse
from app.services import map_service
router = APIRouter(prefix="/api/maps", tags=["maps"])
@router.get("", response_model=List[MapResponse])
async def list_user_maps(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all maps owned by the current user."""
maps = map_service.get_user_maps(db, current_user.id)
return maps
@router.post("", response_model=MapResponse, status_code=status.HTTP_201_CREATED)
async def create_map(
map_data: MapCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new map."""
new_map = map_service.create_map(db, map_data, current_user.id)
return new_map
@router.get("/public", response_model=MapResponse)
async def get_public_map(db: Session = Depends(get_db)):
"""Get the default public map (no authentication required)."""
public_map = map_service.get_default_public_map(db)
if not public_map:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No public map available"
)
return public_map
@router.get("/{map_id}", response_model=MapResponse)
async def get_map(
map_id: UUID,
current_user: User = Depends(get_optional_current_user),
db: Session = Depends(get_db)
):
"""Get a specific map by ID. Requires authentication unless it's the public map."""
map_obj = map_service.get_map_by_id(db, map_id, current_user)
return map_obj
@router.patch("/{map_id}", response_model=MapResponse)
async def update_map(
map_id: UUID,
map_data: MapUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update a map. Only the owner or admin can update."""
updated_map = map_service.update_map(db, map_id, map_data, current_user)
return updated_map
@router.delete("/{map_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_map(
map_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a map. Only the owner or admin can delete."""
map_service.delete_map(db, map_id, current_user)
return None
@router.post("/{map_id}/set-default-public", response_model=MapResponse)
async def set_default_public(
map_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Set a map as the default public map. Admin only."""
updated_map = map_service.set_default_public_map(db, map_id, current_user)
return updated_map

0
app/schemas/__init__.py Normal file
View File

19
app/schemas/auth.py Normal file
View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
"""Request schema for user login."""
username: str
password: str
class TokenResponse(BaseModel):
"""Response schema for authentication tokens."""
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Schema for JWT token payload data."""
user_id: str

32
app/schemas/map.py Normal file
View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
from uuid import UUID
class MapBase(BaseModel):
"""Base map schema with common attributes."""
name: str
description: Optional[str] = None
class MapCreate(MapBase):
"""Schema for creating a new map."""
pass
class MapUpdate(BaseModel):
"""Schema for updating map information."""
name: Optional[str] = None
description: Optional[str] = None
class MapResponse(MapBase):
"""Response schema for map data."""
id: UUID
owner_id: UUID
is_default_public: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

35
app/schemas/map_item.py Normal file
View File

@@ -0,0 +1,35 @@
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, Dict, Any
from datetime import datetime
from uuid import UUID
class MapItemBase(BaseModel):
"""Base map item schema with common attributes."""
type: str = Field(..., description="Item type: cable, wireless_mesh, switch, indoor_ap, outdoor_ap")
geometry: Dict[str, Any] = Field(..., description="GeoJSON geometry (Point or LineString)")
properties: Dict[str, Any] = Field(default_factory=dict, description="Item-specific properties")
class MapItemCreate(MapItemBase):
"""Schema for creating a new map item."""
pass
class MapItemUpdate(BaseModel):
"""Schema for updating map item information."""
type: Optional[str] = None
geometry: Optional[Dict[str, Any]] = None
properties: Optional[Dict[str, Any]] = None
class MapItemResponse(MapItemBase):
"""Response schema for map item data."""
id: UUID
map_id: UUID
created_at: datetime
updated_at: datetime
created_by: Optional[UUID] = None
updated_by: Optional[UUID] = None
model_config = ConfigDict(from_attributes=True)

43
app/schemas/share.py Normal file
View File

@@ -0,0 +1,43 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
from uuid import UUID
class ShareBase(BaseModel):
"""Base share schema with common attributes."""
access_level: str # 'read_only', 'edit', 'guest_read_only'
requires_auth: bool = True
expires_at: Optional[datetime] = None
class ShareCreate(ShareBase):
"""Schema for creating a new share link."""
pass
class ShareUpdate(BaseModel):
"""Schema for updating share information."""
access_level: Optional[str] = None
requires_auth: Optional[bool] = None
expires_at: Optional[datetime] = None
class ShareResponse(ShareBase):
"""Response schema for share data."""
id: UUID
map_id: UUID
share_token: str
created_at: datetime
created_by: Optional[UUID] = None
model_config = ConfigDict(from_attributes=True)
class ShareValidateResponse(BaseModel):
"""Response schema for share token validation."""
valid: bool
access_level: Optional[str] = None
map_id: Optional[UUID] = None
requires_auth: bool = False
expired: bool = False

39
app/schemas/user.py Normal file
View File

@@ -0,0 +1,39 @@
from pydantic import BaseModel, EmailStr, ConfigDict
from typing import Optional
from datetime import datetime
from uuid import UUID
class UserBase(BaseModel):
"""Base user schema with common attributes."""
username: str
email: EmailStr
class UserCreate(UserBase):
"""Schema for creating a new user."""
password: str
class UserUpdate(BaseModel):
"""Schema for updating user information."""
username: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = None
class UserResponse(UserBase):
"""Response schema for user data (without sensitive info)."""
id: UUID
is_admin: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class UserWithToken(UserResponse):
"""User response with authentication tokens."""
access_token: str
refresh_token: str
token_type: str = "bearer"

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,106 @@
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.user import User
from app.schemas.user import UserCreate
from app.schemas.auth import TokenResponse
from app.utils.password import hash_password, verify_password
from app.utils.security import create_access_token, create_refresh_token
from app.config import settings
def authenticate_user(db: Session, username: str, password: str) -> User:
"""
Authenticate a user by username and password.
Args:
db: Database session
username: Username
password: Plain text password
Returns:
User object if authentication successful
Raises:
HTTPException: If authentication fails
"""
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def create_user(db: Session, user_data: UserCreate) -> User:
"""
Create a new user.
Args:
db: Database session
user_data: User creation data
Returns:
Created user object
Raises:
HTTPException: If username or email already exists
"""
# Check if registration is allowed
if not settings.ALLOW_REGISTRATION:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User registration is currently disabled"
)
# Check if username already exists
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
hashed_password = hash_password(user_data.password)
db_user = User(
username=user_data.username,
email=user_data.email,
password_hash=hashed_password,
is_admin=False
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def create_tokens_for_user(user: User) -> TokenResponse:
"""
Create access and refresh tokens for a user.
Args:
user: User object
Returns:
TokenResponse with access and refresh tokens
"""
access_token = create_access_token(str(user.id))
refresh_token = create_refresh_token(str(user.id))
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)

View File

@@ -0,0 +1,227 @@
"""Map item service for business logic."""
from typing import List, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import shape, Point, LineString
import json
from app.models.map_item import MapItem
from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate
from app.services.map_service import get_map_by_id
def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]:
"""Get all items for a map."""
# Verify user has access to the map
get_map_by_id(db, map_id, user)
items = db.query(MapItem).filter(MapItem.map_id == map_id).all()
return items
def get_map_item_by_id(db: Session, item_id: UUID, user: Optional[User] = None) -> MapItem:
"""Get a map item by ID."""
item = db.query(MapItem).filter(MapItem.id == item_id).first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Map item not found"
)
# Verify user has access to the map
get_map_by_id(db, item.map_id, user)
return item
def geojson_to_geography(geojson: dict) -> str:
"""Convert GeoJSON geometry to PostGIS geography WKT."""
geom = shape(geojson)
# Ensure coordinates are in the correct format for PostGIS (lon, lat)
if isinstance(geom, Point):
return f'SRID=4326;POINT({geom.x} {geom.y})'
elif isinstance(geom, LineString):
coords = ', '.join([f'{x} {y}' for x, y in geom.coords])
return f'SRID=4326;LINESTRING({coords})'
else:
raise ValueError(f"Unsupported geometry type: {type(geom)}")
def geography_to_geojson(geography) -> dict:
"""Convert PostGIS geography to GeoJSON."""
geom = to_shape(geography)
return json.loads(json.dumps(geom.__geo_interface__))
def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem:
"""Create a new map item."""
# Verify user has access to the map
get_map_by_id(db, map_id, user)
# Convert GeoJSON to PostGIS geography
geometry_wkt = geojson_to_geography(item_data.geometry)
item = MapItem(
map_id=map_id,
type=item_data.type,
geometry=geometry_wkt,
properties=item_data.properties,
created_by=user.id,
updated_by=user.id
)
db.add(item)
db.commit()
db.refresh(item)
# If this is a cable with device connections, update device port tracking
if item.type == 'cable':
start_device_id = item.properties.get('start_device_id')
end_device_id = item.properties.get('end_device_id')
print(f"Cable created: start_device_id={start_device_id}, end_device_id={end_device_id}")
if start_device_id:
print(f"Updating port connections for start device: {start_device_id}")
update_device_connections(db, UUID(start_device_id), item.id)
if end_device_id:
print(f"Updating port connections for end device: {end_device_id}")
update_device_connections(db, UUID(end_device_id), item.id)
return item
def update_device_connections(db: Session, device_id: UUID, cable_id: UUID) -> None:
"""Add cable connection to device's connections array."""
print(f"update_device_connections called: device_id={device_id}, cable_id={cable_id}")
device = db.query(MapItem).filter(MapItem.id == device_id).first()
if not device:
print(f"Device not found: {device_id}")
return
# Create a mutable copy of properties
properties = dict(device.properties) if device.properties else {}
connections = properties.get('connections', [])
port_count = properties.get('port_count', 0)
print(f"Device {device_id}: port_count={port_count}, current_connections={len(connections)}")
# Find next available port
used_ports = {conn.get('port_number') for conn in connections if isinstance(conn, dict)}
next_port = 1
while next_port in used_ports and next_port <= port_count:
next_port += 1
# Only add if there's an available port
if next_port <= port_count:
connections.append({
'cable_id': str(cable_id),
'port_number': next_port
})
properties['connections'] = connections
# Mark the column as modified so SQLAlchemy detects the change
from sqlalchemy.orm.attributes import flag_modified
device.properties = properties
flag_modified(device, 'properties')
db.commit()
db.refresh(device)
print(f"Added connection to port {next_port}. Total connections now: {len(connections)}")
print(f"Device properties after update: {device.properties}")
else:
print(f"No available ports! Port count: {port_count}, used: {len(connections)}")
def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user: User) -> MapItem:
"""Update a map item."""
item = get_map_item_by_id(db, item_id, user)
# Update fields if provided
if item_data.type is not None:
item.type = item_data.type
if item_data.geometry is not None:
item.geometry = geojson_to_geography(item_data.geometry)
if item_data.properties is not None:
item.properties = item_data.properties
item.updated_by = user.id
db.commit()
db.refresh(item)
return item
def delete_map_item(db: Session, item_id: UUID, user: User) -> None:
"""Delete a map item."""
item = get_map_item_by_id(db, item_id, user)
# If deleting a cable, remove it from device connections
if item.type == 'cable':
start_device_id = item.properties.get('start_device_id')
end_device_id = item.properties.get('end_device_id')
if start_device_id:
remove_device_connection(db, UUID(start_device_id), item.id)
if end_device_id:
remove_device_connection(db, UUID(end_device_id), item.id)
# If deleting an AP, delete all associated wireless mesh links
if item.type in ['indoor_ap', 'outdoor_ap']:
print(f"Deleting AP {item.id}, checking for wireless mesh links...")
# Find all wireless mesh links connected to this AP
wireless_meshes = db.query(MapItem).filter(
MapItem.map_id == item.map_id,
MapItem.type == 'wireless_mesh'
).all()
for mesh in wireless_meshes:
start_ap_id = mesh.properties.get('start_ap_id')
end_ap_id = mesh.properties.get('end_ap_id')
if start_ap_id == str(item.id) or end_ap_id == str(item.id):
print(f"Deleting wireless mesh {mesh.id} connected to AP {item.id}")
db.delete(mesh)
db.delete(item)
db.commit()
def remove_device_connection(db: Session, device_id: UUID, cable_id: UUID) -> None:
"""Remove cable connection from device's connections array."""
print(f"remove_device_connection called: device_id={device_id}, cable_id={cable_id}")
device = db.query(MapItem).filter(MapItem.id == device_id).first()
if not device:
print(f"Device not found: {device_id}")
return
# Create a mutable copy of properties
properties = dict(device.properties) if device.properties else {}
connections = properties.get('connections', [])
print(f"Before removal: {len(connections)} connections")
# Filter out the cable connection
connections = [
conn for conn in connections
if isinstance(conn, dict) and conn.get('cable_id') != str(cable_id)
]
print(f"After removal: {len(connections)} connections")
properties['connections'] = connections
# Mark the column as modified so SQLAlchemy detects the change
from sqlalchemy.orm.attributes import flag_modified
device.properties = properties
flag_modified(device, 'properties')
db.commit()
db.refresh(device)
print(f"Removed cable connection. Device now has {len(connections)} connections")

103
app/services/map_service.py Normal file
View File

@@ -0,0 +1,103 @@
"""Map service for business logic."""
from typing import List, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.map import Map
from app.models.user import User
from app.schemas.map import MapCreate, MapUpdate
def get_user_maps(db: Session, user_id: UUID) -> List[Map]:
"""Get all maps owned by a user."""
return db.query(Map).filter(Map.owner_id == user_id).order_by(Map.updated_at.desc()).all()
def get_map_by_id(db: Session, map_id: UUID, user: Optional[User] = None) -> Map:
"""Get a map by ID with optional authorization check."""
map_obj = db.query(Map).filter(Map.id == map_id).first()
if not map_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Map not found"
)
# If user is provided, check authorization
if user:
if map_obj.owner_id != user.id and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this map"
)
return map_obj
def get_default_public_map(db: Session) -> Optional[Map]:
"""Get the default public map (first map with is_default_public=True)."""
return db.query(Map).filter(Map.is_default_public == True).first()
def create_map(db: Session, map_data: MapCreate, user_id: UUID) -> Map:
"""Create a new map."""
map_obj = Map(
name=map_data.name,
description=map_data.description,
owner_id=user_id,
is_default_public=False
)
db.add(map_obj)
db.commit()
db.refresh(map_obj)
return map_obj
def update_map(db: Session, map_id: UUID, map_data: MapUpdate, user: User) -> Map:
"""Update a map. Only owner or admin can update."""
map_obj = get_map_by_id(db, map_id, user)
# Update fields if provided
if map_data.name is not None:
map_obj.name = map_data.name
if map_data.description is not None:
map_obj.description = map_data.description
db.commit()
db.refresh(map_obj)
return map_obj
def delete_map(db: Session, map_id: UUID, user: User) -> None:
"""Delete a map. Only owner or admin can delete."""
map_obj = get_map_by_id(db, map_id, user)
db.delete(map_obj)
db.commit()
def set_default_public_map(db: Session, map_id: UUID, user: User) -> Map:
"""Set a map as the default public map. Only admins can do this."""
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can set the default public map"
)
# Get the map (admin check is done in get_map_by_id)
map_obj = get_map_by_id(db, map_id, user)
# Unset any existing default public maps
db.query(Map).filter(Map.is_default_public == True).update({"is_default_public": False})
# Set this map as default public
map_obj.is_default_public = True
db.commit()
db.refresh(map_obj)
return map_obj

0
app/utils/__init__.py Normal file
View File

31
app/utils/password.py Normal file
View File

@@ -0,0 +1,31 @@
from passlib.context import CryptContext
# Password hashing context using bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""
Hash a plain password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plain password against a hashed password.
Args:
plain_password: Plain text password to verify
hashed_password: Hashed password from database
Returns:
True if password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)

69
app/utils/security.py Normal file
View File

@@ -0,0 +1,69 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from app.config import settings
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
user_id: User ID to encode in the token
expires_delta: Optional custom expiration time delta
Returns:
Encoded JWT token string
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {
"sub": user_id,
"exp": expire,
"type": "access"
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(user_id: str) -> str:
"""
Create a JWT refresh token with longer expiration.
Args:
user_id: User ID to encode in the token
Returns:
Encoded JWT refresh token string
"""
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {
"sub": user_id,
"exp": expire,
"type": "refresh"
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> dict:
"""
Decode and verify a JWT token.
Args:
token: JWT token string
Returns:
Decoded token payload
Raises:
JWTError: If token is invalid or expired
"""
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload

View File