a working product with ugly ui
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
39
app/config.py
Normal file
39
app/config.py
Normal 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
29
app/database.py
Normal 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
94
app/dependencies.py
Normal 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
44
app/main.py
Normal 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
7
app/models/__init__.py
Normal 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
19
app/models/map.py
Normal 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
46
app/models/map_item.py
Normal 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
22
app/models/session.py
Normal 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
27
app/models/share.py
Normal 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
19
app/models/user.py
Normal 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
0
app/routers/__init__.py
Normal file
80
app/routers/auth.py
Normal file
80
app/routers/auth.py
Normal 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
90
app/routers/items.py
Normal 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
94
app/routers/maps.py
Normal 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
0
app/schemas/__init__.py
Normal file
19
app/schemas/auth.py
Normal file
19
app/schemas/auth.py
Normal 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
32
app/schemas/map.py
Normal 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
35
app/schemas/map_item.py
Normal 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
43
app/schemas/share.py
Normal 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
39
app/schemas/user.py
Normal 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
0
app/services/__init__.py
Normal file
106
app/services/auth_service.py
Normal file
106
app/services/auth_service.py
Normal 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"
|
||||
)
|
||||
227
app/services/item_service.py
Normal file
227
app/services/item_service.py
Normal 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
103
app/services/map_service.py
Normal 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
0
app/utils/__init__.py
Normal file
31
app/utils/password.py
Normal file
31
app/utils/password.py
Normal 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
69
app/utils/security.py
Normal 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
|
||||
0
app/websocket/__init__.py
Normal file
0
app/websocket/__init__.py
Normal file
Reference in New Issue
Block a user