regirstation works but shared links broken

This commit is contained in:
2025-12-12 20:38:35 +05:00
parent 4d3085623a
commit 1f088c8fb0
23 changed files with 1739 additions and 5 deletions

View File

@@ -92,3 +92,20 @@ async def get_optional_current_user(
return user
except JWTError:
return None
def get_user_from_token(token: str, db: Session) -> Optional[User]:
"""
Get user from JWT token string (for WebSocket authentication).
Returns None if token is invalid.
"""
try:
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

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, maps, items
from app.routers import auth, maps, items, map_share, websocket
# Create FastAPI application
app = FastAPI(
@@ -23,6 +23,8 @@ app.add_middleware(
app.include_router(auth.router)
app.include_router(maps.router)
app.include_router(items.router)
app.include_router(map_share.router)
app.include_router(websocket.router)
@app.get("/")

42
app/models/map_share.py Normal file
View File

@@ -0,0 +1,42 @@
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
import enum
from app.database import Base
class SharePermission(str, enum.Enum):
"""Permission levels for map sharing."""
READ = "read" # Read-only access
EDIT = "edit" # Can edit map items
class MapShare(Base):
"""Map sharing with specific users."""
__tablename__ = "map_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)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
permission = Column(SQLEnum(SharePermission), nullable=False, default=SharePermission.READ)
shared_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
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)
class MapShareLink(Base):
"""Public/guest share links for maps."""
__tablename__ = "map_share_links"
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)
token = Column(String(64), unique=True, nullable=False, index=True) # Random token for the share URL
permission = Column(SQLEnum(SharePermission), nullable=False, default=SharePermission.READ)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True) # Optional expiration
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)

102
app/routers/map_share.py Normal file
View File

@@ -0,0 +1,102 @@
"""Map sharing routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from uuid import UUID
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.schemas.map_share import (
MapShareCreate,
MapShareUpdate,
MapShareResponse,
MapShareLinkCreate,
MapShareLinkResponse
)
from app.services import map_share_service
router = APIRouter(prefix="/api/maps/{map_id}/share", tags=["map-sharing"])
@router.post("/users", response_model=MapShareResponse, status_code=status.HTTP_201_CREATED)
async def share_map_with_user(
map_id: UUID,
share_data: MapShareCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Share a map with a specific user."""
share = map_share_service.create_map_share(db, map_id, share_data, current_user)
return share
@router.get("/users", response_model=List[MapShareResponse])
async def get_map_shares_list(
map_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all users this map is shared with."""
shares = map_share_service.get_map_shares(db, map_id, current_user)
return shares
@router.put("/users/{share_id}", response_model=MapShareResponse)
async def update_map_share_permissions(
map_id: UUID,
share_id: UUID,
update_data: MapShareUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update share permissions for a user."""
share = map_share_service.update_map_share(db, map_id, share_id, update_data, current_user)
return share
@router.delete("/users/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_map_share(
map_id: UUID,
share_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Revoke map share from a user."""
map_share_service.delete_map_share(db, map_id, share_id, current_user)
return None
@router.post("/links", response_model=MapShareLinkResponse, status_code=status.HTTP_201_CREATED)
async def create_map_share_link(
map_id: UUID,
link_data: MapShareLinkCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a public/guest share link."""
link = map_share_service.create_share_link(db, map_id, link_data, current_user)
return link
@router.get("/links", response_model=List[MapShareLinkResponse])
async def get_map_share_links_list(
map_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all share links for a map."""
links = map_share_service.get_share_links(db, map_id, current_user)
return links
@router.delete("/links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_map_share_link(
map_id: UUID,
link_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a share link."""
map_share_service.delete_share_link(db, map_id, link_id, current_user)
return None

View File

@@ -9,6 +9,7 @@ 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
from app.services import map_share_service
router = APIRouter(prefix="/api/maps", tags=["maps"])
@@ -49,6 +50,16 @@ async def get_public_map(db: Session = Depends(get_db)):
return public_map
@router.get("/shared/{token}", response_model=MapResponse)
async def get_shared_map(
token: str,
db: Session = Depends(get_db)
):
"""Get a map by share token (no authentication required)."""
map_obj, permission = map_share_service.get_map_by_share_token(db, token)
return map_obj
@router.get("/{map_id}", response_model=MapResponse)
async def get_map(
map_id: UUID,

77
app/routers/websocket.py Normal file
View File

@@ -0,0 +1,77 @@
"""WebSocket routes for real-time updates."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from sqlalchemy.orm import Session
from uuid import UUID
from typing import Optional
import logging
from app.database import get_db
from app.websocket.connection_manager import manager
from app.services.map_share_service import check_map_access
from app.dependencies import get_user_from_token
logger = logging.getLogger(__name__)
router = APIRouter(tags=["websocket"])
@router.websocket("/ws/maps/{map_id}")
async def websocket_endpoint(
websocket: WebSocket,
map_id: UUID,
token: Optional[str] = Query(None),
share_token: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""
WebSocket endpoint for real-time map updates.
Clients can connect using:
- JWT token (authenticated users)
- Share token (guest access)
Example: ws://localhost:8000/ws/maps/{map_id}?token={jwt_token}
Example: ws://localhost:8000/ws/maps/{map_id}?share_token={share_token}
"""
# Verify access to the map
user = None
if token:
try:
user = get_user_from_token(token, db)
except Exception as e:
logger.error(f"Invalid token: {e}")
await websocket.close(code=1008, reason="Invalid token")
return
# Check map access
has_access, permission = check_map_access(db, map_id, user, share_token)
if not has_access:
await websocket.close(code=1008, reason="Access denied")
return
await manager.connect(websocket, map_id)
try:
# Send initial connection message
await websocket.send_json({
"type": "connected",
"data": {
"map_id": str(map_id),
"permission": permission.value
}
})
# Keep connection alive and listen for messages
while True:
# Receive messages (for potential future use like cursor position, etc.)
data = await websocket.receive_json()
# Echo back for now (can add more features later)
logger.info(f"Received message from client: {data}")
except WebSocketDisconnect:
manager.disconnect(websocket, map_id)
logger.info(f"Client disconnected from map {map_id}")
except Exception as e:
logger.error(f"WebSocket error: {e}")
manager.disconnect(websocket, map_id)

53
app/schemas/map_share.py Normal file
View File

@@ -0,0 +1,53 @@
"""Schemas for map sharing."""
from pydantic import BaseModel
from uuid import UUID
from datetime import datetime
from typing import Optional
from app.models.map_share import SharePermission
class MapShareCreate(BaseModel):
"""Schema for creating a map share with a specific user."""
user_id: UUID
permission: SharePermission = SharePermission.READ
class MapShareUpdate(BaseModel):
"""Schema for updating map share permissions."""
permission: SharePermission
class MapShareResponse(BaseModel):
"""Schema for map share response."""
id: UUID
map_id: UUID
user_id: UUID
permission: SharePermission
shared_by: Optional[UUID]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class MapShareLinkCreate(BaseModel):
"""Schema for creating a public share link."""
permission: SharePermission = SharePermission.READ
expires_at: Optional[datetime] = None
class MapShareLinkResponse(BaseModel):
"""Schema for share link response."""
id: UUID
map_id: UUID
token: str
permission: SharePermission
is_active: bool
created_by: Optional[UUID]
expires_at: Optional[datetime]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -6,11 +6,13 @@ from fastapi import HTTPException, status
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import shape, Point, LineString
import json
import asyncio
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
from app.websocket.connection_manager import manager
def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]:
@@ -58,6 +60,19 @@ def geography_to_geojson(geography) -> dict:
return json.loads(json.dumps(geom.__geo_interface__))
def item_to_dict(item: MapItem) -> dict:
"""Convert MapItem to JSON-serializable dict for WebSocket broadcast."""
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()
}
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
@@ -93,6 +108,14 @@ def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: U
print(f"Updating port connections for end device: {end_device_id}")
update_device_connections(db, UUID(end_device_id), item.id)
# Broadcast item creation to WebSocket clients
try:
loop = asyncio.get_event_loop()
loop.create_task(manager.send_item_created(map_id, item_to_dict(item)))
except RuntimeError:
# No event loop running, skip WebSocket broadcast
pass
return item
@@ -155,6 +178,14 @@ def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user:
db.commit()
db.refresh(item)
# Broadcast item update to WebSocket clients
try:
loop = asyncio.get_event_loop()
loop.create_task(manager.send_item_updated(item.map_id, item_to_dict(item)))
except RuntimeError:
# No event loop running, skip WebSocket broadcast
pass
return item
@@ -162,6 +193,10 @@ 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)
# Capture map_id and item_id before deletion for WebSocket broadcast
map_id = item.map_id
deleted_item_id = str(item.id)
# If deleting a cable, remove it from device connections
if item.type == 'cable':
start_device_id = item.properties.get('start_device_id')
@@ -192,6 +227,14 @@ def delete_map_item(db: Session, item_id: UUID, user: User) -> None:
db.delete(item)
db.commit()
# Broadcast item deletion to WebSocket clients
try:
loop = asyncio.get_event_loop()
loop.create_task(manager.send_item_deleted(map_id, deleted_item_id))
except RuntimeError:
# No event loop running, skip WebSocket broadcast
pass
def remove_device_connection(db: Session, device_id: UUID, cable_id: UUID) -> None:
"""Remove cable connection from device's connections array."""

View File

@@ -0,0 +1,299 @@
"""Map sharing service for business logic."""
from typing import List, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from datetime import datetime
import secrets
from app.models.map_share import MapShare, MapShareLink, SharePermission
from app.models.user import User
from app.models.map import Map
from app.schemas.map_share import MapShareCreate, MapShareUpdate, MapShareLinkCreate
from app.services.map_service import get_map_by_id
def generate_share_token() -> str:
"""Generate a random share token."""
return secrets.token_urlsafe(32)
def create_map_share(
db: Session,
map_id: UUID,
share_data: MapShareCreate,
current_user: User
) -> MapShare:
"""Share a map with a specific user."""
# Verify user owns the map
map_obj = get_map_by_id(db, map_id, current_user)
if map_obj.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the map owner can share it"
)
# Check if user exists
target_user = db.query(User).filter(User.id == share_data.user_id).first()
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check if already shared
existing_share = db.query(MapShare).filter(
MapShare.map_id == map_id,
MapShare.user_id == share_data.user_id
).first()
if existing_share:
# Update existing share
existing_share.permission = share_data.permission
existing_share.updated_at = datetime.utcnow()
db.commit()
db.refresh(existing_share)
return existing_share
# Create new share
share = MapShare(
map_id=map_id,
user_id=share_data.user_id,
permission=share_data.permission,
shared_by=current_user.id
)
db.add(share)
db.commit()
db.refresh(share)
return share
def get_map_shares(db: Session, map_id: UUID, current_user: User) -> List[MapShare]:
"""Get all shares for a map."""
# Verify user owns the map
map_obj = get_map_by_id(db, map_id, current_user)
if map_obj.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the map owner can view shares"
)
shares = db.query(MapShare).filter(MapShare.map_id == map_id).all()
return shares
def update_map_share(
db: Session,
map_id: UUID,
share_id: UUID,
update_data: MapShareUpdate,
current_user: User
) -> MapShare:
"""Update map share permissions."""
# Verify user owns the map
map_obj = get_map_by_id(db, map_id, current_user)
if map_obj.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the map owner can update shares"
)
share = db.query(MapShare).filter(
MapShare.id == share_id,
MapShare.map_id == map_id
).first()
if not share:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share not found"
)
share.permission = update_data.permission
share.updated_at = datetime.utcnow()
db.commit()
db.refresh(share)
return share
def delete_map_share(
db: Session,
map_id: UUID,
share_id: UUID,
current_user: User
) -> None:
"""Revoke map share."""
# Verify user owns the map
map_obj = get_map_by_id(db, map_id, current_user)
if map_obj.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the map owner can revoke shares"
)
share = db.query(MapShare).filter(
MapShare.id == share_id,
MapShare.map_id == map_id
).first()
if not share:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share not found"
)
db.delete(share)
db.commit()
def create_share_link(
db: Session,
map_id: UUID,
link_data: MapShareLinkCreate,
current_user: User
) -> MapShareLink:
"""Create a public/guest share link."""
# Verify user owns the map
map_obj = get_map_by_id(db, map_id, current_user)
if map_obj.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the map owner can create share links"
)
token = generate_share_token()
link = MapShareLink(
map_id=map_id,
token=token,
permission=link_data.permission,
is_active=True,
created_by=current_user.id,
expires_at=link_data.expires_at
)
db.add(link)
db.commit()
db.refresh(link)
return link
def get_share_links(db: Session, map_id: UUID, current_user: User) -> List[MapShareLink]:
"""Get all share links for a map."""
# Verify user owns the map
map_obj = get_map_by_id(db, map_id, current_user)
if map_obj.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the map owner can view share links"
)
links = db.query(MapShareLink).filter(MapShareLink.map_id == map_id).all()
return links
def delete_share_link(
db: Session,
map_id: UUID,
link_id: UUID,
current_user: User
) -> None:
"""Delete a share link."""
# Verify user owns the map
map_obj = get_map_by_id(db, map_id, current_user)
if map_obj.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the map owner can delete share links"
)
link = db.query(MapShareLink).filter(
MapShareLink.id == link_id,
MapShareLink.map_id == map_id
).first()
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share link not found"
)
db.delete(link)
db.commit()
def get_map_by_share_token(db: Session, token: str) -> tuple[Map, SharePermission]:
"""Get map by share token (for guest access)."""
link = db.query(MapShareLink).filter(
MapShareLink.token == token,
MapShareLink.is_active == True
).first()
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid or expired share link"
)
# Check if link is expired
if link.expires_at and link.expires_at < datetime.utcnow():
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Share link has expired"
)
map_obj = db.query(Map).filter(Map.id == link.map_id).first()
if not map_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Map not found"
)
return map_obj, link.permission
def check_map_access(
db: Session,
map_id: UUID,
user: Optional[User] = None,
token: Optional[str] = None
) -> tuple[bool, SharePermission]:
"""
Check if user has access to a map and return their permission level.
Returns (has_access, permission_level)
"""
map_obj = db.query(Map).filter(Map.id == map_id).first()
if not map_obj:
return False, SharePermission.READ
# Owner has full edit access
if user and map_obj.owner_id == user.id:
return True, SharePermission.EDIT
# Check user share
if user:
share = db.query(MapShare).filter(
MapShare.map_id == map_id,
MapShare.user_id == user.id
).first()
if share:
return True, share.permission
# Check share token
if token:
try:
_, permission = get_map_by_share_token(db, token)
return True, permission
except HTTPException:
pass
# Check if map is public
if map_obj.is_default_public:
return True, SharePermission.READ
return False, SharePermission.READ

View File

@@ -0,0 +1,85 @@
"""WebSocket connection manager for real-time map updates."""
from fastapi import WebSocket
from typing import Dict, List, Set
from uuid import UUID
import json
import logging
logger = logging.getLogger(__name__)
class ConnectionManager:
"""Manages WebSocket connections for real-time map updates."""
def __init__(self):
# map_id -> Set of WebSocket connections
self.active_connections: Dict[str, Set[WebSocket]] = {}
async def connect(self, websocket: WebSocket, map_id: UUID):
"""Accept a new WebSocket connection for a map."""
await websocket.accept()
map_key = str(map_id)
if map_key not in self.active_connections:
self.active_connections[map_key] = set()
self.active_connections[map_key].add(websocket)
logger.info(f"Client connected to map {map_id}. Total connections: {len(self.active_connections[map_key])}")
def disconnect(self, websocket: WebSocket, map_id: UUID):
"""Remove a WebSocket connection."""
map_key = str(map_id)
if map_key in self.active_connections:
self.active_connections[map_key].discard(websocket)
if not self.active_connections[map_key]:
del self.active_connections[map_key]
logger.info(f"Client disconnected from map {map_id}")
async def broadcast_to_map(self, map_id: UUID, message: dict):
"""Broadcast a message to all clients connected to a specific map."""
map_key = str(map_id)
if map_key not in self.active_connections:
return
# Create a copy of the set to avoid modification during iteration
connections = self.active_connections[map_key].copy()
disconnected = []
for connection in connections:
try:
await connection.send_json(message)
except Exception as e:
logger.error(f"Error sending message to client: {e}")
disconnected.append(connection)
# Remove disconnected clients
for connection in disconnected:
self.disconnect(connection, map_id)
async def send_item_created(self, map_id: UUID, item_data: dict):
"""Notify clients that a new item was created."""
await self.broadcast_to_map(map_id, {
"type": "item_created",
"data": item_data
})
async def send_item_updated(self, map_id: UUID, item_data: dict):
"""Notify clients that an item was updated."""
await self.broadcast_to_map(map_id, {
"type": "item_updated",
"data": item_data
})
async def send_item_deleted(self, map_id: UUID, item_id: str):
"""Notify clients that an item was deleted."""
await self.broadcast_to_map(map_id, {
"type": "item_deleted",
"data": {"id": item_id}
})
# Global connection manager instance
manager = ConnectionManager()